Rails XSS, and the four places the escape does not reach
Rails escapes five characters on its way into a template and people read that as "Rails handles
XSS", which is true for a text node and false everywhere else in the document. The escape is one
table lookup with no idea where the value is going: the same five substitutions run whether the
value lands between two <p> tags, inside href="...", inside class= with no quotes around it,
or between <script> and </script>. Only the first of those four is actually closed by it. The
escape misses the other three, and it misses a fourth place that is not in a template at all: a
translation key whose name ends in _html.
Everything below was run against a generated Rails 8.1.3.1 app on Ruby 4.0.5, Apple M2 Max, macOS
arm64-darwin25, with PostgreSQL on port 15432, rails-html-sanitizer 1.7.1, loofah 2.25.2, nokogiri
1.19.4 and Brakeman 8.0.6. The probe views are one line each and their source is in this page; the
responses are pasted out of curl. One thing I could not run: headless Chrome 153.0.8010.53 would
not reach the dev server from the command line, it served its own error page instead of my probe
every time, so nothing here claims to have watched a browser execute a payload. The claims stop at
what the HTML says and at how Nokogiri's HTML5 parser, which implements the WHATWG parsing
algorithm, reads it.
What the escape actually is
ERB::Util::HTML_ESCAPE, at
activesupport-8.1.3.1/lib/active_support/core_ext/erb/util.rb:40, is the whole of it:
HTML_ESCAPE = { "&" => "&", ">" => ">", "<" => "<", '"' => """, "'" => "'" }
Five entries. ERB::Util.html_escape two lines up calls the unwrapped version and marks the result
html_safe, which is why running it twice does not double escape. Confirmed on the probe app:
ERB::Util.html_escape(%(a<b>&'"))
# => "a<b>&'""
Nothing in that table is a space, a colon, a slash, a tab or a newline, and nothing in it depends on context. That is the entire story of this page. A single quote and a double quote both being in the set is what makes a quoted attribute safe from breakout, and the absence of everything else is what leaves the other three cases open.
The one case that is genuinely closed: a value between tags. <p><%= params[:q] %></p> with
q=<script>alert(1)</script> answered <p><script>alert(1)</script></p>, and there is
no payload that gets out of that. If every untrusted value in your application lands in a text node,
you have no XSS and you can stop reading.
The href attribute, where escaping is irrelevant
A URL attribute is the case people get wrong most, because the escaping works perfectly and the hole is still open. Two probe views:
<a href="<%= params[:q] %>">click</a>
<%= link_to "click", params[:q] %>
A GET with q=javascript:alert(1) answered the same bytes from both:
<a href="javascript:alert(1)">click</a>
Not one character of that payload is in HTML_ESCAPE. The escape ran, substituted nothing, and
produced a working anchor. Escaping does stop the other attack on that line: q=" onmouseover="alert(1)
answered <a href="" onmouseover="alert(1)">click</a>, and Nokogiri reads exactly one
attribute off it, href, whose value is the literal string " onmouseover="alert(1). So a quoted
attribute cannot be broken out of and can still be handed a scheme that executes.
link_to has no opinion about the scheme. ActionView::Helpers::UrlHelper#link_to is at
actionview-8.1.3.1/lib/action_view/helpers/url_helper.rb:198 and there is no protocol check
anywhere in it. Whatever you pass as the second argument becomes the href.
The fix is an allowlist on the scheme, and the naive version of it does not work. A
start_with?("javascript:") guard caught one payload out of four:
naive("javascript:alert(1)") # => "#"
naive("JaVaScRiPt:alert(1)") # => "JaVaScRiPt:alert(1)"
naive(" javascript:alert(1)") # => " javascript:alert(1)"
naive("java\tscript:alert(1)") # => "java\tscript:alert(1)"
URI.parse caught four out of four, because it downcases the scheme itself and raises on the two
with whitespace in them:
def guarded(url)
u = URI.parse(url.to_s)
u.scheme.nil? || %w[http https mailto].include?(u.scheme) ? url : "#"
rescue URI::InvalidURIError
"#"
end
guarded("JaVaScRiPt:alert(1)") # => "#"
guarded(" javascript:alert(1)") # => "#"
guarded("/posts/1") # => "/posts/1"
guarded("https://example.com") # => "https://example.com"
guarded("data:text/html,x") # => "#"
URI.parse("JaVaScRiPt:alert(1)").scheme is "javascript", lowercased, which is the only reason
the case-folded payload is caught by a comparison against a lowercase list. The cost of this guard
is one URI.parse per link plus a rescue, and the thing it will break for you is a relative URL
containing a character URI.parse rejects, which then silently renders as #. If you build hrefs
only from path helpers, you do not need it at all; the guard is for the field where a user typed a
URL.
The unquoted attribute
Quoting the attribute is doing more work than it looks like. Without the quotes, the space is the
delimiter, and no space is in HTML_ESCAPE:
<div class=<%= params[:q] %>>hi</div>
q=a onmouseover=alert(1) answered <div class=a onmouseover=alert(1)>hi</div>, and
Nokogiri::HTML5.fragment read the attributes off it as
{"class" => "a", "onmouseover" => "alert(1)"}. Two attributes, one of them an event handler, out of
a value that was escaped on the way in. Rails cannot help here because by the time the escape runs
there is no information left about whether the interpolation sits inside quotes.
Inside a <script> tag
A <script> body is not HTML, it is raw text, so the HTML escape is worse than useless there: the
browser will not decode the entities it produces. Three probes, all with
q=</script><script>alert(1)</script>.
to_json is safe, and it is safe by default rather than by anyone's care:
<script>var user = <%= { name: params[:q] }.to_json.html_safe %>;</script>
<script>var user = {"name":"\u003c/script\u003e\u003cscript\u003ealert(1)\u003c/script\u003e"};</script>
ActiveSupport.escape_html_entities_in_json is true in a Rails 8.1 app, and that flag is what
turns <, > and & into the six character escapes \u003c, \u003e and \u0026 inside
the JSON string. There is no </script> left in the output, so the parser never leaves script data
state. Swap to_json for JSON.generate and the same view answers:
<script>var user = {"name":"</script><script>alert(1)</script>"};</script>
A live closing tag, straight into the page. JSON.generate is Ruby's, it does not consult the
Active Support flag, and it is what you reach for if you are avoiding Rails monkey patches on
principle. The same divergence on the response side, where escape_json_responses decides it, is in
render json, and the bytes it actually sends.
escape_javascript, aliased j, is the helper that looks like the right answer and is not:
<script>var name = "<%= j params[:q] %>";</script>
<script>var name = "<\/script><script>alert(1)<\/script>";</script>
JS_ESCAPE_MAP at actionview-8.1.3.1/lib/action_view/helpers/javascript_helper.rb:9 does handle
the dangerous sequence: "</" maps to "<\\/", which is why no closing tag survives. But j
returns an unsafe String, so <%= escapes it again on the way out, and the < becomes <.
Parsing that page with Nokogiri::HTML5 and reading doc.at("script").text gives back
var name = "<\/script>";, entities intact, because script content is never entity decoded.
The page is not exploitable and the JavaScript variable now holds the wrong string, which will be
reported to you as a display bug by somebody whose name has an apostrophe in it. Use <%== j(...) %>
if you want j, or do not put data in a script body at all.
The version I actually recommend puts the JSON in an attribute, where the HTML escape is correct and sufficient:
<div data-user="<%= { name: params[:q] }.to_json %>">hi</div>
<div data-user="{"name":"</script><img src=x onerror=alert(1)>"}">hi</div>
One attribute, data-user, and JSON.parse on the value Nokogiri reads back returns the original
string byte for byte. The cost is that the consumer has to be JavaScript that reads
dataset.user, which in practice means a Stimulus controller rather than an inline script, and that
is a cost worth paying.
The _html suffix on a translation key
t has an escaping rule keyed off the name of the key, which is the one piece of Rails escaping
behaviour I have never seen anybody discover on purpose. ActiveSupport::HtmlSafeTranslation, at
activesupport-8.1.3.1/lib/active_support/html_safe_translation.rb:31, decides it with a regexp:
def html_safe_translation_key?(key)
/(?:_|\b)html\z/.match?(key)
end
With greeting: "Hello <b>%{name}</b>" and greeting_html: holding the identical string, and
name set to <img src=x onerror=alert(1)>:
t(:greeting) = "Hello <b><img src=x onerror=alert(1)></b>" html_safe? = false
t(:greeting_html) = "Hello <b><img src=x onerror=alert(1)></b>" html_safe? = true
Two different behaviours from the same string, chosen by the last seven characters of the key. The
_html version escapes the interpolation and marks the surrounding translation safe, which is
exactly right, and the plain version escapes the whole thing including your own <b>, which is why
the first thing anybody does when a translation renders with literal angle brackets is rename the
key. The regexp also matches a key whose final segment is just html, so card.html is treated as
safe; it does not match outerhtml, because \b needs a word boundary before the h.
The consequence worth stating: with an _html key, the locale file is code. If your translations
are edited by anybody outside the repository, a translation management platform or a client with
access to a YAML file, then every _html key in that file is an XSS vector and the escaping rule
above is protecting the wrong half of the string.
The 20 methods that drop the safe flag
ActiveSupport::SafeBuffer tracks safety on the object, so the question of which operations
preserve it has a precise answer. UNSAFE_STRING_METHODS at
activesupport-8.1.3.1/lib/active_support/core_ext/string/output_safety.rb:20 holds 20 names:
capitalize chomp chop delete delete_prefix delete_suffix downcase lstrip next reverse
rstrip scrub squeeze strip succ swapcase tr tr_s unicode_normalize upcase
with gsub and sub in a second list six lines down. Each of those returns a plain String, so the
next <%= escapes the result. Verified:
b = "<b>ok</b>".html_safe
b.upcase.html_safe? # => false
b.strip.html_safe? # => false
b.sub("ok", "x").html_safe? # => false
b[0, 3].html_safe? # => true
(b * 2).html_safe? # => true
[] is the one to know about, and it is deliberate: SafeBuffer#[] at output_safety.rb:38
re-wraps the result when the receiver was safe. So "<b>ok</b>".html_safe[0, 3] is the safe buffer
"<b>", an opening tag with no closer. truncate does not hand you that by default, because it
escapes first, but truncate("<b>hello world</b>".html_safe, length: 8, escape: false) returned
"<b>he..." marked safe, which is a <b> the browser will keep applying until it finds a closer
somewhere else on the page.
Concatenation goes the other way and is the part that works well. + and << both escape the
argument rather than trusting it:
"<b>ok</b>".html_safe + "<i>" # => "<b>ok</b><i>"
So building markup by appending to a safe buffer is safe by construction. The way out of it is
safe_concat, which appends without escaping: "<b>".html_safe.safe_concat("<i>") returned
"<b><i>". The other way out is .html_safe on a string you interpolated into, which is
the injection in the render html page.
sanitize costs 304 times what escaping costs
sanitize is the right answer when the markup itself is untrusted, and it is not free, because it
is a full Nokogiri parse. Best of five runs of 2000 calls each on 1380 bytes of ordinary HTML,
20 paragraphs with a bold and a link in each:
input bytes: 1380
html_escape 0.0039 ms/call
sanitize 1.1730 ms/call (304x)
strip_tags 1.9144 ms/call (496x)
strip_tags being slower than sanitize is not a typo and it surprised me. Both parse; the full
sanitizer then serialises text only, and on this input that is more work than scrubbing attributes.
What sanitize gets right is the thing the href section above needed. Loofah checks the protocol
at loofah-2.25.2/lib/loofah/html5/scrub.rb:196, after entity decoding, control character
stripping and downcasing, against a 27 entry ALLOWED_PROTOCOLS. Every case-folded and
whitespace-padded variant that beat the naive guard lost the whole attribute, and so did a
data:text/html URI:
sanitize %(<a href="javascript:alert(1)">x</a>) # => "<a>x</a>"
sanitize %(<a href="JaVaScRiPt:alert(1)">x</a>) # => "<a>x</a>"
sanitize %(<a href="java\tscript:alert(1)">x</a>) # => "<a>x</a>"
sanitize %(<a href="data:text/html,<script>alert(1)</script>">x</a>) # => "<a>x</a>"
data is on ALLOWED_PROTOCOLS, which reads alarming until you get to line 199, where a data:
URI is held to ALLOWED_URI_DATA_MEDIATYPES, five entries: image/gif, image/jpeg, image/png,
text/css, text/plain. A data:image/png;base64, src passes through untouched.
strip_tags is safe to render, which I had to check rather than assume: it re-escapes the characters
it leaves behind, so strip_tags("1 < 2") returns "1 < 2" marked html_safe. What it does not
do is remove the text that was inside the tag it removed:
strip_tags("<script>alert(1)</script>") returns "alert(1)", which renders on the page as the
literal string alert(1) and gets filed as a rendering bug.
The dead end: sanitising the URL
Faced with <a href="<%= url %>"> and a javascript: payload, the reflex is to wrap the value in
sanitize. That does nothing at all, and it took me a minute to see why:
sanitize("javascript:alert(1)") # => "javascript:alert(1)"
strip_tags("javascript:alert(1)") # => "javascript:alert(1)"
link_to("click", sanitize("javascript:alert(1)")) # => "<a href=\"javascript:alert(1)\">click</a>"
sanitize is an HTML parser and a bare URL is not HTML, so there is nothing for it to scrub. The
protocol check at scrub.rb:196 only fires for an attribute named in ATTR_VAL_IS_URI, and that
requires the attribute to exist. Sanitising the finished anchor works:
sanitize(link_to("click", "javascript:alert(1)")) # => "<a>click</a>"
and costs a parse per link, 33.7 μs against the 2.0 μs link_to takes on its own for a 28 byte
anchor, best of five runs of 5000. It also discards any attribute link_to added that is not on the
allowlist, data-turbo-method among them. Guard the scheme before link_to, not the HTML after it.
What Brakeman 8.0.6 caught
Brakeman on the probe app, 16 templates, 5 security warnings, 4 of them cross site scripting:
Confidence: High
Category: Cross-Site Scripting
Check: LinkToHref
Message: Unsafe parameter value in `link_to` href
Code: link_to("click", params[:q])
File: app/views/probes/link_to.html.erb
Line: 1
Confidence: High
Category: Cross-Site Scripting
Check: CrossSiteScripting
Message: Unescaped parameter value
Code: params[:q]
File: app/views/probes/raw_vs_equals.html.erb
Line: 1
Confidence: Weak
Category: Cross-Site Scripting
Check: CrossSiteScripting
Message: Unescaped parameter value
Code: JSON.generate(:name => params[:q])
File: app/views/probes/script_jsongenerate.html.erb
Line: 1
The LinkToHref check is real and it is the reason to run Brakeman. What it did not report is the
interesting half. <a href="<%= params[:q] %>">click</a> produced no warning, and that is the
identical hole written in ERB instead of a helper. Neither did <div class=<%= params[:q] %>>. Both
files were scanned, both are in the 16 templates, and the checklist Brakeman runs contains
LinkToHref, which only inspects link_to calls.
So Brakeman covers the helper form of the URL case and nothing of the raw form, and covers none of
the unquoted attribute case. A green Brakeman run is a statement about raw, html_safe and a
list of named helpers. It is not a statement about your attributes.
Content Security Policy ships commented out
config/initializers/content_security_policy.rb in a Rails 8.1.3.1 app has every executable line
commented out, so a freshly generated application sends no CSP header. Verified with curl -I
against the probe app: no content-security-policy in the response. Uncommenting the policy block
and the nonce generator, exactly as generated, produced this on the next request:
content-security-policy: default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src 'self' https: 'nonce-'; style-src 'self' https: 'nonce-'
'nonce-' with nothing after it, because the generated generator is
->(request) { request.session.id.to_s } and session.id is nil until something writes to the
session. On a probe view that sets session[:seen], the same header came back with
'nonce-27d32173f9a49fd2e9d99b1d83a981c7'. That nonce is the session id, so it is constant for the
life of the session rather than per response, which is a weaker thing than a nonce usually means.
The generated file also leaves config.content_security_policy_nonce_auto commented out, so
javascript_tag "var x = 1" rendered with no nonce attribute and would be blocked by the
script-src above. That is the whole cost of turning CSP on: every inline script and inline style
in the application needs a nonce or needs to move to a file, and script-src 'self' https: as
generated allows any script from any HTTPS origin, which is broad enough that it stops almost
nothing an injected <script src> would want to do. A CSP worth the migration starts by deleting
https: from that line.
The call, and what would change it
My position after running all of this: the five character escape is correct and sufficient for text
nodes, and every other context needs a decision made by hand. Concretely, three rules. Quote every
attribute, always, with no exception for a value you think is a number. Treat any attribute whose
value is a URL as untrusted output regardless of escaping, and put a scheme allowlist in front of
it. Put nothing in a <script> body; use a data- attribute and to_json.
What would change the first two: link_to refusing a scheme outside an allowlist by default, with
an opt-out, the way redirect_to gained allow_other_host. That is a breaking change for anybody
rendering mailto: or tel: from a database column, and it would close the single most common
Rails XSS I know of. A Brakeman check that flagged an interpolation inside href="..." in ERB, not
only inside link_to, would get most of the way there without a framework change.
The tests
22 tests, 51 assertions, in test/integration/xss_test.rb on the probe app:
Run options: --seed 44116
# Running:
......................
Finished in 0.065239s, 337.2216 runs/s, 781.7410 assertions/s.
22 runs, 51 assertions, 0 failures, 0 errors, 0 skips
Every response body quoted above is asserted with assert_equal against the exact string, not a
match?, because the claim is about the bytes. The two attribute cases additionally parse the
result with Nokogiri::HTML5.fragment and assert on the attribute hash, since "escaping happened"
and "one attribute came out" are different claims and only the second one matters.
What this post does not cover
render html:, render inline: and the .html_safe interpolation that is the classic Rails
injection, which have their own page in
Rendering HTML in Rails. Action Text and Trix, where the sanitizer
allowlist is wider than the default 43 tags and gets wider again if you install Lexxy. Stored XSS
through a Markdown renderer, which is a question about your renderer's options and not about Rails.
DOM XSS, where the injection never touches the server and no amount of ERB escaping is relevant.
CSRF, session fixation and the rest of the Securing Rails Applications guide. And CSP beyond the
header bytes: no browser executed anything in this post, so nothing here measures what a policy
blocks in practice, only what Rails sends.
Comments
No comments yet. Be the first.