Audit BenchAi
← All posts

How to Review Code for Cross-Site Scripting (XSS) Risk

·8 min read

Cross-site scripting is one of the oldest vulnerability classes on the web, and modern frameworks have made it much rarer by escaping output automatically. React escapes strings rendered in JSX, Vue escapes template interpolation, and most server-side template engines escape by default. That success is exactly why the XSS that does show up today is concentrated in a small number of predictable places: the specific spots where a developer deliberately turned that automatic protection off.

Where auto-escaping gets turned off

  • React's dangerouslySetInnerHTML, used to render rich text, markdown output, or content from a CMS.
  • Vue's v-html directive, and Angular's [innerHTML] binding orbypassSecurityTrustHtml calls.
  • Server-side template filters that explicitly opt out of escaping — |safe in Jinja,|raw in Twig, or similar "trust this string" filters in other engines.
  • Any of these used on content that ultimately traces back to another user — a comment, a display name, a file name, a support ticket description.

None of these are wrong to use on their own. They are wrong to use on a string that could contain attacker-controlled markup without first passing it through a dedicated HTML sanitizer built for that purpose, not a hand-rolled regex.

The three flavors, and why the fix differs

Stored XSS happens when malicious input is saved — in a database, a file, a cache — and later rendered unescaped to other users; it is the most damaging variant because one attacker can affect every visitor. Reflected XSS happens when input from the current request (a query parameter, a search term) is echoed back into the page without escaping, requiring a victim to click a crafted link. DOM-based XSS happens entirely in the browser, when client-side JavaScript reads something attacker-influenced and writes it into the page without ever touching the server. The fix is the same in principle — escape or sanitize based on where the value lands — but where you have to look for the bug is different for each one.

DOM-based XSS hides from a server-side review

A code reviewer scanning backend templates and API responses can completely miss DOM-based XSS, because the vulnerable code never touches the server at all. Client-side JavaScript that reads location.hash, location.search, or document.referrer and writes the result into innerHTML, document.write, or an eval call is a complete, self-contained vulnerability that a backend-focused review will walk right past. Front-end code needs the same scrutiny as backend templates, not less.

Why input validation alone doesn't solve this

The correct way to neutralize a value depends entirely on where it ends up — inside an HTML element body, inside an HTML attribute, inside a <script>block, or inside a URL each require different escaping rules. Validating that an input "looks like a normal name" on the way in does nothing to protect the several different contexts it might later be rendered into. This is why framework-provided, context-aware escaping consistently beats a single custom sanitization function applied once at the input boundary.

Sinks worth flagging on sight

  • innerHTML, outerHTML, and insertAdjacentHTML assigned from anything other than a fixed, developer-written string.
  • document.write or eval used on any value that traces back to user input.
  • style.cssText or attribute values built from user input, which can enable CSS-based exfiltration in older browsers.
  • An href or src attribute built from user input without checking the scheme, allowing a javascript: URL to execute on click.

Content Security Policy is a backstop, not a substitute

A well-configured CSP can prevent an injected script from executing even if an XSS bug slips through review, and it is worth having for exactly that reason. But it should never be treated as the primary control — CSP has known bypass techniques, gets weakened by legitimate needs like inline scripts or third-party widgets, and does nothing to prevent the underlying bug from existing. Review the code as if no CSP exists, then treat CSP as a second layer on top.

Checklist for reviewing a diff that touches rendering

  1. Every use of dangerouslySetInnerHTML, v-html, or an unescaped template filter is paired with a real HTML sanitizer, not a hand-written filter.
  2. Front-end code that reads from the URL, referrer, or postMessage and writes to the DOM gets the same scrutiny as server-rendered output.
  3. User-influenced values used in attributes, inline styles, or URLs are escaped for that specific context, not just HTML-body-escaped by default.
  4. Links built from user input validate the URL scheme before rendering as a clickable href.

Read the OWASP Top 10:2025, explained →

See how to review code for SQL injection risk →