DEV Community

Ivan Yatsenko
Ivan Yatsenko

Posted on

Rails escaped the input. The XSS fired anyway.

A while back I was building a plain UTM-tracking feature on a large Rails + Vue app. Nothing exotic: read a query param, keep it in the session, drop it into the page. Rails escaped the value on the way out, the way it always does.

I still got arbitrary JavaScript running in a victim's session from a single link. No console pasting, no "self-XSS" asterisk. Just a link.

The reason is a blind spot a lot of Rails + Vue teams share, so it's worth walking through.

The setup

The reflected value landed inside a Vue attribute binding:

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

Look at the colon. :utm-source is not a string attribute. In Vue, :attr means "evaluate this as a JavaScript expression." That one character is the whole story.

Why escaping didn't help

Rails HTML-escapes the value. A single quote becomes &#39;. In an HTML text or attribute context, that is correct and safe.

But here the sequence is:

  1. Rails emits &#39; into the HTML.
  2. The browser decodes HTML entities back to ' before Vue ever touches the attribute.
  3. Vue parses the decoded string as a JavaScript expression and runs it.

The escaping was done for the HTML context. The sink is a JavaScript context. Right tool, wrong place.

This is a known class: Client-Side Template Injection (CSTI). It shows up wherever server-side escaping meets a client framework that re-parses the server's output as its own template. Server escapes for one grammar, client executes in another.

The part that fooled me

Naive payloads did nothing. '+alert(1)+' produced silence. That almost made me close the ticket as a false positive.

The reason is Vue's expression sandbox. Vue evaluates the expression against the component's proxy scope, so bare identifiers like alert, window or document resolve as component properties and come back undefined. The sandbox quietly swallows them, which is exactly what makes this look safe when it isn't.

The escape from the sandbox is a textbook move: reach the real global scope through a property access instead of a bare identifier.

''.constructor.constructor('alert(1)')()
Enter fullscreen mode Exit fullscreen mode

''.constructor is String. .constructor on that is Function. Function('alert(1)')() runs the code in the real global context, where window, document and fetch live. It is property access on a literal, not a bare identifier, so the sandbox's allowlist never trips.

That trick is old. It comes from AngularJS CSTI research years ago, and it ports straight to Vue.

What it actually meant

Wrapped to fit the '<%= %>' template, the payload gives arbitrary JS in the victim's browser, in their session, delivered by a link that is indistinguishable from a normal marketing UTM link.

The session cookie was HttpOnly, so no direct cookie theft. People stop there and downgrade the severity. They shouldn't. Session-riding still works: a fetch with credentials included runs as the victim. Send that link to a logged-in admin and you are issuing requests with their privileges, reading admin-only endpoints, lifting the CSRF token straight out of the page.

Reflected, not stored, so it needs a click. But a UTM link is the most clickable, least suspicious link that exists.

Why the scanners missed it

Standard Rails SAST (Brakeman) sees <%= @utm_source %>, notes that it is escaped output, and moves on. It is right about the Ruby. It has no way to know a client framework will pick that value up and execute it. The whole class is invisible to a tool that only reasons about the server side.

To catch it you need one of two things: a static rule that flags server-rendered data landing inside a client-side template binding, or DAST that actually drives the DOM.

I wrote a small Semgrep rule for the pattern. One rule surfaced every instance of the class in the codebase, including a couple of spots nobody had flagged, because the bug isn't the specific variable, it's the shape.

The fix

Allowlist the input at the source. A UTM value is [A-Za-z0-9_.-]. Strip everything else, cap the length:

UTM_SOURCE_DISALLOWED = /[^\w.\-]/
UTM_SOURCE_MAX_LENGTH = 64

def sanitized_utm(value)
  value.to_s.gsub(UTM_SOURCE_DISALLOWED, '').first(UTM_SOURCE_MAX_LENGTH).presence
end
Enter fullscreen mode Exit fullscreen mode

Quotes, parens, backticks, template literals, all gone. There is nothing left to break out of the Vue string literal with.

But that only closes this one instance. The real fix is architectural: don't put server-rendered data into a : binding at all. Pass it as a static attribute or a JSON prop. The : binding for server data is the root cause; the sanitizer is a patch on top of it.

The takeaway

"It's escaped" is a statement about one context. The moment escaped output crosses into a second template engine, the guarantee is gone, and every framework combination has this seam.

If you run Rails and Vue in the same response (or React, or Angular, same story), go grep your views for server variables sitting inside client-side bindings. That boundary is where this entire bug class lives, and your Ruby scanner is not looking at it.


I built a small in-browser checker for this exact pattern. Paste a view, it flags the dangerous bindings, nothing leaves your machine: https://vanyaneytrino.github.io/csti-scan/

Source: https://github.com/VanyaNeytrino/csti-scan

Top comments (0)