XSS (development/debug exception page)
Description
This commit fixes an XSS in Rails' development debug exception page by escaping user-controlled exception messages when copied to the clipboard. Previously the exception message could be output with raw HTML inside a script tag used for the copy-to-clipboard feature, allowing HTML content to be injected if the message contained HTML. The patch replaces raw with default ERB escaping for the copied message and adds a test asserting escaping of the message (e.g., turning <script> into <script>). This mitigates a development-only XSS risk (CVE-2026-33167).
Proof of Concept
Pre-fix exploit flow (illustrative):
1) Trigger an exception with HTML in the message, e.g. access /xss_error which returns 'x</script><script>alert(1)</script>'.
2) Copy the exception message to the clipboard via the development error page's copy-to-clipboard control.
3) On a separate HTML page that unsafely inserts clipboard content into the DOM using innerHTML, paste/read the clipboard content to see the injected HTML execute:
Attacker page example (dangerous HTML, test only):
<!DOCTYPE html>
<html>
<body>
<div id="out"></div>
<script>
// Attacker-provided clipboard content derived from the Rails dev page
var payload = 'x</script><script>alert(1)</script>';
document.getElementById('out').innerHTML = payload;
</script>
</body>
</html>
This would result in an alert(1) in a browser if the content is inserted via innerHTML without escaping.
With the fix, the copied content is escaped (e.g., x</script><script>alert(1)</script>), which when inserted into the DOM would render as text rather than executing scripts, preventing the XSS.
Commit Details
Author: John Hawthorn
Date: 2026-03-17 17:16 UTC
Message:
Fix XSS in debug exceptions copy-to-clipboard
The exception message was output with `raw` inside a <script> tag. If an
attacker is able to trigger an exception message containing HTML it
would be rendered to the page.
This affects development error page and in most cases is not reachable
in production.
Use default ERB escaping instead of `raw` to ensure the message is
HTML-escaped.
[CVE-2026-33167]
[GHSA-pgm4-439c-5jp6]
Triage Assessment
Vulnerability Type: XSS
Confidence: HIGH
Reasoning:
Commit changes escape user-controlled exception messages before copying to clipboard in the debug exception page. Replacing raw output with default escaping prevents HTML in exception messages from being interpreted or injected, addressing an XSS risk in development error pages (CVE-2026-33167).
Verification Assessment
Vulnerability Type: XSS (development/debug exception page)
Confidence: HIGH
Affected Versions: Rails 8.1.x releases prior to 8.1.3 (development error pages)
Code Diff
diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb
index 3a84601d25518..6066ecd6d9510 100644
--- a/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb
+++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/layout.erb
@@ -345,7 +345,7 @@
<body>
<%= yield %>
- <script type="text/plain" id="exception-message-for-copy"><%= raw @exception_message_for_copy %></script>
+ <script type="text/plain" id="exception-message-for-copy"><%= @exception_message_for_copy %></script>
</body>
</html>
diff --git a/actionpack/test/dispatch/debug_exceptions_test.rb b/actionpack/test/dispatch/debug_exceptions_test.rb
index d9f109c4f5033..94c7911c4c480 100644
--- a/actionpack/test/dispatch/debug_exceptions_test.rb
+++ b/actionpack/test/dispatch/debug_exceptions_test.rb
@@ -128,6 +128,8 @@ def call(env)
rescue Exception
raise ActionView::Template::Error.new(template)
end
+ when "/xss_error"
+ raise "x</script><script>alert(1)</script>"
else
raise "puke!"
end
@@ -978,6 +980,16 @@ def self.build_app(app, *args)
assert_select "#container code", /undefined local variable or method ['`]string”'/
end
+ test "exception message is escaped in copy-to-clipboard script tag" do
+ @app = DevelopmentApp
+
+ get "/xss_error", headers: { "action_dispatch.show_exceptions" => :all }
+ assert_response 500
+
+ assert_no_match "<script>alert(1)</script>", body
+ assert_match "<script>alert(1)</script>", body
+ end
+
test "includes copy button in error pages" do
@app = DevelopmentApp
@@ -998,7 +1010,6 @@ def self.build_app(app, *args)
assert_response 500
assert_no_match %r{<button}, body
- assert_no_match %r{<script}, body
end
test "exception message includes causes for nested exceptions" do