Skip to content

Commit

Permalink
HTTPEvents: filter headers using http_header_filters configuration (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
PetrHeinz authored Aug 14, 2023
1 parent 943ded5 commit ad1fbfd
Show file tree
Hide file tree
Showing 5 changed files with 93 additions and 10 deletions.
1 change: 0 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ jobs:
- 2.5
- 2.4
- 2.3
- 2.2
- jruby-9.4.3.0
- jruby-9.2.14.0
- truffleruby-23.0.0
Expand Down
28 changes: 21 additions & 7 deletions lib/logtail-rack/http_events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,24 @@ def silence_request
@silence_request
end

# Filter sensitive HTTP headers (such as "Authorization: Bearer secret_token")
#
# Filtered HTTP header values will be sent to Better Stack as "[FILTERED]"
#
# @example
# Logtail::Integrations::Rack::HTTPEvents.http_header_filters = ["Authorization"]
def http_header_filters=(value)
@http_header_filters = value
@http_header_filters = value.map { |header_name| normalize_header_name(header_name) }
end

# Accessor method for {#http_header_filters=}
def http_header_filters
@http_header_filters
end

def normalize_header_name(name)
name.to_s.downcase.gsub("-", "_")
end
end

CONTENT_LENGTH_KEY = 'Content-Length'.freeze
Expand Down Expand Up @@ -138,12 +148,11 @@ def call(env)

http_response = HTTPResponse.new(
content_length: content_length,
headers: headers,
headers: filter_http_headers(headers),
http_context: http_context,
request_id: request.request_id,
status: status,
duration_ms: duration_ms,
headers_to_sanitize: self.class.http_header_filters,
)

{
Expand All @@ -169,15 +178,14 @@ def call(env)
http_request = HTTPRequest.new(
body: event_body,
content_length: safe_to_i(request.content_length),
headers: request.headers,
headers: filter_http_headers(request.headers),
host: force_encoding(request.host),
method: request.request_method,
path: request.path,
port: request.port,
query_string: force_encoding(request.query_string),
request_id: request.request_id,
scheme: force_encoding(request.scheme),
headers_to_sanitize: self.class.http_header_filters,
)

{
Expand Down Expand Up @@ -212,11 +220,10 @@ def call(env)
http_response = HTTPResponse.new(
body: event_body,
content_length: content_length,
headers: headers,
headers: filter_http_headers(headers),
request_id: request.request_id,
status: status,
duration_ms: duration_ms,
headers_to_sanitize: self.class.http_header_filters,
)

{
Expand Down Expand Up @@ -260,6 +267,13 @@ def silenced?(env, request)
end
end

def filter_http_headers(headers)
headers.each do |name, _|
normalized_header_name = self.class.normalize_header_name(name)
headers[name] = "[FILTERED]" if self.class.http_header_filters&.include?(normalized_header_name)
end
end

def safe_to_i(val)
val.nil? ? nil : val.to_i
end
Expand Down
2 changes: 1 addition & 1 deletion lib/logtail-rack/version.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module Logtail
module Integrations
module Rack
VERSION = "0.2.1"
VERSION = "0.2.2"
end
end
end
2 changes: 1 addition & 1 deletion logtail-ruby-rack.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
spec.homepage = "https://github.com/logtail/logtail-ruby-rack"
spec.license = "ISC"

spec.required_ruby_version = '>= 2.2.10'
spec.required_ruby_version = '>= 2.3'

spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = "https://github.com/logtail/logtail-ruby-rack"
Expand Down
70 changes: 70 additions & 0 deletions spec/logtail-rack/http_events_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
require "spec_helper"
require "logtail-rack/config"
require 'stringio'


RSpec.describe Logtail::Integrations::Rack::HTTPEvents do
let(:app) { ->(env) { [200, env, "app"] } }
let(:mock_request) { Rack::MockRequest.env_for('https://example.com/test-page', { 'HTTP_AUTHORIZATION' => 'Bearer secret_token', 'HTTP_CONTENT_TYPE' => 'text/plain' }) }

let :middleware do
described_class.new(app)
end

it "log HTTP request and response" do
logs = capture_logs { middleware.call mock_request }

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
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" => "Bearer secret_token", "Content_Type" => "text/plain"})
end

it "filter HTTP request headers using http_header_filters" do
logs = capture_logs { with_http_header_filters(%w[Authorization]) { 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 HTTP request headers using http_header_filters without regard to case or dashes" do
logs = capture_logs { with_http_header_filters(%w[authorization CONTENT-TYPE]) { 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" => "[FILTERED]"})
end

it "ignores non-existent headers in http_header_filters" do
logs = capture_logs { with_http_header_filters(%w[Not_Found_Header]) { 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

def capture_logs(&blk)
old_logger = Logtail::Config.instance.logger

string_io = StringIO.new
logger = Logtail::Logger.new(string_io)
logger.formatter = Logtail::Logger::JSONFormatter.new
Logtail::Config.instance.logger = logger

blk.call

string_io.string.split("\n").map { |record| JSON.parse(record) }
ensure
Logtail::Config.instance.logger = old_logger
end

def with_http_header_filters(headers, &blk)
previous_http_header_filters = Logtail::Integrations::Rack::HTTPEvents.http_header_filters = headers

blk.call
ensure
Logtail::Integrations::Rack::HTTPEvents.http_header_filters = previous_http_header_filters
end
end

0 comments on commit ad1fbfd

Please sign in to comment.