ruby / escaping
Templates escape with = by default, and
the Haml subset rejects raw HTML
construction. A small number of code paths still produce HTML
strings that the template emits with != (raw output). Each
of those paths is a place to escape, not a place to trust
input.
Where raw HTML comes from
- formatters that build link or badge HTML in Ruby
- search snippets returned by Postgres
ts_headline - flash messages built by interpolating user-supplied names
Anything that flows into != must be escaped at the source.
Formatters
A formatter that builds HTML in Ruby escapes every dynamic piece, even when it looks safe today:
require "cgi"
module Fmt
module Lists
def self.list(name, url)
esc_name = CGI.escapeHTML(name.to_s)
esc_url = CGI.escapeHTML(url.to_s)
%(<a href="#{esc_url}">#{esc_name}</a>)
end
end
end
The same rule covers titles, domains, event names, and any other user-controlled strings interpolated into HTML. A formatter that returns HTML and forgets to escape one parameter is one of the most common XSS sources in a server-rendered app.
ts_headline
Postgres' ts_headline
returns a snippet with <b>...</b> markers around matching
terms. The terms are user input. If a search query contains
<script>, the snippet will too unless escaped.
Escape the entire snippet, then reintroduce only the markers the function adds:
require "cgi"
def safe_headline(snippet)
CGI.escapeHTML(snippet.to_s)
.gsub("<b>", "<b>")
.gsub("</b>", "</b>")
end
The handler returns this string and the template renders it
with != row.headline.
Flash messages
A flash that interpolates a user-controlled name renders in the next response. Escape on the way in:
flash_next(:notice, "Merged into #{CGI.escapeHTML(target.name)}")
If every flash value is treated as HTML by the layout, the producer is the right place to escape. The reader cannot tell which strings are safe.
One auditable layer
The Haml subset concentrates raw HTML output behind a single
operator (!=). Search the codebase for != and audit every
producer:
rg -nU '^[[:space:]]*!=' ui/views/
Adding the rule "every dynamic value at a != boundary is
escaped at its source" gives a small, finite set of places to
review.
For data that lands in spreadsheets instead of HTML, see ruby / csv for the equivalent neutralization at that boundary.