flawopen.com/Cross-Site Scripting/Ruby

Cross-Site Scripting in Ruby

High CWE-79 Draft — pending review
ELI5

Imagine a guestbook where visitors write a public note. Stored XSS is like someone writing a note that isn't just text — it's a hidden trick that makes the guestbook page itself start doing things, like stealing the next visitor's login session, the moment anyone opens the page to read it.

Key terms on this page
output encoding
Converting characters that have special meaning in HTML (like < and >) into harmless equivalents before inserting untrusted text into a page, so the browser displays it as text instead of running it as markup or script.
DOM
The in-memory tree structure a browser builds from a page's HTML — where an element's innerHTML is set determines whether inserted content is rendered as inert text or executable markup.

What's happening

Cross-site scripting happens when untrusted input reaches the rendered page without proper output encoding. Rails' ERB templates auto-escape by default, so this almost always traces back to .html_safe or the raw() helper being called on untrusted data.

Real-world impact

In 2005, an 19-year-old user exploited a stored XSS flaw on MySpace to create the "Samy" worm — a script that added itself to every profile that viewed it, infecting over a million profiles within about 20 hours and forcing MySpace offline to contain it. It remains one of the most-cited demonstrations of how quickly stored XSS can self-propagate.

Source: widely documented in security industry retrospectives — see References below.

Vulnerable vs. fixed

VULNERABLE
<%# html_safe skips escaping &%>
<div>
  <%= @comment.body.html_safe %>
</div>
FIXED
<%# ERB output auto-escapes by default &%>
<div>
  <%= @comment.body %>
</div>

Why the fix works

Rails wraps ERB output in automatic HTML escaping by default — <%= @comment.body %> converts special characters to their entity equivalents before rendering. .html_safe doesn't sanitize anything; it just marks the string as "trust this as already-safe HTML," which disables escaping for that string entirely.

Ruby/Rails-specific gotchas

.html_safe is a common misunderstanding, not just a footgun

Its name suggests it makes a string safe. It does the opposite: it tells Rails to skip escaping that string, trusting it's already safe HTML. Called on untrusted user input, it reintroduces exactly the vulnerability Rails' default escaping exists to prevent.

raw() is functionally identical to .html_safe

raw(@comment.body) and @comment.body.html_safe both disable escaping for the value — same risk, different syntax, both need the same review scrutiny.

Content marked .html_safe once can propagate that trust incorrectly

If a value is marked .html_safe at one point and later concatenated with untrusted input, Rails may not re-escape the combined string as expected — treat any .html_safe value as permanently sensitive, not just at its point of creation.

Common misconceptions

"ERB auto-escapes everything, so I'm safe by default"

True for plain <%= %> output — false the moment .html_safe or raw() is involved.

"This only matters for comment sections"

Any reflected value counts — a search query echoed on a results page, a URL parameter shown in an error message, a filename displayed after upload.

How to check if you're affected

grep -rn "\.html_safe\|raw(" app/views/
Brakeman flags html_safe/raw usage on request-derived data (warning type "Cross-Site Scripting") automatically — run it in CI.

Prevention checklist

FAQ

Is this the same bug as SQL injection?

Same underlying shape — untrusted data mixed into a command's structure without encoding — but the target and damage differ: SQL injection targets the database, XSS targets other users' browsers.

Does sanitize() make .html_safe safe?

Yes, when applied immediately before marking safe, using an allow-list of permitted tags/attributes — the risk is calling .html_safe without sanitizing first, not the sanitizer itself.

References

View in: Python JavaScript Go Java PHP C# Ruby C/C++ Rust Kotlin Swift Solidity (N/A)