Skip to content

Commit 5543891

Browse files
committed
feat(data-collection): Request data collection
* Add `PII_HEADER_SNIPPETS` to backfill * Use `data_collection.http_headers.request` for ip address collection (both `env` and `request`) * Use `data_collection.cookies` for cookie collection (fix `Hash` signature since it always was one) * Use `data_collection.url_query_params` for query string and parse query into `Hash` and filter * Use `data_collection.http_bodies` for body * JSON is also parsed and filtered now, form data is also filtered for sensitive keys * Sensitive headers like auth and cookies are not *ALWAYS* filtered unlike before
1 parent 4decfb9 commit 5543891

9 files changed

Lines changed: 188 additions & 96 deletions

File tree

sentry-ruby/lib/sentry/data_collection.rb

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ class DataCollection
2828
# data_collection.stack_frame_variables = true
2929
# data_collection.frame_context_lines = 5
3030
# end
31+
3132
MODES = %i[off deny_list allow_list].freeze
33+
34+
PII_HEADER_SNIPPETS = %w[forwarded -ip _ip remote via _user -user].freeze
35+
3236
BODY_TYPES = %i[
3337
incoming_request
3438
outgoing_request
@@ -102,6 +106,11 @@ def initialize(document:, variables:)
102106
# @default `3`
103107
attr_accessor :frame_context_lines
104108

109+
# Filters key-value data using the default sensitive denylist.
110+
def self.filter(values)
111+
KeyValueCollection.new(mode: :deny_list, terms: nil).filter(values)
112+
end
113+
105114
# Builds data collection settings compatible with the legacy send_default_pii
106115
# configuration.
107116
def self.backfill(configuration)
@@ -112,8 +121,10 @@ def self.backfill(configuration)
112121
# TODO-neel-data map to exact ruby behaviour for backwards compat behavior
113122
data_collection.user_info = false
114123
data_collection.cookies.mode = :off
115-
data_collection.http_headers.request.mode = :off
116-
data_collection.http_headers.response.mode = :off
124+
data_collection.http_headers.request.mode = :deny_list
125+
data_collection.http_headers.request.terms = PII_HEADER_SNIPPETS
126+
data_collection.http_headers.response.mode = :deny_list
127+
data_collection.http_headers.response.terms = PII_HEADER_SNIPPETS
117128
data_collection.http_bodies = []
118129
data_collection.url_query_params.mode = :off
119130
data_collection.graphql.document = false
@@ -132,7 +143,7 @@ def initialize
132143
request: KeyValueCollection.new(mode: :deny_list, terms: nil),
133144
response: KeyValueCollection.new(mode: :deny_list, terms: nil)
134145
)
135-
@http_bodies = nil
146+
@http_bodies = BODY_TYPES
136147
@url_query_params = KeyValueCollection.new(mode: :deny_list, terms: nil)
137148
@database_query_data = true
138149
@graphql = GraphQL.new(document: true, variables: true)

sentry-ruby/lib/sentry/data_collection/key_value_collection.rb

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ class KeyValueCollection
77
FILTERED_VALUE = "[Filtered]"
88

99
# Keys from this list are ALWAYS filtered, regardless of :mode
10-
# TODO-neel-data cookies have separate list, see JS
1110
SENSITIVE_DENY_LIST = %w[
1211
auth
1312
token
@@ -30,6 +29,30 @@ class KeyValueCollection
3029
set-cookie
3130
].freeze
3231

32+
# Additional terms applied to cookie names only. These cover common
33+
# opaque session, identity-provider, and load-balancer cookies without
34+
# making the general header denylist overly broad.
35+
SENSITIVE_COOKIE_NAME_DENY_LIST = %w[
36+
.sid
37+
sessid
38+
remember
39+
oidc
40+
pkce
41+
nonce
42+
__secure-
43+
__host-
44+
awsalb
45+
awselb
46+
akamai
47+
__stripe
48+
cognito
49+
firebase
50+
supabase
51+
sb-
52+
mfa
53+
2fa
54+
].freeze
55+
3356
# `mode` controls whether values are collected:
3457
# - `:off` disables collection.
3558
# - `:deny_list` collects values except those matching `terms`.
@@ -56,19 +79,19 @@ def terms=(terms)
5679
#
5780
# @param values [Hash] key-value data to filter
5881
# @return [Hash] a new filtered hash, or an empty hash when collection is off
59-
def filter(values)
82+
def filter(values, cookie: false)
6083
return {} if mode == :off
6184

6285
values.each_with_object({}) do |(key, value), filtered|
63-
filtered[key] = safe_value?(key) ? value : FILTERED_VALUE
86+
filtered[key] = safe_value?(key, cookie: cookie) ? value : FILTERED_VALUE
6487
end
6588
end
6689

6790
private
6891

69-
def safe_value?(key)
92+
def safe_value?(key, cookie: false)
7093
key_downcase = key.to_s.downcase
71-
return false if sensitive?(key_downcase)
94+
return false if sensitive?(key_downcase, cookie: cookie)
7295

7396
case mode
7497
when :deny_list
@@ -80,8 +103,9 @@ def safe_value?(key)
80103
end
81104
end
82105

83-
def sensitive?(key)
84-
SENSITIVE_DENY_LIST.any? { |term| key.include?(term) }
106+
def sensitive?(key, cookie: false)
107+
SENSITIVE_DENY_LIST.any? { |term| key.include?(term) } ||
108+
(cookie && SENSITIVE_COOKIE_NAME_DENY_LIST.any? { |term| key.include?(term) })
85109
end
86110

87111
def matches_any_term?(key)

sentry-ruby/lib/sentry/event.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,12 @@ def to_json_compatible
128128
private
129129

130130
def add_request_interface(env)
131-
@request = Sentry::RequestInterface.new(env: env, send_default_pii: @send_default_pii, rack_env_whitelist: @rack_env_whitelist)
131+
@request = Sentry::RequestInterface.new(
132+
env: env,
133+
data_collection: @data_collection,
134+
send_default_pii: @send_default_pii,
135+
rack_env_whitelist: @rack_env_whitelist
136+
)
132137
end
133138

134139
def serialize_attributes

sentry-ruby/lib/sentry/interfaces/request.rb

Lines changed: 42 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
# frozen_string_literal: true
22

3+
require "json"
4+
35
module Sentry
46
class RequestInterface < Interface
57
REQUEST_ID_HEADERS = %w[action_dispatch.request_id HTTP_X_REQUEST_ID].freeze
68
CONTENT_HEADERS = %w[CONTENT_TYPE CONTENT_LENGTH].freeze
7-
IP_HEADERS = [
8-
"REMOTE_ADDR",
9-
"HTTP_CLIENT_IP",
10-
"HTTP_X_REAL_IP",
11-
"HTTP_X_FORWARDED_FOR"
12-
].freeze
139

1410
# Regex to detect lowercase chars — match? is allocation-free (no MatchData/String)
1511
LOWERCASE_PATTERN = /[a-z]/.freeze
@@ -27,10 +23,10 @@ class RequestInterface < Interface
2723
# @return [Hash]
2824
attr_accessor :data
2925

30-
# @return [String]
26+
# @return [String, Hash]
3127
attr_accessor :query_string
3228

33-
# @return [String]
29+
# @return [Hash]
3430
attr_accessor :cookies
3531

3632
# @return [Hash]
@@ -40,33 +36,26 @@ class RequestInterface < Interface
4036
attr_accessor :env
4137

4238
# @param env [Hash]
43-
# @param send_default_pii [Boolean]
39+
# @param data_collection [DataCollection]
40+
# @param send_default_pii [Boolean] Deprecated compatibility input, replaced with data_collection.
4441
# @param rack_env_whitelist [Array]
42+
# @see Configuration#data_collection
4543
# @see Configuration#send_default_pii
4644
# @see Configuration#rack_env_whitelist
47-
def initialize(env:, send_default_pii:, rack_env_whitelist:)
45+
def initialize(env:, data_collection:, send_default_pii:, rack_env_whitelist:)
4846
env = env.dup
49-
50-
unless send_default_pii
51-
# need to completely wipe out ip addresses
52-
RequestInterface::IP_HEADERS.each do |header|
53-
env.delete(header)
54-
end
55-
end
56-
5747
request = ::Rack::Request.new(env)
5848

59-
if send_default_pii
60-
self.data = read_data_from(request)
61-
self.cookies = request.cookies
62-
self.query_string = request.query_string
63-
end
64-
65-
self.url = request.scheme && request.url.split("?").first
6649
self.method = request.request_method
50+
self.url = request.scheme && request.url.split("?").first
6751

68-
self.headers = filter_and_format_headers(env, send_default_pii)
69-
self.env = filter_and_format_env(env, rack_env_whitelist)
52+
query = data_collection.url_query_params.filter(request.GET)
53+
self.query_string = query unless query&.empty?
54+
55+
self.cookies = data_collection.cookies.filter(request.cookies, cookie: true)
56+
self.data = read_data_from(request) if data_collection.http_bodies.include?(:incoming_request)
57+
self.headers = filter_and_format_headers(env, data_collection.http_headers.request)
58+
self.env = filter_and_format_env(env, data_collection.http_headers.request, rack_env_whitelist)
7059
end
7160

7261
private
@@ -75,25 +64,31 @@ def read_data_from(request)
7564
return "Skipped non-rewindable request body" unless request.body.respond_to?(:rewind)
7665

7766
if request.form_data?
78-
request.POST
79-
elsif request.body # JSON requests, etc
80-
data = request.body.read(MAX_BODY_LIMIT)
81-
data = Utils::EncodingHelper.encode_to_utf_8(data.to_s)
82-
request.body.rewind
83-
data
67+
DataCollection.filter(request.POST)
68+
else
69+
body = request.body.read(MAX_BODY_LIMIT)
70+
body = Utils::EncodingHelper.encode_to_utf_8(body.to_s)
71+
72+
if request.media_type == "application/json" || request.media_type&.end_with?("+json")
73+
parsed_body = JSON.parse(body)
74+
parsed_body.is_a?(Hash) ? DataCollection.filter(parsed_body) : parsed_body
75+
else
76+
body
77+
end
8478
end
85-
rescue IOError => e
79+
rescue JSON::ParserError, IOError => e
8680
e.message
81+
ensure
82+
request.body.rewind if request.body.respond_to?(:rewind)
8783
end
8884

89-
def filter_and_format_headers(env, send_default_pii)
85+
def filter_and_format_headers(env, collection)
9086
env.each_with_object({}) do |(key, value), memo|
9187
begin
9288
key = key.to_s # rack env can contain symbols
9389
next memo["X-Request-Id"] ||= Utils::RequestId.read_from(env) if Utils::RequestId::REQUEST_ID_HEADERS.include?(key)
9490
next if is_server_protocol?(key, value, env["SERVER_PROTOCOL"])
9591
next if is_skippable_header?(key)
96-
next if key == "HTTP_AUTHORIZATION" && !send_default_pii
9792

9893
# Rack stores headers as HTTP_WHAT_EVER, we need What-Ever
9994
key = key.delete_prefix("HTTP_")
@@ -107,12 +102,13 @@ def filter_and_format_headers(env, send_default_pii)
107102
Sentry.sdk_logger.warn(LOGGER_PROGNAME) { "Error raised while formatting headers: #{e.message}" }
108103
next
109104
end
105+
end.then do |e|
106+
collection.filter(e)
110107
end
111108
end
112109

113110
def is_skippable_header?(key)
114111
key.match?(LOWERCASE_PATTERN) || # lower-case envs aren't real http headers
115-
key == "HTTP_COOKIE" || # Cookies don't go here, they go somewhere else
116112
!(key.start_with?("HTTP_") || CONTENT_HEADERS.include?(key))
117113
end
118114

@@ -134,11 +130,15 @@ def self.rack_3_or_above?
134130
Gem::Version.new(::Rack.release) >= Gem::Version.new("3.0")
135131
end
136132

137-
def filter_and_format_env(env, rack_env_whitelist)
138-
return env if rack_env_whitelist.empty?
139-
140-
env.select do |k, _v|
141-
rack_env_whitelist.include? k.to_s
133+
def filter_and_format_env(env, collection, rack_env_whitelist)
134+
if rack_env_whitelist.empty?
135+
env
136+
else
137+
env.select do |k, _v|
138+
rack_env_whitelist.include? k.to_s
139+
end
140+
end.then do |e|
141+
collection.filter(e)
142142
end
143143
end
144144
end

sentry-ruby/spec/sentry/data_collection/key_value_collection_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
it "normalizes terms assigned after initialization" do
5858
collection.terms = ["USER"]
5959

60-
expect(collection.filter("user_id" => "1")).to eq("user_id" => "[Filtered]")
60+
expect(collection.filter({ "user_id" => "1" })).to eq("user_id" => "[Filtered]")
6161
end
6262

6363
it "covers every built-in sensitive term" do

sentry-ruby/spec/sentry/data_collection_spec.rb

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,26 @@
33
RSpec.describe Sentry::DataCollection do
44
subject(:data_collection) { described_class.new }
55

6+
describe ".filter" do
7+
it "applies the default sensitive denylist" do
8+
expect(described_class.filter("token" => "secret", "page" => "2")).to eq(
9+
"token" => "[Filtered]",
10+
"page" => "2"
11+
)
12+
end
13+
end
14+
615
describe ".backfill" do
716
it "uses the send_default_pii=false defaults" do
817
configuration = Sentry::Configuration.new
918
data_collection = described_class.backfill(configuration)
1019

1120
expect(data_collection.user_info).to eq(false)
1221
expect(data_collection.cookies.mode).to eq(:off)
13-
expect(data_collection.http_headers.request.mode).to eq(:off)
14-
expect(data_collection.http_headers.response.mode).to eq(:off)
22+
expect(data_collection.http_headers.request.mode).to eq(:deny_list)
23+
expect(data_collection.http_headers.request.terms).to eq(described_class::PII_HEADER_SNIPPETS)
24+
expect(data_collection.http_headers.response.mode).to eq(:deny_list)
25+
expect(data_collection.http_headers.request.terms).to eq(described_class::PII_HEADER_SNIPPETS)
1526
expect(data_collection.http_bodies).to eq([])
1627
expect(data_collection.url_query_params.mode).to eq(:off)
1728
expect(data_collection.database_query_data).to eq(false)
@@ -37,10 +48,10 @@
3748
expect(data_collection.cookies.mode).to eq(:deny_list)
3849
expect(data_collection.cookies.terms).to be_nil
3950
expect(data_collection.http_headers.request.mode).to eq(:deny_list)
40-
expect(data_collection.http_headers.request.terms).to be_nil
51+
expect(data_collection.http_headers.request.terms).to eq(nil)
4152
expect(data_collection.http_headers.response.mode).to eq(:deny_list)
42-
expect(data_collection.http_headers.response.terms).to be_nil
43-
expect(data_collection.http_bodies).to be_nil
53+
expect(data_collection.http_headers.request.terms).to eq(nil)
54+
expect(data_collection.http_bodies).to eq(described_class::BODY_TYPES)
4455
expect(data_collection.url_query_params.mode).to eq(:deny_list)
4556
expect(data_collection.url_query_params.terms).to be_nil
4657
expect(data_collection.database_query_data).to eq(true)

sentry-ruby/spec/sentry/event_spec.rb

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -98,24 +98,21 @@
9898
scope.apply_to_event(event)
9999

100100
expect(event.to_h[:request]).to eq(
101-
env: { 'SERVER_NAME' => 'localhost', 'SERVER_PORT' => '80' },
102-
headers: { 'Host' => 'localhost', 'X-Request-Id' => 'abcd-1234-abcd-1234' },
101+
env: { 'REMOTE_ADDR' => '[Filtered]', 'SERVER_NAME' => 'localhost', 'SERVER_PORT' => '80' },
102+
headers: { 'Host' => 'localhost', 'X-Forwarded-For' => '[Filtered]', 'X-Request-Id' => 'abcd-1234-abcd-1234' },
103103
method: 'POST',
104104
url: 'http://localhost/lol',
105+
cookies: {}
105106
)
106107
expect(event.to_h[:tags][:request_id]).to eq("abcd-1234-abcd-1234")
107108
expect(event.to_h[:user][:ip_address]).to eq(nil)
108109
end
109110

110-
it "removes ip address headers" do
111+
it "filters ip address headers from headers and env" do
111112
scope.apply_to_event(event)
112113

113-
# doesn't affect scope's rack_env
114-
expect(scope.rack_env).to include("REMOTE_ADDR")
115-
expect(event.request.headers.keys).not_to include("REMOTE_ADDR")
116-
expect(event.request.headers.keys).not_to include("Client-Ip")
117-
expect(event.request.headers.keys).not_to include("X-Real-Ip")
118-
expect(event.request.headers.keys).not_to include("X-Forwarded-For")
114+
expect(event.request.env).to include("REMOTE_ADDR" => "[Filtered]")
115+
expect(event.request.headers).to include("X-Forwarded-For" => "[Filtered]")
119116
end
120117
end
121118

@@ -132,7 +129,7 @@
132129
env: { 'SERVER_NAME' => 'localhost', 'SERVER_PORT' => '80', "REMOTE_ADDR" => "192.168.1.1" },
133130
headers: { 'Host' => 'localhost', "X-Forwarded-For" => "1.1.1.1, 2.2.2.2", "X-Request-Id" => "abcd-1234-abcd-1234" },
134131
method: 'POST',
135-
query_string: 'biz=baz',
132+
query_string: { 'biz' => 'baz' },
136133
url: 'http://localhost/lol',
137134
cookies: {}
138135
)
@@ -160,7 +157,7 @@
160157
env: { 'SERVER_NAME' => 'localhost', 'SERVER_PORT' => '80', "REMOTE_ADDR" => "192.168.1.1" },
161158
headers: { 'Host' => 'localhost', "X-Forwarded-For" => "1.1.1.1, 2.2.2.2", "X-Request-Id" => "abcd-1234-abcd-1234" },
162159
method: 'POST',
163-
query_string: 'biz=baz',
160+
query_string: { 'biz' => 'baz' },
164161
url: 'http://localhost/lol',
165162
cookies: {}
166163
)

0 commit comments

Comments
 (0)