Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix XSS in Ruby on Rails

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Let ERB escape: <%= @name %> and tag helpers such as image_tag(..., alt: @name) escape & < > " and ', unless the value is html_safe. Never call raw, html_safe or <%== on user input; for rich text use sanitize with an allowlist. Link only http and https URLs: escaping does not stop javascript:.

Stack
  • Language Ruby
  • Runtime Ruby 4.0.7
  • Framework Ruby on Rails 8.1.4 (ERB views)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks8 cards
Download the project ZIP, 23 KB
On this page

Steps

  1. Find raw, html_safe, <%==, safe_join on untrusted parts, content_tag with escape: false, and helpers that build HTML strings by interpolation.
  2. Print values with <%= %> and pass attributes through tag helpers (image_tag, tag.img, link_to); both escape for HTML text and quoted attributes.
  3. For user-written rich text, use sanitize(html, tags: [...], attributes: [...]) with the smallest allowlist you need, not strip_tags plus raw.
  4. Check URLs before link_to or href: parse them and allow only http and https; URI.parse refuses the tab and newline characters browsers would quietly delete.
  5. Add a Content Security Policy (config/initializers/content_security_policy.rb) as a second layer, and regression tests that parse the page with an HTML5 parser.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableapp/views/profiles/vulnerable.html.erbERB
<img src="/avatar.svg" alt="<%== @name %>" width="64" height="64">
<h1><%= raw @name %></h1>
<p class="bio"><%= @bio.html_safe %></p>

With IMPL=vulnerable, 7 of 9 tests fail (5 exploit tests).

The fix: output encoding

Fixedapp/views/profiles/fixed.html.erbERB
<%= image_tag "/avatar.svg", alt: @name, width: 64, height: 64 %>
<h1><%= @name %></h1>
<p class="bio"><%= @bio %></p>

With IMPL=fixed, 9 of 9 tests pass.

The fix: URL scheme allowlist

Fixedapp/views/profiles/fixed.html.erbERB
<% if web_url?(@website) %>
    <%= link_to "Website", @website %>
<% end %>

With IMPL=fixed, 9 of 9 tests pass.

The fix: the web_url? helper

Fixedapp/helpers/profiles_helper.rbRuby
# Escaping keeps a URL inside its href attribute, but javascript:alert(1) is still a
# javascript: URL once escaped, so only http and https links are allowed. URI.parse refuses
# characters a browser would quietly delete, such as the tab in "java\tscript:".
def web_url?(url)
  %w[http https].include?(URI.parse(url).scheme&.downcase)
rescue URI::InvalidURIError
  false
end

With IMPL=fixed, 9 of 9 tests pass.

A fix that looks safe but is not

Wrong fixapp/views/profiles/wrong.html.erbERB
<%# strip_tags removes every tag, so the text can go out raw and entities in old profiles (&eacute;) still render. %>
<img src="/avatar.svg" alt="<%= raw strip_tags(@name) %>" width="64" height="64">
<h1><%= raw strip_tags(@name) %></h1>
<p class="bio"><%= raw strip_tags(@bio) %></p>

With IMPL=wrong, 4 of 9 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/profile_test.rbRuby
# Exploit: what a browser would run from the page must stay inert.

# No script elements, no event-handler attributes, and only http(s) links.
def assert_nothing_executable(page)
  assert_empty page.elements.select { |e| e.name == "script" }.map(&:text), "script element"
  handlers = page.elements.flat_map { |e| e.attribute_nodes.map(&:name).grep(/\Aon/).map { |n| "#{e.name} #{n}" } }
  assert_empty handlers, "event-handler attribute"
  page.links.each do |href|
    assert_includes ["", "http", "https"], Page.url_scheme(href), "link to a #{Page.url_scheme(href)}: URL: #{href.inspect}"
  end
end

test "exploit: script element in the bio does not run" do
  assert_nothing_executable(profile(name: "Mallory", bio: "<script>alert(document.domain)</script>"))
end

test "exploit: event-handler attribute in the bio does not run" do
  assert_nothing_executable(profile(name: "Mallory", bio: "<img src=x onerror=alert(document.domain)>"))
end

test "exploit: quote in the name cannot break out of the alt attribute" do
  name = '" onerror="alert(document.domain)'
  page = profile(name:)
  assert_nothing_executable(page)
  assert_equal name, avatar_alt(page)
end

test "exploit: javascript: website is not linked" do
  assert_nothing_executable(profile(name: "Mallory", website: "javascript:alert(document.domain)"))
end

test "exploit: tab inside the scheme does not smuggle a javascript: URL through" do
  # Browsers delete tabs and newlines from a URL before parsing it.
  assert_nothing_executable(profile(name: "Mallory", website: "java\tscript:alert(document.domain)"))
end

Pitfalls

  • html_safe does not make anything safe: it marks a string as already escaped, so Rails stops escaping it. raw(x) and <%== x %> do the same.
  • strip_tags removes tags but not quotes, so raw(strip_tags(name)) in an attribute still breaks out of it: this card's wrong implementation shows a quote turning the alt text into an onerror attribute. It also mangles legitimate text that contains < and >.
  • link_to escapes the URL for the attribute, but javascript:alert(1) is still a javascript: URL once escaped; blocking the scheme by name misses java\tscript: and upper-case variants.
  • sanitize is for HTML you mean to keep: pass the smallest tags: and attributes: allowlist you need and test it with your own payloads; it is not a fix for text that should never contain markup.
  • Rails 8.1.2.1, 8.0.4.1 and 7.2.3.1 fixed XSS in Action View tag helpers and in SafeBuffer#% (CVE-2026-33168 and CVE-2026-33170); stay on a patched release.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs xss/ruby-rails does this for you, in the pinned Ruby container).
  2. Against your own pages, submit a script tag, an img with onerror, a quote followed by an event handler, and javascript: and java<TAB>script: links, then check the parsed page for scripts, on* attributes and non-http links.
  3. Brakeman's CrossSiteScripting and LinkToHref warnings and Semgrep's template rules report raw, html_safe and link_to with a variable; neither sees a template chosen at run time, as here.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix XSS in Ruby on Rails
ToolRuleWhat it found here
Semgrepruby.rails.security.audit.xss.templates.avoid-raw.avoid-raw1 finding in vulnerable.html.erb and 3 in wrong.html.erb (raw strip_tags(...)), none in fixed.html.erb.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id>
Semgrepruby.rails.security.audit.xss.templates.avoid-html-safe.avoid-html-safe1 finding in vulnerable.html.erb (@bio.html_safe).Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id>
Semgrepruby.rails.security.audit.xss.templates.alias-for-html-safe.alias-for-html-safe1 finding in vulnerable.html.erb (the <%== in the alt attribute).Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id>
Semgrepruby.rails.security.audit.xss.templates.dangerous-link-to.dangerous-link-to1 finding in each template, fixed.html.erb included: it reports any variable passed to link_to and cannot see the web_url? check.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id>
Semgrepp/ruby, p/owasp-top-ten, p/security-auditThe template rules above are not in these packs; p/security-audit reported ruby.lang.security.missing-csrf-protection on ApplicationController, a false positive with load_defaults 5.2 and later.Checked: Semgrep 1.180.0 (CE); --config p/ruby, p/owasp-top-ten and p/security-audit on app/ and config/
CodeQLrb/reflected-xssNot expected to report anything on this card: XSS.qll follows controller instance variables only into the action's default template (app/views/profiles/show.html.erb), and the card renders "profiles/#{impl}". The sinks it knows are <%==, raw, html_safe and the URL argument of link_to; strip_tags has no model, so raw(strip_tags(...)) in the wrong template would not be flagged, and there is no javascript: scheme check, so any request data in link_to counts.Checked: github/codeql main at b5b8165; query and Ruby library source read, not executed
BrakemanCrossSiteScripting, LinkToHrefCross-site scriptingNo warnings on this card: the controller picks the template with render "profiles/#{impl}", and Brakeman cannot follow params into a template it cannot name. On a probe that renders each template from its own action it reported the three unescaped fields in vulnerable.html.erb (High), the three raw strip_tags fields in wrong.html.erb (Weak) and LinkToHref (High) in all three templates, fixed.html.erb included.Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q /card (all checks, optional ones included)

Libraries and versions

Libraries and versions
ComponentVersion
LanguageRuby
RuntimeRuby 4.0.7
FrameworkRuby on Rails 8.1.4 (ERB views)
rails (railties, actionpack, actionview)8.1.4
nokogiri (HTML5 parser in the tests)1.19.4
minitest6.0.6
minitest-reporters (JUnitReporter)1.8.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xss/ruby-railsfolder.

Shell
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install
Shell
# Expected: every test passes
IMPL=fixed BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/profile_test.rb

# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/profile_test.rb

# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/profile_test.rb

Without Ruby installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=fixed -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/profile_test.rb'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=vulnerable -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/profile_test.rb'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=wrong -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/profile_test.rb'

Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs xss/ruby-rails runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 23 KB

Verified

Machine
Debian 13 (trixie) in Docker, on macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • ruby 4.0.7 (ruby:4.0.7-trixie image, linux/arm64)
  • bundler 4.0.20
  • rails 8.1.4
  • minitest 6.0.6
  • minitest-reporters 1.8.0
  • nokogiri 1.19.4
  • semgrep 1.180.0
  • brakeman 8.1.0
Matrix
fixed: 9 of 9 tests pass; vulnerable: 7 of 9 tests fail (5 exploit tests); wrong: 4 of 9 tests fail (2 exploit tests)
Test results
fixed 9/9 pass; vulnerable 7/9 fail (5 exploit tests, plus the two functional tests that show markup as typed); wrong 4/9 fail (2 exploit tests: the quote that breaks out of alt and java<TAB>script:, plus the same two functional tests), test runs offline (--network=none) after install; node examples/fixes/run-all.mjs ruby-rails --install, results/2026-10-11-fix-rails.json
Dependency freshness
Rails 8.1.4 was published on 2026-09-24 and Brakeman 8.1.0 on 2026-10-01. uri 1.1.2 and prettyprint 0.2.1 (both 2026-10-08) were three days old and are not security releases, so the Gemfile holds uri at 1.1.1 and prettyprint at 0.2.0; every other gem in Gemfile.lock is at least 13 days old. OSV reported no known vulnerabilities for any pin. Ruby 4.0.7 (2026-09-15) is the latest 4.0 release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Cross Site Scripting Prevention Cheat Sheet
  2. Rails Guides: Securing Rails Applications, cross-site scripting
  3. Rails API: ActionView::Helpers::OutputSafetyHelper (raw, safe_join)
  4. Rails API: ActionView::Helpers::SanitizeHelper (sanitize, strip_tags)
  5. Rails API: ActionView::Helpers::UrlHelper (link_to)
  6. Rails security releases 7.2.3.1, 8.0.4.1 and 8.1.2.1 (Action View and SafeBuffer XSS)
  7. Brakeman: cross-site scripting
  8. Brakeman: link_to href
  9. CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.