Information Disclosure via exception backtrace in error handling logs/notifications
Description
The commit changes Action Pack's rescue_from handling to stop emitting exception backtrace information in the structured event payload for rescue_from events. Previously, the first line of the exception backtrace (sanitized by removing Rails.root) could be included in the event payload (and thus logged/observed via ActiveSupport notifications), leaking server filesystem paths (Rails.root) and other sensitive path information. The patch removes the backtrace from the emitted event payload entirely, reducing information disclosure risk via error handling logs/notifications.
Proof of Concept
PoC to demonstrate information disclosure via exception backtrace in logs/notifications before the fix:
Assumptions:
- Rails.root = "/var/www/myapp"
- An exception occurs during request handling and is caught by rescue_from, which emits a structured event that includes exception backtrace information.
Before fix (conceptual):
- The first line of the exception backtrace is included in the event payload (with Rails.root prefix removed):
exception_backtrace: "/app/controllers/foo_controller.rb:42:in `index'" # (example path after sanitizing Rails.root)
- This path could be logged or sent to a monitoring service and disclose the repository layout on the server.
PoC Ruby-IRB-like snippet (illustrative, not a running Rails app):
#!/usr/bin/env ruby
root = "/var/www/myapp" # Rails.root
begin
raise "boom"
rescue => e
# BEFORE FIX: backtrace may be sent in logs/notifications
first_line = e.backtrace&.first
backtrace_payload = first_line&.delete_prefix("#{root}/")
puts "Backtrace payload (potential disclosure): #{backtrace_payload}"
end
After fix (conceptual):
- The payload would no longer include exception_backtrace, or it would be nil/empty, preventing leakage of the filesystem path.
PoC exploit vector (attack path):
- An attacker does not directly execute code to read files, but relies on error handling/logging/monitoring that captures structured_event payloads containing exception_backtrace.
- If access to logs/monitoring is not properly restricted, the attacker could glean server filesystem layout (Rails.root and surrounding paths) from the backtrace line, aiding targeted reconnaissance or further exploits.
Mitigation verification steps:
- Trigger an error in production with rescue_from that emits the unsafe payload and observe logs/notification payloads before/after this commit.
- Ensure that the exception_backtrace field is not present or is null in the emitted events after the fix.
Commit Details
Author: Hartley McGuire
Date: 2025-11-02 18:19 UTC
Message:
Merge pull request #56075 from skipkayhil/hm-ykzwzxuypxrsypvs
Various event related fixes
Triage Assessment
Vulnerability Type: Information disclosure
Confidence: HIGH
Reasoning:
The changes sanitize exception backtraces before emitting rescue_from events, removing sensitive path information (Rails.root) from logs/notifications. This reduces information disclosure risk via stack traces in error handling, addressing a security-related exposure. The rest of the changes are minor or test scaffolding, but the backtrace sanitization constitutes the security fix.
Verification Assessment
Vulnerability Type: Information Disclosure via exception backtrace in error handling logs/notifications
Confidence: HIGH
Affected Versions: 8.1.0 - 8.1.2 (pre-fix); 8.1.3+ includes the fix in this commit
Code Diff
diff --git a/actionpack/lib/action_controller/metal/redirecting.rb b/actionpack/lib/action_controller/metal/redirecting.rb
index 7a949033c3f4f..f1a5d3a67bfbe 100644
--- a/actionpack/lib/action_controller/metal/redirecting.rb
+++ b/actionpack/lib/action_controller/metal/redirecting.rb
@@ -117,7 +117,7 @@ def allowed_redirect_hosts=(hosts)
# The `action_on_open_redirect` configuration option controls the behavior when an unsafe
# redirect is detected:
# * `:log` - Logs a warning but allows the redirect
- # * `:notify` - Sends an ActiveSupport notification for monitoring
+ # * `:notify` - Sends an Active Support notification for monitoring
# * `:raise` - Raises an UnsafeRedirectError
#
# To allow any external redirects pass `allow_other_host: true`, though using a
@@ -144,7 +144,7 @@ def allowed_redirect_hosts=(hosts)
# config.action_controller.action_on_path_relative_redirect = :raise
#
# * `:log` - Logs a warning but allows the redirect
- # * `:notify` - Sends an ActiveSupport notification but allows the redirect
+ # * `:notify` - Sends an Active Support notification but allows the redirect
# (includes stack trace to help identify the source)
# * `:raise` - Raises an UnsafeRedirectError
def redirect_to(options = {}, response_options = {})
diff --git a/actionpack/lib/action_controller/structured_event_subscriber.rb b/actionpack/lib/action_controller/structured_event_subscriber.rb
index 7bf6cf35f4c15..8bee0acffeb2f 100644
--- a/actionpack/lib/action_controller/structured_event_subscriber.rb
+++ b/actionpack/lib/action_controller/structured_event_subscriber.rb
@@ -46,10 +46,14 @@ def halted_callback(event)
def rescue_from_callback(event)
exception = event.payload[:exception]
+
+ exception_backtrace = exception.backtrace&.first
+ exception_backtrace = exception_backtrace&.delete_prefix("#{Rails.root}/") if defined?(Rails.root) && Rails.root
+
emit_event("action_controller.rescue_from_handled",
exception_class: exception.class.name,
exception_message: exception.message,
- exception_backtrace: exception.backtrace&.first&.delete_prefix("#{Rails.root}/")
+ exception_backtrace:
)
end
diff --git a/actionpack/test/controller/redirect_test.rb b/actionpack/test/controller/redirect_test.rb
index 43acaa10d5111..a198528a6d4c4 100644
--- a/actionpack/test/controller/redirect_test.rb
+++ b/actionpack/test/controller/redirect_test.rb
@@ -676,7 +676,7 @@ def test_redirect_to_path_relative_url_starting_with_an_at_with_log
def test_redirect_to_path_relative_url_starting_with_an_at_with_notify
with_path_relative_redirect(:notify) do
events = []
- ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args|
+ subscriber = ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args|
events << ActiveSupport::Notifications::Event.new(*args)
end
@@ -692,14 +692,14 @@ def test_redirect_to_path_relative_url_starting_with_an_at_with_notify
assert_kind_of Array, event.payload[:stack_trace]
assert event.payload[:stack_trace].any? { |line| line.include?("redirect_to_path_relative_url_starting_with_an_at") }
ensure
- ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller")
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
end
def test_redirect_to_path_relative_url_with_notify
with_path_relative_redirect(:notify) do
events = []
- ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args|
+ subscriber = ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args|
events << ActiveSupport::Notifications::Event.new(*args)
end
@@ -715,7 +715,7 @@ def test_redirect_to_path_relative_url_with_notify
assert_kind_of Array, event.payload[:stack_trace]
assert event.payload[:stack_trace].any? { |line| line.include?("redirect_to_path_relative_url") }
ensure
- ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller")
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
end
@@ -760,7 +760,7 @@ def test_redirect_to_absolute_url_does_not_log
def test_redirect_to_absolute_url_does_not_notify
with_path_relative_redirect(:notify) do
events = []
- ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args|
+ subscriber = ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args|
events << ActiveSupport::Notifications::Event.new(*args)
end
@@ -774,7 +774,7 @@ def test_redirect_to_absolute_url_does_not_notify
assert_equal "http://test.host/things/stuff", redirect_to_url
assert_empty events
ensure
- ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller")
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
end
@@ -808,7 +808,7 @@ def test_redirect_to_query_string_url_does_not_trigger_path_relative_warning_wit
def test_redirect_to_query_string_url_does_not_trigger_path_relative_warning_with_notify
with_path_relative_redirect(:notify) do
events = []
- ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args|
+ subscriber = ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args|
events << ActiveSupport::Notifications::Event.new(*args)
end
@@ -818,7 +818,7 @@ def test_redirect_to_query_string_url_does_not_trigger_path_relative_warning_wit
assert_empty events.select { |e| e.payload[:message]&.include?("Path relative URL redirect detected") }
ensure
- ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller")
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
end
@@ -864,7 +864,7 @@ def test_redirect_to_external_with_action_on_open_redirect_log
def test_redirect_to_external_with_action_on_open_redirect_notify
with_action_on_open_redirect(:notify) do
events = []
- ActiveSupport::Notifications.subscribe("open_redirect.action_controller") do |*args|
+ subscriber = ActiveSupport::Notifications.subscribe("open_redirect.action_controller") do |*args|
events << ActiveSupport::Notifications::Event.new(*args)
end
@@ -878,7 +878,7 @@ def test_redirect_to_external_with_action_on_open_redirect_notify
assert_kind_of ActionDispatch::Request, event.payload[:request]
assert_kind_of Array, event.payload[:stack_trace]
ensure
- ActiveSupport::Notifications.unsubscribe("open_redirect.action_controller")
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
end