I’ve cleaned up XSS issues in enough Rails apps to know the pattern: the team assumes Rails auto-escaping has them covered, then one helper, one html_safe, or one “temporary” rich text feature quietly blows a hole in the whole thing.
Rails does a lot right by default. That’s true. But most real XSS bugs in Rails don’t come from ERB tags alone. They come from the seams: helpers, JavaScript interpolation, admin tooling, markdown rendering, ActionText assumptions, and legacy code that predates current defaults.
Here’s a real-world style case study based on the kind of cleanup I’ve seen in production.
The app
A mid-sized SaaS platform had:
- Rails 7
- ERB templates
- some Stimulus controllers
- ActionText for internal notes
- a legacy markdown preview feature
- a customer support admin panel
The team believed they were “safe because Rails escapes output.” Mostly true. Not enough.
A bug bounty report showed stored XSS in the support dashboard. From there, we found three more XSS paths that were one copy-paste away from becoming exploitable.
The first bug: helper returns html_safe
This was the original issue.
Support agents could add tags to customer profiles, and those tags appeared as styled pills in the admin panel.
Before
# app/helpers/customers_helper.rb
module CustomersHelper
def render_tag(tag)
"<span class='tag'>#{tag.name}</span>".html_safe
end
end
<% @customer.tags.each do |tag| %>
<%= render_tag(tag) %>
<% end %>
If tag.name contained this:
<script>alert('xss')</script>
the helper wrapped it in trusted HTML and Rails stopped escaping it.
This is one of the oldest Rails footguns. html_safe doesn’t sanitize anything. It just tells Rails, “trust me, I handled it.” Most of the time, the developer didn’t.
After
# app/helpers/customers_helper.rb
module CustomersHelper
def render_tag(tag)
content_tag(:span, tag.name, class: "tag")
end
end
content_tag escapes the text content correctly. Same output, no XSS.
If you genuinely need to assemble HTML fragments, use APIs that escape by default.
def status_badge(label, color:)
content_tag(:span, label, class: "badge badge-#{color}")
end
The rule I push hard in Rails teams is simple: treat html_safe as suspicious until proven otherwise. Every use should trigger review.
The second bug: unsafe interpolation into JavaScript
The next issue was in a page bootstrapping script.
Before
<script>
window.currentUserName = "<%= @current_user.display_name %>";
</script>
Looks harmless. It isn’t.
If display_name contains a quote and script-breaking payload, you get script injection.
For example:
"; alert('owned'); //
renders as:
<script>
window.currentUserName = ""; alert('owned'); //";
</script>
That’s game over.
After
Use JSON encoding when embedding data into JavaScript.
<script>
window.currentUserName = <%= raw(json_escape(@current_user.display_name.to_json)) %>;
</script>
Even better, stop injecting dynamic values directly into inline scripts when possible.
<div
id="profile"
data-current-user-name="<%= @current_user.display_name %>">
</div>
Then read it from Stimulus or plain JS:
const el = document.getElementById("profile")
const currentUserName = el.dataset.currentUserName
```text
That approach reduces XSS risk and makes CSP easier later, because inline scripts are annoying under a strict policy.
## The third bug: markdown preview trusted too much
The app had a customer-facing note editor with markdown preview. The backend rendered markdown to HTML.
### Before
app/helpers/markdown_helper.rb
module MarkdownHelper def render_markdown(text) MarkdownRenderer.render(text).html_safe end end
That’s another classic. Developers trust the markdown library, but many markdown renderers allow raw HTML by default, or can be configured in unsafe ways.
A payload like:
```markdown
Hello <img src=x onerror=alert(1)>
could come back as executable HTML.
After
The fix depends on the renderer, but the broad strategy is the same:
- disable raw HTML in markdown if possible
- sanitize the rendered output with an allowlist
- avoid
html_safeon untrusted content unless it has been sanitized
# app/helpers/markdown_helper.rb
module MarkdownHelper
ALLOWED_TAGS = %w[p br strong em a ul ol li code pre blockquote]
ALLOWED_ATTRS = %w[href title]
def render_markdown(text)
rendered = MarkdownRenderer.render(text)
sanitize(rendered, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRS)
end
end
Rails provides sanitize, which is the right direction for user-controlled rich content. Don’t allow more tags and attributes than you actually need.
I’ve seen teams allow style, class, and arbitrary data-* attributes “for flexibility.” That flexibility becomes your incident report.
The fourth bug: dangerous links in rich text
This one was subtle. The app allowed support agents to save internal notes with rich text. They assumed only trusted staff used it, so nobody worried much about it.
Then one account got compromised.
The attacker inserted a link using a javascript: URL.
Before
<div class="note-body">
<%= @note.body %>
</div>
If @note.body came from rich text or sanitized HTML but URL schemes weren’t filtered correctly, this could survive:
<a href="javascript:alert(document.cookie)">View case</a>
A lot of sanitization efforts focus on tags and forget URL protocols.
After
Use Rails sanitization and verify allowed protocols are constrained by the sanitizer in use. For custom sanitization flows, test javascript:, data:, and weirdly encoded variants.
For plain HTML content:
sanitize(@note.body, tags: %w[p br strong em a ul ol li], attributes: %w[href])
And then actually test payloads, not just happy paths.
I like keeping a tiny request spec file with ugly payloads that should never execute or survive in dangerous form.
RSpec.describe "XSS filtering", type: :helper do
include ActionView::Helpers::SanitizeHelper
it "removes script tags" do
html = sanitize('<script>alert(1)</script><p>ok</p>')
expect(html).to eq("<p>ok</p>")
end
it "removes javascript URLs" do
html = sanitize('<a href="javascript:alert(1)">click</a>')
expect(html).not_to include("javascript:")
end
end
That kind of test catches accidental sanitizer changes during upgrades.
The hidden problem: JSON in script tags
We also found a pattern I see all the time in Rails apps rendering server data into frontend config blobs.
Before
<script>
window.bootstrap = <%= raw @project.to_json %>;
</script>
Developers often think to_json makes it safe. It doesn’t make it safe for HTML script context by itself.
If a string inside the JSON contains </script>, the browser can terminate the script block early.
After
<script>
window.bootstrap = <%= raw json_escape(@project.to_json) %>;
</script>
json_escape matters here. Without it, you can still get script-breaking payloads even though the content is valid JSON.
If you can avoid script-tag bootstrapping entirely and fetch data via JSON endpoints, even better. But when you do embed JSON, escape for the actual output context.
That’s the whole game with XSS: escape for context, not generically.
The cleanup policy we put in place
Fixing individual bugs helped, but the bigger win was changing team habits.
We added a short review checklist:
- No
html_safewithout a security comment explaining why it’s safe - No
rawon user-controlled data - No ERB interpolation into JavaScript without JSON encoding
- All rich text or markdown output goes through sanitization
- Tests for dangerous payloads in helpers rendering HTML
- CSP enabled and tightened
That last one matters.
CSP as damage control
CSP won’t fix a vulnerable template. It will make exploitation harder, and that’s worth a lot in real apps.
In Rails, start with the built-in content security policy support:
# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self
policy.style_src :self, :https
policy.img_src :self, :https, :data
policy.object_src :none
policy.base_uri :self
policy.frame_ancestors :none
end
If your app still depends on inline scripts, you’ll need to work through that carefully. Rails supports nonces, and if you need practical CSP rollout details, https://csp-guide.com is useful.
The official Rails security docs are also worth keeping close during this kind of cleanup: https://guides.rubyonrails.org/security.html
CSP is not a substitute for escaping and sanitizing. I treat it like seatbelts, not brakes.
What changed after the fixes
After the remediation:
- stored XSS in admin tags was gone
- markdown preview no longer allowed active HTML payloads
- frontend bootstrap data was safely encoded
- rich text links were constrained by sanitization
- CSP reduced the blast radius of any future mistake
The bigger change was cultural. The team stopped saying “Rails escapes everything” and started asking the better question:
What context is this data going into, and who marked it as safe?
That’s how Rails apps stay out of XSS trouble. Not by trusting the framework blindly, but by respecting the places where developers override its protections.
If I had to boil it down to one opinionated rule, it’s this: in Rails, XSS usually shows up right after somebody tries to be clever with HTML. Keep your rendering boring, use the helpers Rails gives you, sanitize rich content aggressively, and make every html_safe earn its existence.