htmlsafeslip
Flags a .html_safe call, or a raw(...) call, applied to a value
traceable — directly, or via a same-method local variable — to
params[...]/params.dig(...), with no sanitization call applied to
it first.
Both .html_safe and raw(...) tell Rails' view layer "this string is
already safe HTML, don't auto-escape it." Rails' default auto-escaping
(on by default since Rails 3) is the single biggest built-in XSS defense
the framework has — calling either directly on unsanitized,
user-controlled input defeats it entirely, turning a <script>...</script>
payload typed into a comment, bio, or URL param into live, executing
HTML the instant it's rendered. See DETAILS.md for the
exact failure mode.
Honest prior-art note: Brakeman — the standard, widely used Rails security scanner — already has a mature, built-in check (
CheckCrossSiteScripting) for exactly this pattern class. This is a real, meaningful overlap, not a different, uncovered gap. See DETAILS.md for the full, honest comparison — in short, Brakeman requires a full, bootable Rails application to run against;htmlsafeslipis a standalone, zero-Rails/Brakeman-dependency Ripper check that runs against any.rbfile or directory, with no app-boot step.
Quick start
gem install htmlsafeslip
htmlsafeslip check app/controllers app/views app/helpershtmlsafeslip: 1 finding(s)
[HS001] app/controllers/comments_controller.rb:4 params[:comment].html_safe -- CommentsController#create: params[:comment].html_safe marks a value traceable to `params[:comment]' in #create as already-safe HTML, with no sanitize(...)/strip_tags(...)/h(...)/ERB::Util.html_escape(...) call applied to it first -- this defeats Rails' default auto-escaping entirely, so a <script>...</script> (or any other HTML) payload in this value executes live, the instant it's rendered. Run it through sanitize(...) or strip_tags(...) before marking it safe, or drop .html_safe and let Rails auto-escape it. See DETAILS.md.
Exits 1 if any finding, 0 otherwise — wire it into CI as a pre-merge
gate on app/controllers/, app/views/, and app/helpers/.
As a library
require "htmlsafeslip"
findings = Htmlsafeslip.check(paths: Dir.glob("app/**/*.rb"))
findings.each { |f| puts "[#{f.code}] #{f.kind} on #{f.receiver} (#{f.file}:#{f.line})" }What it checks (v0.1)
A .html_safe call, or a raw(...) call, on a value that is:
- a direct
params[...]/params.dig(...)read, or - a bare local variable assigned from one of those earlier in the same method (traced anywhere later in the same method body),
...with no sanitization call (sanitize(...),
ActionController::Base.helpers.sanitize(...), strip_tags(...),
ERB::Util.html_escape(...)/h(...)) applied to that value first,
anywhere in the chain — including nested inside raw(...)'s own
argument (raw(sanitize(params[:x])) is correctly recognized as safe).
params[:bio].html_safe # flagged
raw(params[:comment]) # flagged
bio = params[:bio]; bio.html_safe # flagged (same-method trace)
params.dig(:user, :bio).html_safe # flagged
sanitize(params[:bio]).html_safe # not flagged -- sanitized first
strip_tags(params[:bio]).html_safe # not flagged -- sanitized first
raw(sanitize(params[:x])) # not flagged -- nested sanitize
"<b>Static trusted HTML</b>".html_safe # not flagged -- hardcoded literal
raw("<hr>") # not flagged -- hardcoded literal
h(params[:name]) # not flagged -- out of scope, the safe pattern
@user.bio.html_safe # not flagged -- DB attribute, see limitationNot flagged / explicitly out of scope (see DETAILS.md for the full, honest list):
- A database/model-attribute value with no traceable connection to
paramsin the same method — a real, documented scope limitation, not a guarantee the value is actually safe (it may have originated from an unsanitizedparamsvalue written in a different method/request entirely). - A hardcoded string literal — the common, legitimate Rails idiom for marking genuinely-static trusted HTML as safe.
-
h(...)/ERB::Util.html_escape(...)calls themselves — these are the safe escaping pattern, so they're never matched as findings at all (only recognized as a sanitizer when they wrap a traced value before.html_safe/raw(...)). - Same-method scope only — a variable assigned from
paramsin one method isn't traced into a different method, even under the identical variable name.
Class/method boundaries are found by parsing with Ruby's own Ripper,
not regex over raw source — see DETAILS.md for exactly
how, and its honest limits.
Requirements
Developed and tested against Ruby 3.0.2, matching this workspace's other Ruby packages. No known incompatibility with newer 3.x versions. No Rails or ActionView installation is required to run htmlsafeslip — it's a pure static-source check.
License
MIT