From 01933a3920b540a8d9701c2d41ea580ff3a678e5 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Wed, 23 Sep 2026 16:33:04 +0200 Subject: [PATCH 1/2] Add failing tests for T-1087: filter credential headers by default Authorization, Proxy-Authorization, Cookie and Set-Cookie should be replaced with [FILTERED] without any configuration, and setting http_header_filters to an empty list should log every header again. Co-Authored-By: Claude Fable 5.1 --- spec/logtail-rack/http_events_spec.rb | 30 ++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/spec/logtail-rack/http_events_spec.rb b/spec/logtail-rack/http_events_spec.rb index de1aa10..d6392a7 100755 --- a/spec/logtail-rack/http_events_spec.rb +++ b/spec/logtail-rack/http_events_spec.rb @@ -17,9 +17,33 @@ expect(logs.map { |log| log['message'] }).to match(['Started GET "/test-page"', /Completed 200 OK in \d+\.\d+ms/]) end - it "log HTTP request headers" do + it "log HTTP request headers, filtering the Authorization header by default" do logs = capture_logs { middleware.call mock_request } + request_headers_json = logs.first["event"]["http_request_received"]["headers_json"] + expect(JSON.parse(request_headers_json)).to eq({"Authorization" => "[FILTERED]", "Content_Type" => "text/plain"}) + end + + it "filter credential headers in the request and the response by default" do + app = ->(env) { [200, { "Content-Type" => "text/plain", "Set-Cookie" => "session=abc" }, "app"] } + request = Rack::MockRequest.env_for('https://example.com/test-page', { + 'HTTP_AUTHORIZATION' => 'Bearer secret_token', + 'HTTP_PROXY_AUTHORIZATION' => 'Basic cHJveHk6c2VjcmV0', + 'HTTP_COOKIE' => 'session=abc', + 'HTTP_CONTENT_TYPE' => 'text/plain', + }) + + logs = capture_logs { described_class.new(app).call request } + + request_headers_json = logs.first["event"]["http_request_received"]["headers_json"] + expect(JSON.parse(request_headers_json)).to eq({"Authorization" => "[FILTERED]", "Proxy_Authorization" => "[FILTERED]", "Cookie" => "[FILTERED]", "Content_Type" => "text/plain"}) + response_headers_json = logs.last["event"]["http_response_sent"]["headers_json"] + expect(JSON.parse(response_headers_json)).to eq({"Content-Type" => "text/plain", "Set-Cookie" => "[FILTERED]"}) + end + + it "log every header when http_header_filters is set to an empty list" do + logs = capture_logs { with_http_header_filters([]) { middleware.call mock_request } } + request_headers_json = logs.first["event"]["http_request_received"]["headers_json"] expect(JSON.parse(request_headers_json)).to eq({"Authorization" => "Bearer secret_token", "Content_Type" => "text/plain"}) end @@ -61,10 +85,10 @@ def capture_logs(&blk) end def with_http_header_filters(headers, &blk) - previous_http_header_filters = Logtail::Integrations::Rack::HTTPEvents.http_header_filters = headers + Logtail::Integrations::Rack::HTTPEvents.http_header_filters = headers blk.call ensure - Logtail::Integrations::Rack::HTTPEvents.http_header_filters = previous_http_header_filters + Logtail::Integrations::Rack::HTTPEvents.http_header_filters = Logtail::Integrations::Rack::HTTPEvents::DEFAULT_HTTP_HEADER_FILTERS end end From bf09f782f613d27de361ae933557bb940be6e3c5 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Wed, 23 Sep 2026 16:35:47 +0200 Subject: [PATCH 2/2] T-1087 Filter Authorization, Proxy-Authorization, Cookie and Set-Cookie headers by default Credential headers were sent to Better Stack in clear text unless the app configured http_header_filters. DEFAULT_HTTP_HEADER_FILTERS is the public default list, setting http_header_filters still replaces it. Co-Authored-By: Claude Fable 5.1 --- lib/logtail-rack/http_events.rb | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/logtail-rack/http_events.rb b/lib/logtail-rack/http_events.rb index 08db972..369d1dc 100755 --- a/lib/logtail-rack/http_events.rb +++ b/lib/logtail-rack/http_events.rb @@ -15,6 +15,8 @@ module Rack # response events. The {Events::HTTPRequest} and {Events::HTTPResponse} events # respectively. class HTTPEvents < Middleware + DEFAULT_HTTP_HEADER_FILTERS = ["Authorization", "Proxy-Authorization", "Cookie", "Set-Cookie"].freeze + class << self # Allows you to capture the HTTP request body, default is off (false). # @@ -107,8 +109,11 @@ def silence_request # # Filtered HTTP header values will be sent to Better Stack as "[FILTERED]" # + # {DEFAULT_HTTP_HEADER_FILTERS} are filtered out of the box. Setting this replaces + # the whole list, pass an empty list to log every header. + # # @example - # Logtail::Integrations::Rack::HTTPEvents.http_header_filters = ["Authorization"] + # Logtail::Integrations::Rack::HTTPEvents.http_header_filters = Logtail::Integrations::Rack::HTTPEvents::DEFAULT_HTTP_HEADER_FILTERS + ["X-Api-Key"] def http_header_filters=(value) @http_header_filters = value.map { |header_name| normalize_header_name(header_name) } end @@ -123,6 +128,8 @@ def normalize_header_name(name) end end + self.http_header_filters = DEFAULT_HTTP_HEADER_FILTERS + CONTENT_LENGTH_KEY = 'Content-Length'.freeze def call(env) @@ -271,7 +278,7 @@ def silenced?(env, request) def filter_http_headers(headers) headers.map do |name, value| normalized_name = self.class.normalize_header_name(name) - is_filtered = self.class.http_header_filters&.include?(normalized_name) + is_filtered = self.class.http_header_filters.include?(normalized_name) [name, is_filtered ? "[FILTERED]" : value] end.to_h end