DEV Community

Cover image for Brakeman missed the XSS. Here is the Semgrep rule that catches it.
Ivan Yatsenko
Ivan Yatsenko

Posted on

Brakeman missed the XSS. Here is the Semgrep rule that catches it.

Second post in a series on security bugs that live where server-side and client-side templates meet. The first post showed a reflected XSS that fired even though Rails escaped the input: a server value landed inside a Vue attribute binding, and Vue ran it as JavaScript. This post is the follow-up people asked for. If Brakeman does not see this class, what does? Here is the rule I wrote, and how to run it on a real codebase.


The gap in one paragraph

Rails static analysis reads Ruby. To Brakeman, <%= @utm_source %> is
escaped output. Safe. Done.

But the value does not stop there. It lands inside a Vue binding:

<widget :utm-source="'<%= @utm_source %>'" />
Enter fullscreen mode Exit fullscreen mode

The colon in front of the attribute changes everything. Vue does not
treat :utm-source as text. It treats it as a JavaScript expression and
runs it. The escaping Rails did was for an HTML context. The value ended
up in a JS context. Wrong armor for the wrong fight.

Brakeman cannot see this, and it is not Brakeman's fault. The bug is not
in the Ruby. It is in what happens after the Ruby hands the string to the
browser, and then to Vue. A Ruby analyzer does not model that step.

So you need a tool that reads the view as text and knows this shape is
dangerous. That tool is Semgrep.

Why Semgrep and not a regex in CI

You can catch the obvious case with grep. You will also catch a hundred
false positives and miss the multi-line ones. Semgrep sits between a dumb
regex and a full parser. It reads the file, it understands patterns, and
it runs the same way on every machine and in CI. For a bug class you want
to block on every pull request, that is what you want.

And the important part for a legacy monolith: Semgrep does not need your
app to build, boot, or have a working test database. It reads files. You
can run it on a ten-year-old codebase that only boots on one person's
laptop.

The rule

Here is the whole thing. It is short on purpose.

rules:
  - id: vue-binding-server-string-xss
    languages: [generic]
    severity: ERROR
    message: >
      Server-rendered value is placed inside a Vue attribute binding
      (colon prefix). Vue evaluates the binding as a JavaScript expression,
      so Rails HTML-escaping does not protect this context. This is
      Client-Side Template Injection (CSTI). Pass the value as a static
      attribute or a JSON prop instead.
    patterns:
      - pattern-regex: ':[a-zA-Z-]+="''<%=.*%>''"'
Enter fullscreen mode Exit fullscreen mode

Three things worth explaining, because they are the difference between a
rule that helps and a rule people mute.

languages: [generic]. An .html.erb file is not one language. It is
ERB mixed into HTML. Semgrep's Ruby mode would parse the Ruby and lose
the HTML around it, which is exactly the part that matters here. Generic
mode reads the file as text and lets you match the shape across both.

The pattern matches the dangerous shape, not the value. It looks for a
colon-prefixed attribute, then ="', then an ERB output tag, then '".
That is the exact antipattern: a server tag wrapped in single quotes
inside a Vue binding. It does not care what the variable is called.
@utm_source, @landing.sf_source, anything. If it sits in that shape,
it is a finding.

severity: ERROR. This is deliberate. ERROR gives Semgrep a non-zero
exit code, which fails the CI job. A warning that does not fail the build
is a warning everyone scrolls past. If you mean do not merge this, say
ERROR.

Proving it catches the real thing and skips the safe thing

A rule you do not test is a rule you do not trust. So here are two files.

The vulnerable view, the shape from the first post:

<pages-career-vacancies-id :utm-source="'<%= @utm_source %>'" />
<other-widget :sf-source="'<%= @landing.sf_source %>'" />
Enter fullscreen mode Exit fullscreen mode

The safe view. Same value, but passed correctly. One as a JSON prop, one
as a plain static attribute with no colon:

<pages-career :utm-source="<%= @utm_source.to_json %>" />
<form-widget utm-source="<%= @utm_source %>" />
Enter fullscreen mode Exit fullscreen mode

Run it:

semgrep --config rules.yml app/views/
Enter fullscreen mode Exit fullscreen mode

Result:

2 Code Findings

    app/views/index.html.erb
      vue-binding-server-string-xss
         1| <pages-career-vacancies-id :utm-source="'<%= @utm_source %>'" />
         2| <other-widget :sf-source="'<%= @landing.sf_source %>'" />
Enter fullscreen mode Exit fullscreen mode

Two findings in the vulnerable file. Zero in the safe file. That is the
whole point of the safe file. It proves the rule is not just yelling at
every <%= near a Vue tag. It fires on the JS-expression context and
stays quiet on the correct ways to pass the same data.

When I ran this rule on the real codebase where I found the original bug,
it flagged eleven places of this class. The one reflected XSS I already
knew about, and ten more of the same shape I had not looked at yet. That
is the argument for a rule over a one-time manual find. You fix one, the
rule finds the other ten, and then it guards the eleventh that someone
writes next month.

Wiring it into CI

Drop the rule in .semgrep/rules.yml and add two scripts. One for the
full report, one that only fails on the blocking class:

{
  "scripts": {
    "check:sast": "semgrep --config .semgrep/rules.yml .",
    "check:sast:critical": "semgrep --config .semgrep/rules.yml --severity ERROR --error ."
  }
}
Enter fullscreen mode Exit fullscreen mode

--error is what makes CI stop. Without it Semgrep prints findings and
exits zero, and a green build with a critical finding in the log is worse
than no scan at all, because now people trust the green.

What this rule does not do

Same honesty as the tool from the first post. This is a text-shape rule,
not a taint analysis. It knows one antipattern well and nothing else.

It matches the single-quote-wrapped shape :attr="'<%= ... %>'". Change
the quoting and you need another pattern. A real config grows a small
family of these, one per shape you have actually seen.

It does not follow data flow. It cannot tell you whether
@landing.sf_source is user-controlled or a hardcoded constant. It flags
the shape and leaves the judgement to you. For this class that is the
right trade, because the shape itself is the smell, but do not mistake it
for taint tracking.

It is Vue-flavored. Angular's [prop] and [innerHTML] are the same
class with different syntax and need their own patterns.

Clean output does not mean the app is safe. It means this one shape is
absent. That is all a rule like this can ever promise.

The takeaway

Brakeman is good at the language it reads. This bug lives one layer past
that language, in the handoff from ERB to Vue, so it needs a tool that
reads the view as text and knows the dangerous shape. That tool is a
fifteen-line Semgrep rule you can write in an afternoon, test against a
safe-and-vulnerable pair, and run on a legacy monolith that does not even
boot on your machine.

The manual find fixes one bug. The rule fixes the ten you did not see and
guards the eleventh nobody has written yet. That is the difference
between finding a bug and closing a class.

Rule and the safe-vs-vulnerable test files are in the repo below. Take
the pattern, point it at your own app/views/, and see what it says.


If this shape exists in your codebase, the browser tool
from the first post flags it without any setup: paste a view, it marks the
bindings that carry server data. Next post: how 3-D Secure actually
assembles, and where it breaks.

Top comments (0)