Class: RuboCop::Cop::Heatwave::SafeBufferInAttribute

Inherits:
Base
  • Object
show all
Extended by:
AutoCorrector
Defined in:
lib/rubocop/cop/heatwave/safe_buffer_in_attribute.rb

Overview

Flags <%= EXPR %> ERB interpolations that emit html_safe /
ActiveSupport::SafeBuffer content (fa_icon, tag.*, content_tag,
link_to, button_to, any *_tag helper, safe_join, safe_concat,
sanitize, simple_format, highlight, raw, render,
<expr>.html_safe, or a h(...) / html_escape(...) /
ERB::Util.html_escape(...) wrapper — these are no-ops on SafeBuffer
and don't actually fix the bug) inside an HTML attribute value when
the expression is not wrapped in html_escape_once(...).

Rails ERB's auto-escape is a no-op on SafeBuffer, so the inner "
characters from the helper's HTML markup leak through and close the
attribute prematurely. ERB::Util.html_escape_once forces the escape
while leaving any already-encoded entities in the safe content alone,
which is what you want when the value will later be read out (e.g. via
dataset.foo for Turbo's data-turbo-submits-with swap).

The autocorrect emits the fully-qualified ERB::Util.html_escape_once,
not the bare html_escape_once helper: the bare form is only in scope
in app/views/** ERB and raises NoMethodError inside ViewComponent
templates (app/components/**/*.html.erb), which was AppSignal #4429.
The module function is identical in output and resolves in both.

The check is intentionally conservative — it only fires on a fixed
list of known-html_safe call patterns plus a .html_safe trailer, so
plain string interpolations like placeholder="<%= label %>" are not
flagged.

Examples:

# bad — fa_icon's `class="fa-..."` `"`s break out of the attribute
<button data-turbo-submits-with="<%= fa_icon('spinner', family: :solid, class: 'fa-spin') %>">

# good
<button data-turbo-submits-with="<%= ERB::Util.html_escape_once(fa_icon('spinner', family: :solid, class: 'fa-spin')) %>">

Constant Summary collapse

MSG =

Msg.

'`html_safe` content interpolated into an HTML attribute value ' \
'is not auto-escaped by Rails — wrap the expression in ' \
'`ERB::Util.html_escape_once(...)` so the inner `"` characters ' \
'do not close the attribute prematurely.'
DOUBLE_QUOTED_ATTR =

An attribute (name="…" or name='…') whose value contains at least
one <%= … %> interpolation. The surrounding quote char is excluded
from the value content so we don't accidentally span across
neighbouring attributes; the <%= … %> body is .*? so it can carry
the opposite quote (e.g. 'spinner' inside a double-quoted attr).

/\b([a-zA-Z_][\w:-]*)\s*=\s*"((?:[^"]*?<%=.*?%>)+[^"]*?)"/m
SINGLE_QUOTED_ATTR =
/\b([a-zA-Z_][\w:-]*)\s*=\s*'((?:[^']*?<%=.*?%>)+[^']*?)'/m
ERB_INTERPOLATION =

A single ERB output tag inside an attribute value. Capture group 1
is the trimmed expression, used both for the html-safe heuristic and
the autocorrect's ERB::Util.html_escape_once(...) wrap.

/<%=\s*(.+?)\s*%>/m
HTML_SAFE_HEAD =

Heuristic for "this expression returns html_safe / SafeBuffer".
Matches at the START of the expression so we don't false-positive on
helpers.fa_icon (already covered by RedundantHelpersFaIcon) or
arbitrary strings that happen to mention these names.

h / html_escape / ERB::Util.html_escape are intentionally
included: they preserve the SafeBuffer flag (no-op when input is
already html_safe) and therefore do not fix the attribute-quote-
collision bug. Wrapping h(fa_icon(...)) in
ERB::Util.html_escape_once(...) is the correct fix even though it
looks redundant.

h and tag are the short-name traps: h is the html_escape
alias and tag is the TagBuilder, but both are also plausible
local-variable names. Matching a bare h / tag (e.g. <%= h %>
for an Integer, <%= tag %> for a String) wrapped ordinary
auto-escaped values in a pointless — and, in a ViewComponent
template, crashing — call (AppSignal #4429). So they now only match
when actually invoked: h( as a call, tag.foo / tag( as the
builder. The longer names (raw, sanitize, …) are left as bare
\b matches because they are rarely variable names and are commonly
called without parens (raw user_input).

/\A(?:
  fa_icon
  | tag (?= \s* (?: \. \w | \( ) )
  | content_tag
  | link_to | button_to
  | [a-z_]\w*_tag
  | safe_join | safe_concat
  | sanitize | simple_format | highlight
  | raw
  | render
  | h (?= \s* \( ) | html_escape | ERB::Util\.html_escape
)\b/x
HTML_SAFE_TRAIL =

Trailing .html_safe anywhere — covers foo.html_safe,
bar.baz.html_safe, (stuff).html_safe.

/\.html_safe\b/
ALREADY_ESCAPED =

Already wrapped in html_escape_once(...) — skip. Both the bare
html_escape_once (valid in app/views/** ERB) and the qualified
ERB::Util.html_escape_once (what the autocorrect now emits, and the
only form that also works inside ViewComponent templates) count as
already escaped. Note that h(...) / ERB::Util.html_escape(...)
are not treated as "already escaped" — they're no-ops on SafeBuffer
and don't fix the bug, so they're flagged via HTML_SAFE_HEAD above.

/\A(?:ERB::Util\.)?html_escape_once\s*[(\s]/
SCRIPT_BLOCK =

Skip matches inside <script>…</script> blocks — those are JS
string-literal contents, not real HTML attributes.

%r{<script\b[^>]*>(.*?)</script>}im
JAVASCRIPT_TAG_BLOCK =

Also skip <%= javascript_tag do %> … <% end %> helper blocks. These
emit a <script> only at RENDER time — there is no literal <script>
in the ERB source, so SCRIPT_BLOCK misses them — but their bodies are
JS string literals just like a <script> block. Without this guard,
el.innerHTML = '<%= fa_icon(…) %>' (a JS assignment) matches the
attribute regex as if innerHTML were an HTML attribute, and the
autocorrect wraps it in html_escape_once, which HTML-escapes the SVG
so the icon renders as literal <svg> text (shipped as a bug once —
PR #1569). Non-greedy to the first <% end %>: javascript_tag blocks
in this codebase never nest an ERB block (verified 0/124), so the
first <% end %> always closes the block. do -%> trim variant and a
trimmed <%- end %> are both allowed.

/<%=?\s*javascript_tag\b.*?\bdo\s*-?%>(.*?)<%-?\s*end\s*-?%>/m

Instance Method Summary collapse

Instance Method Details

#on_new_investigationvoid

This method returns an undefined value.

Invoked by RuboCop when investigating a Ruby-parseable file.



135
136
137
# File 'lib/rubocop/cop/heatwave/safe_buffer_in_attribute.rb', line 135

def on_new_investigation
  scan_source
end

#on_other_filevoid

This method returns an undefined value.

Invoked by RuboCop when investigating a non-Ruby file (e.g. ERB).



141
142
143
# File 'lib/rubocop/cop/heatwave/safe_buffer_in_attribute.rb', line 141

def on_other_file
  scan_source
end