FlawOpen

flawopen.com/xss/Ruby

● CWE-79 · High
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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 %> and raw(value) output it unescaped. Every use of the unescaped forms needs content that is sanitized or constant.
String#html_safe
html_safe does not escape anything: it returns an ActiveSupport::SafeBuffer that Rails will output as-is. Calling it on a string that contains interpolated user input publishes that input as HTML.
content_tag and safe_join
content_tag(:p, text) escapes text and 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 calling html_safe.
sanitize(html, tags:, attributes:)
Rails' sanitize uses an allowlist (Rails::HTML5::SafeListSanitizer in Rails 7.1+) to keep permitted tags and attributes and drop scripts, event handlers and javascript: URLs. Restrict tags: and attributes: to what the feature actually needs.
link_to and javascript: URLs
link_to escapes the href attribute value but does not check the scheme, so javascript:alert(1) becomes a clickable script. Parse the URL and allow only http and https before building the link.

Framework mappings

Step-by-Step Attack Flow

Step 1

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.

Step 2

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.

Step 3

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.

Step 4

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

UNPATCHED FLAW
# 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
HARDENED SECURE PATCH
# 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

Sources