flawopen.com/xss/Ruby
Cross-Site Scripting (XSS) in Ruby (Rails, ERB, Rails::HTML)
Calling html_safe or raw on user input, rendering an interpolated string with render html:, or passing an unchecked URL to link_to lets a visitor's text run as JavaScript in other users' browsers (CWE-79). What Rails escapes for you, the helpers that switch escaping off, and the safe replacement for each.
Think of Rails SafeBuffer as a trust stamp on a print job. ERB <%= %> escapes ordinary values, but <%== %>, raw, or html_safe stamps a value as already safe and turns escaping off. That stamp does not sanitize it. link_to escapes the href syntax but does not approve its scheme, so validate destinations separately and allow only http or https.
Core Concepts & Subsystem Terms
ERB <%= %> vs <%== %> / raw- In Rails views,
<%= value %>HTML-escapes any string that is not marked safe.<%== value %>andraw(value)output it unescaped. Every use of the unescaped forms needs content that is sanitized or constant. String#html_safehtml_safedoes not escape anything: it returns anActiveSupport::SafeBufferthat Rails will output as-is. Calling it on a string that contains interpolated user input publishes that input as HTML.content_tag and safe_joincontent_tag(:p, text)escapestextand wraps it in a tag;safe_join([a, b])escapes each unsafe element and joins them into a safe buffer. Use them to build HTML in helpers instead of interpolating into a string and callinghtml_safe.sanitize(html, tags:, attributes:)- Rails'
sanitizeuses an allowlist (Rails::HTML5::SafeListSanitizer in Rails 7.1+) to keep permitted tags and attributes and drop scripts, event handlers andjavascript:URLs. Restricttags:andattributes:to what the feature actually needs. link_to and javascript: URLslink_toescapes thehrefattribute value but does not check the scheme, sojavascript:alert(1)becomes a clickable script. Parse the URL and allow onlyhttpandhttpsbefore building the link.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-79
- OWASP Top 10:2025 — A05:2025 Injection
Step-by-Step Attack Flow
Input reaches a view or helper
A profile form saves name, bio and website, and a search link carries q. Stored values are shown to every visitor; the search value can be sent to a victim as a crafted link.
A helper disables escaping
html_safe, raw or render html: with an interpolated string mark the value as trusted, and link_to accepts any scheme. The benign test value <img src=x onerror=alert(1)> reaches the page unchanged.
The browser parses it as markup
The page contains a real <img> element and onerror fires; a stored javascript:alert(1) website runs when the link is clicked.
The script runs as the site
The code executes with the application's origin: it can read the DOM, submit forms with the page's CSRF token and act with the victim's session.
Source Code: Flaw vs. Secure Implementation
# profiles_helper.rb: each helper below lets user text reach the page as HTML
module ProfilesHelper
# html_safe marks the interpolated name as trusted HTML
def greeting(name)
"<p>Hello, #{name}</p>".html_safe
end
# raw outputs the bio without escaping
def bio_html(bio)
raw(bio)
end
# link_to accepts a javascript: URL
def website_link(url)
link_to "Website", url
end
end
class SearchController < ApplicationController
# the interpolated string is marked safe before rendering
def show
render html: "<h1>Results for #{params[:q]}</h1>".html_safe
end
end
# profiles_helper.rb: Rails escapes text; HTML and URLs are checked first
module ProfilesHelper
ALLOWED_TAGS = %w[p br strong em a ul ol li].freeze
# content_tag escapes the name and wraps it in <p>
def greeting(name)
content_tag(:p, "Hello, #{name}")
end
# sanitize keeps an allowlist of tags and attributes
def bio_html(bio)
sanitize(bio, tags: ALLOWED_TAGS, attributes: %w[href])
end
# only http and https URLs become links
def website_link(url)
uri = URI.parse(url.to_s)
return "" unless %w[http https].include?(uri.scheme)
link_to "Website", uri.to_s, rel: "nofollow noopener"
rescue URI::InvalidURIError
""
end
end
class SearchController < ApplicationController
# safe_join escapes the query before it is wrapped in <h1>
def show
render html: helpers.content_tag(:h1, safe_join(["Results for ", params[:q].to_s]))
end
end
Engineering & System Hardening Checklist
- ✓Run Brakeman in CI and fail the build on Cross-Site Scripting warnings, including
html_safe,rawandlink_towith user-supplied URLs. - ✓Grep views and helpers for
html_safe,raw(,<%==andrender html:; each hit must wrap sanitized or constant content. - ✓Build markup in helpers with
content_tag,tagandsafe_joinrather than interpolated strings. - ✓Pass user-supplied HTML through
sanitizewith explicittags:andattributes:allowlists, and validate URL schemes beforelink_to. - ✓Enable the Rails Content Security Policy (
config/initializers/content_security_policy.rb) withcontent_security_policy_nonce_generator, and keep the session cookieHttpOnlyandSameSite=Lax.
Sources
- CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
- Rails Guides — Securing Rails Applications: Cross-Site Scripting
- Rails API — ActionView::Helpers::SanitizeHelper#sanitize
- Rails API — ActionView::Helpers::OutputSafetyHelper (raw, safe_join)
- Brakeman — Cross-Site Scripting warnings
- OWASP Cross Site Scripting Prevention Cheat Sheet