LaunchKit
← All posts
· 16 min read · by The LaunchKit team · 0 views

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 = { "&" => "&amp;",  ">" => "&gt;",   "<" => "&lt;", '"' => "&quot;", "'" => "&#39;" }

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&lt;b&gt;&amp;&#39;&quot;"

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>&lt;script&gt;alert(1)&lt;/script&gt;</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="&quot; onmouseover=&quot;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 = "&lt;\/script&gt;&lt;script&gt;alert(1)&lt;\/script&gt;";</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 &lt;. Parsing that page with Nokogiri::HTML5 and reading doc.at("script").text gives back var name = "&lt;\/script&gt;";, 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="{&quot;name&quot;:&quot;</script><img src=x onerror=alert(1)>&quot;}">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>&lt;img src=x onerror=alert(1)&gt;</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>&lt;i&gt;"

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 &lt; 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.

#rails #security

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.