From cb71a9b547b48aec752bee1c0b19790766705ef2 Mon Sep 17 00:00:00 2001 From: kumarin Date: Mon, 31 Aug 2026 11:55:34 +0530 Subject: [PATCH 1/2] OTWO-7731 Application Potentially Vulnerable to BREACH Attack (CVE-2013-3587) --- app/middleware/suppress_api_compression.rb | 43 ++++++++++++++++++++++ config/application.rb | 4 ++ 2 files changed, 47 insertions(+) create mode 100644 app/middleware/suppress_api_compression.rb diff --git a/app/middleware/suppress_api_compression.rb b/app/middleware/suppress_api_compression.rb new file mode 100644 index 000000000..d5a8109e9 --- /dev/null +++ b/app/middleware/suppress_api_compression.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# BREACH mitigation (CVE-2013-3587). +# +# HTTP compression over HTTPS creates a side-channel: an attacker who can observe +# compressed response sizes can infer secrets in the response body (BREACH attack). +# +# This middleware disables gzip compression for all /api/* responses by: +# 1. Removing gzip from Accept-Encoding before the request reaches Rack::Deflater, +# preventing Rails-level compression. +# 2. Setting Content-Encoding: identity on the response, signalling nginx not to +# apply gzip compression at the infrastructure layer. +class SuppressApiCompression + API_PATH_PREFIX = '/api/' + + def initialize(app) + @app = app + end + + def call(env) + if env['PATH_INFO'].start_with?(API_PATH_PREFIX) + env['HTTP_ACCEPT_ENCODING'] = strip_gzip(env['HTTP_ACCEPT_ENCODING']) + end + + status, headers, body = @app.call(env) + + headers['Content-Encoding'] = 'identity' if env['PATH_INFO'].start_with?(API_PATH_PREFIX) + + [status, headers, body] + end + + private + + def strip_gzip(accept_encoding) + return accept_encoding if accept_encoding.blank? + + result = accept_encoding + .gsub(/gzip\s*(;\s*q\s*=\s*[\d.]+)?\s*,?\s*/i, '') + .gsub(/,\s*$/, '') + .strip + result.presence + end +end diff --git a/config/application.rb b/config/application.rb index afaf2992e..6ef366b0b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -16,6 +16,10 @@ class Application < Rails::Application require_relative '../app/middleware/mime_type_sanitizer' config.middleware.insert_before ActionDispatch::Static, MimeTypeSanitizer + # BREACH mitigation: disable gzip for API responses (CVE-2013-3587) + require_relative '../app/middleware/suppress_api_compression' + config.middleware.insert_before Rack::Deflater, SuppressApiCompression + config.load_defaults 6.1 config.generators.stylesheets = false From 055e748b9397ead2162d4bb4867e9908129c5b43 Mon Sep 17 00:00:00 2001 From: kumarin Date: Mon, 31 Aug 2026 12:46:08 +0530 Subject: [PATCH 2/2] review fix --- app/middleware/suppress_api_compression.rb | 31 +++---- .../suppress_api_compression_test.rb | 80 +++++++++++++++++++ 2 files changed, 91 insertions(+), 20 deletions(-) create mode 100644 test/middleware/suppress_api_compression_test.rb diff --git a/app/middleware/suppress_api_compression.rb b/app/middleware/suppress_api_compression.rb index d5a8109e9..3ba795abd 100644 --- a/app/middleware/suppress_api_compression.rb +++ b/app/middleware/suppress_api_compression.rb @@ -6,10 +6,10 @@ # compressed response sizes can infer secrets in the response body (BREACH attack). # # This middleware disables gzip compression for all /api/* responses by: -# 1. Removing gzip from Accept-Encoding before the request reaches Rack::Deflater, -# preventing Rails-level compression. -# 2. Setting Content-Encoding: identity on the response, signalling nginx not to -# apply gzip compression at the infrastructure layer. +# 1. Forcing Accept-Encoding to 'identity' before the request reaches Rack::Deflater, +# preventing Rails-level compression for any encoding (gzip, deflate, br). +# 2. Setting Content-Encoding: identity on the response (when not already set), +# signalling nginx not to apply gzip compression at the infrastructure layer. class SuppressApiCompression API_PATH_PREFIX = '/api/' @@ -18,26 +18,17 @@ def initialize(app) end def call(env) - if env['PATH_INFO'].start_with?(API_PATH_PREFIX) - env['HTTP_ACCEPT_ENCODING'] = strip_gzip(env['HTTP_ACCEPT_ENCODING']) - end + path = env['PATH_INFO'].to_s + api_request = path == '/api' || path.start_with?(API_PATH_PREFIX) + + # Prevent Rack::Deflater from selecting gzip/deflate for API responses. + env['HTTP_ACCEPT_ENCODING'] = 'identity' if api_request status, headers, body = @app.call(env) - headers['Content-Encoding'] = 'identity' if env['PATH_INFO'].start_with?(API_PATH_PREFIX) + # Avoid clobbering an existing Content-Encoding set by downstream middleware. + headers['Content-Encoding'] = 'identity' if api_request && !headers.key?('Content-Encoding') [status, headers, body] end - - private - - def strip_gzip(accept_encoding) - return accept_encoding if accept_encoding.blank? - - result = accept_encoding - .gsub(/gzip\s*(;\s*q\s*=\s*[\d.]+)?\s*,?\s*/i, '') - .gsub(/,\s*$/, '') - .strip - result.presence - end end diff --git a/test/middleware/suppress_api_compression_test.rb b/test/middleware/suppress_api_compression_test.rb new file mode 100644 index 000000000..f7747d3d1 --- /dev/null +++ b/test/middleware/suppress_api_compression_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SuppressApiCompressionTest < ActiveSupport::TestCase + def setup + @app = ->(_env) { [200, { 'Content-Type' => 'application/json' }, ['ok']] } + @middleware = SuppressApiCompression.new(@app) + end + + describe 'API paths (/api/*)' do + it 'forces Accept-Encoding to identity so Rack::Deflater skips all compression' do + env = { 'PATH_INFO' => '/api/v1/projects', 'HTTP_ACCEPT_ENCODING' => 'gzip, deflate, br' } + @middleware.call(env) + + _(env['HTTP_ACCEPT_ENCODING']).must_equal 'identity' + end + + it 'sets Content-Encoding: identity on the response' do + env = { 'PATH_INFO' => '/api/v1/projects', 'HTTP_ACCEPT_ENCODING' => 'gzip' } + _status, headers, _body = @middleware.call(env) + + _(headers['Content-Encoding']).must_equal 'identity' + end + + it 'does not overwrite an existing Content-Encoding header' do + app_with_encoding = ->(_env) { [200, { 'Content-Encoding' => 'deflate' }, ['ok']] } + middleware = SuppressApiCompression.new(app_with_encoding) + env = { 'PATH_INFO' => '/api/v1/projects', 'HTTP_ACCEPT_ENCODING' => 'gzip' } + _status, headers, _body = middleware.call(env) + + _(headers['Content-Encoding']).must_equal 'deflate' + end + + it 'handles nil Accept-Encoding without error' do + env = { 'PATH_INFO' => '/api/v1/projects', 'HTTP_ACCEPT_ENCODING' => nil } + _status, headers, _body = @middleware.call(env) + + _(headers['Content-Encoding']).must_equal 'identity' + end + + it 'applies to the bare /api path without a trailing slash' do + env = { 'PATH_INFO' => '/api', 'HTTP_ACCEPT_ENCODING' => 'gzip' } + _status, headers, _body = @middleware.call(env) + + _(headers['Content-Encoding']).must_equal 'identity' + end + + it 'passes through the original status and body unchanged' do + env = { 'PATH_INFO' => '/api/v1/projects', 'HTTP_ACCEPT_ENCODING' => 'gzip' } + status, _headers, body = @middleware.call(env) + + _(status).must_equal 200 + _(body).must_equal ['ok'] + end + end + + describe 'non-API paths' do + it 'does not modify Accept-Encoding' do + env = { 'PATH_INFO' => '/projects', 'HTTP_ACCEPT_ENCODING' => 'gzip, deflate' } + @middleware.call(env) + + _(env['HTTP_ACCEPT_ENCODING']).must_equal 'gzip, deflate' + end + + it 'does not set Content-Encoding on the response' do + env = { 'PATH_INFO' => '/projects', 'HTTP_ACCEPT_ENCODING' => 'gzip' } + _status, headers, _body = @middleware.call(env) + + _(headers['Content-Encoding']).must_be_nil + end + + it 'does not affect paths that begin with api but are not under /api/' do + env = { 'PATH_INFO' => '/apikey', 'HTTP_ACCEPT_ENCODING' => 'gzip' } + @middleware.call(env) + + _(env['HTTP_ACCEPT_ENCODING']).must_equal 'gzip' + end + end +end