Skip to content

Commit 3f1ab53

Browse files
committed
Leave the input a params parser quotes out of error lines
When Rails can't parse a request's params, the parser's message quotes the input where it stopped: JSON's "unexpected character: '<body>'", Rack's "invalid %-encoding (<value>)". The error line copied it into cause_message, and Rails' BadRequest wraps it in its own message ("Invalid request parameters: ..."), so a password there was logged unfiltered, at any log level. Error lines for Rails' ParseError (and its ParamErrors), Rack's params errors and errors raised because of them keep the error and cause classes and Rails' words before the input, without the parser's message.
1 parent c2c29c5 commit 3f1ab53

7 files changed

Lines changed: 113 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8888
- **Security**: LogStruct no longer empties Rails' `filter_parameters` or loads ActiveRecord and the other frameworks before the app's initializers run, so `filter_attributes` and Rails' own parameter filtering keep working (GHSA-cpp6-p4hf-383g)
8989
- **Security**: Values nested deeper than the depth limit are summarized instead of logged raw, skipping filtering and scrubbing (#15)
9090
- **Security**: When Rails can't parse a request's body (malformed JSON), its debug line gives the body's size and type. Rails logged the whole body there, so a password in it was logged unfiltered
91+
- **Security**: The error line for params Rails can't parse (malformed JSON, a bad %-encoding) leaves out the input the parser's message quotes: it has no `cause_message`, and Rails' "Invalid request parameters: ..." stops before the input. A password where the parser stopped was logged unfiltered
9192
- **Security**: Structs, `ActionController::Parameters` and other objects in a log line are filtered and scrubbed like Hashes, instead of being serialized unfiltered
9293
- **Fix**: A value that refers back to itself (a Hash or Array containing its own parent) is logged once instead of being expanded up to the depth limit, which took seconds and produced very large lines
9394
- **ActiveRecord**: Verbose query logs (the "↳ caller" lines) are turned off as intended. The integration set them on `ActiveRecord::Base`, which loaded it early and never matched, because the setting is on the `ActiveRecord` module

‎docs/app/docs/integrations/security/page.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,9 +269,10 @@ export default function SecurityIntegrationPage() {
269269
<li>
270270
<code>config.integrations.enable_rack_error_handler</code> (default <code>true</code>):
271271
CSRF and IP spoofing lines, and the error lines for errors that get past Rails&apos;{' '}
272-
<code>ShowExceptions</code>. With it on, when Rails can&apos;t parse a request&apos;s body
273-
(malformed JSON), its debug line gives the body&apos;s size and type instead of the body,
274-
which nothing would filter.
272+
<code>ShowExceptions</code>. With it on, when Rails can&apos;t parse a request&apos;s
273+
params (malformed JSON, a bad %-encoding), its debug line gives the body&apos;s size and
274+
type instead of the body, and the error line leaves out the input the parser&apos;s
275+
message quotes. Nothing could filter either.
275276
</li>
276277
<li>
277278
<code>config.error_handling_modes.security_errors</code> (default <code>Report</code>).

‎lib/log_struct/integrations/rack_error_handler/error_lines.rb‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,24 @@ module ErrorLines
1515
T::Array[String]
1616
)
1717

18+
# Errors from parsing a request's params: Rails' ParseError (and its ParamErrors), and Rack's. Their messages,
19+
# and those of errors raised because of them (Rails' "Invalid request parameters: ..."), quote the request's
20+
# input: a JSON parser's excerpt of the body, Rack's "invalid %-encoding (<value>)". Nothing can filter a body
21+
# that didn't parse, so their lines leave the input out.
22+
PARAMS_ERROR_NAMES = T.let(
23+
[
24+
"ActionDispatch::Http::Parameters::ParseError",
25+
"Rack::BadRequest",
26+
"Rack::QueryParser::InvalidParameterError",
27+
"Rack::QueryParser::ParameterTypeError",
28+
"Rack::QueryParser::ParamsTooDeepError"
29+
].freeze,
30+
T::Array[String]
31+
)
32+
# Rails' message for a ParseError
33+
PARSE_ERROR_MESSAGE = "Error occurred while parsing request parameters"
34+
PARAMS_ERROR_MESSAGE = "Invalid request parameters"
35+
1836
sig { returns(T::Boolean) }
1937
def self.enabled?
2038
LogStruct.enabled? && LogStruct.config.integrations.enable_rack_error_handler
@@ -44,7 +62,7 @@ def self.log_escaped(request, error)
4462
Log::Error.new(
4563
source: Source::Rails,
4664
error_class: error.class,
47-
message: error.message,
65+
message: messages(error).first,
4866
backtrace: error.backtrace,
4967
path: request.path,
5068
http_method: request.method,
@@ -66,20 +84,20 @@ def self.log_unhandled(request, wrapper)
6684
LoggedErrors.mark(error)
6785
# Without a backtrace cleaner, Rails' trace is Thread::Backtrace::Locations
6886
trace = Array(wrapper.exception_trace).map(&:to_s)
69-
cause = error.cause
87+
message, cause_message = messages(error)
7088
line = Log::Error.new(
7189
source: Source::Rails,
7290
error_class: error.class,
73-
message: error.message,
91+
message: message,
7492
backtrace: trace.empty? ? error.backtrace : trace,
7593
path: request.path,
7694
http_method: request.method,
7795
source_ip: source_ip(request),
7896
user_agent: request.user_agent,
7997
referer: StringScrubber.without_query(request.referer),
8098
status: wrapper.status_code,
81-
cause_class: cause&.class&.name,
82-
cause_message: cause&.message
99+
cause_class: error.cause&.class&.name,
100+
cause_message: cause_message
83101
)
84102
case request.get_header("action_dispatch.debug_exception_log_level")
85103
when ::Logger::DEBUG then LogStruct.debug(line)
@@ -143,6 +161,25 @@ def self.source_ip(request)
143161
request.ip
144162
end
145163

164+
# The message and the cause's message for an error's line, without the input a params parser quoted
165+
sig { params(error: Exception).returns([String, T.nilable(String)]) }
166+
def self.messages(error)
167+
cause = error.cause
168+
if params_error?(error)
169+
[error.instance_of?(::ActionDispatch::Http::Parameters::ParseError) ? PARSE_ERROR_MESSAGE : PARAMS_ERROR_MESSAGE, nil]
170+
elsif cause && params_error?(cause)
171+
message = error.message.sub(cause.message, "").sub(/[\s:]+\z/, "")
172+
[message.empty? ? PARAMS_ERROR_MESSAGE : message, nil]
173+
else
174+
[error.message, cause&.message]
175+
end
176+
end
177+
178+
sig { params(error: Exception).returns(T::Boolean) }
179+
def self.params_error?(error)
180+
error.class.ancestors.any? { |ancestor| PARAMS_ERROR_NAMES.include?(ancestor.name) }
181+
end
182+
146183
sig { params(error: Exception).returns(T::Boolean) }
147184
def self.security_error?(error)
148185
ip_spoof_error?(error) || CSRF_ERROR_NAMES.include?(error.class.name)

‎rails_test_app/templates/app/controllers/logging_controller.rb‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ def test_basic
2222
render json: {status: "ok", message: "Basic logging completed"}
2323
end
2424

25+
# Reads the request's params
26+
def test_params
27+
render json: {keys: params.keys}
28+
end
29+
2530
# Error logging
2631
def test_error
2732
# Since the tests run in the test environment and Rails' test behavior may catch exceptions

‎rails_test_app/templates/config/routes.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
get "/logging/error_logging", to: "logging#test_error_logging"
1414
get "/logging/shrine_upload", to: "logging#test_shrine_upload"
1515
get "/logging/ams", to: "logging#test_ams_serialization"
16+
post "/logging/params", to: "logging#test_params"
1617

1718
post "/security/submit", to: "security#submit"
1819
get "/security/remote_ip", to: "security#remote_ip"

‎rails_test_app/templates/test/integration/unhandled_errors_test.rb‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,21 @@ class UnhandledErrorsTest < ActionDispatch::IntegrationTest
5656
assert_not_includes @log_output.string, "synthetic-raw-secret"
5757
end
5858

59+
test "the error lines for params Rails can't parse leave out the input the parsers quote" do
60+
post "/logging/params", params: '{"password":synthetic-raw-secret}', headers: {"CONTENT_TYPE" => "application/json"}
61+
62+
assert_response :bad_request
63+
post "/logging/params", params: "password=%zz-synthetic-raw-secret", headers: {"CONTENT_TYPE" => "application/x-www-form-urlencoded"}
64+
65+
assert_response :bad_request
66+
errors = lines.select { |line| line["evt"] == "error" }
67+
68+
assert_equal [["ActionDispatch::Http::Parameters::ParseError", "Error occurred while parsing request parameters", "JSON::ParserError"],
69+
["ActionController::BadRequest", "Invalid request parameters", "Rack::QueryParser::InvalidParameterError"]],
70+
errors.map { |line| line.values_at("error_class", "msg", "cause_class") }
71+
assert_not_includes @log_output.string, "synthetic-raw-secret"
72+
end
73+
5974
test "a routing error is an error line with its 404 status" do
6075
get "/no/such/route"
6176

‎test/log_struct/integrations/rack_error_handler_test.rb‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,41 @@ def test_a_body_rails_cant_parse_is_logged_as_its_size_not_its_contents
6161
refute_includes output.string, "synthetic-raw-secret"
6262
end
6363

64+
def test_errors_from_parsing_params_leave_out_the_input_the_parsers_quote
65+
parse_error = raised do
66+
JSON.parse('{"password":synthetic-raw-secret}')
67+
rescue JSON::ParserError
68+
raise ActionDispatch::Http::Parameters::ParseError, "Error occurred while parsing request parameters"
69+
end
70+
bad_request = raised do
71+
Rack::Utils.parse_nested_query("password=%zz-synthetic-raw-secret")
72+
rescue Rack::Utils::InvalidParameterError => e
73+
raise ActionController::BadRequest, "Invalid request parameters: #{e.message}"
74+
end
75+
rack_error = raised { Rack::Utils.parse_nested_query("password=%zz-synthetic-raw-secret") }
76+
request = ActionDispatch::Request.new(Rack::MockRequest.env_for("/sessions", method: "POST"))
77+
lines = []
78+
79+
LogStruct.stub(:fatal, ->(line) { lines << line }) do
80+
LogStruct.stub(:error, ->(line) { lines << line }) do
81+
[parse_error, bad_request].each do |error|
82+
ErrorLines.log_unhandled(request, ActionDispatch::ExceptionWrapper.new(ActiveSupport::BacktraceCleaner.new, error))
83+
end
84+
# From a middleware outside ShowExceptions that reads Rack's params
85+
ErrorLines.log_escaped(request, rack_error)
86+
end
87+
end
88+
89+
expected = [
90+
["Error occurred while parsing request parameters", "JSON::ParserError", nil],
91+
["Invalid request parameters", "Rack::QueryParser::InvalidParameterError", nil],
92+
["Invalid request parameters", nil, nil]
93+
]
94+
95+
assert_equal expected, lines.map { |line| [line.message, line.cause_class, line.cause_message] }
96+
refute_includes lines.map { |line| line.serialize.to_json }.join, "synthetic-raw-secret"
97+
end
98+
6499
def test_the_middleware_logs_an_escaped_error_once_and_raises_it_again
65100
io = StringIO.new
66101
logger = Logger.new(io)
@@ -83,4 +118,14 @@ def test_the_middleware_logs_an_escaped_error_once_and_raises_it_again
83118
assert_equal ["rails", "error", "RuntimeError", "boom", "/orders", "GET", "https://app.example.com/reset"],
84119
lines.first.values_at("src", "evt", "error_class", "msg", "path", "method", "referer")
85120
end
121+
122+
private
123+
124+
def raised
125+
yield
126+
127+
flunk "Expected an error"
128+
rescue => e
129+
e
130+
end
86131
end

0 commit comments

Comments
 (0)