Skip to content

Lesson 2 of 9 · 25m

XSS in the Wild: Stored, Reflected, and DOM-Based

Why 'strip script tags' fails, how context decides the payload, and the encoding-and-CSP stack that actually stops XSS.

A forum lets users post comments, and one morning every profile view starts firing requests to an off-site logger. Nobody clicked anything. A <img src=x onerror=…> sat in a comment for a week, executing in every visitor's session — that's stored cross-site scripting, and it's still among the most common findings in real assessments.

XSS is a context-confusion bug: attacker data gets interpreted as code because it was emitted into a document without encoding for the context it landed in. There are three delivery shapes. Reflected XSS bounces off the request (?q=<payload> rendered into results) and needs a clicked link. Stored XSS persists in your database and fires for every viewer — the dangerous one. DOM-based XSS never touches your templates; a client-side script writes location.hash into innerHTML and sinks the payload itself.

Contexts, not characters

The reason "strip <script>" never works is that the escaping rule depends on where the value lands:

<!-- HTML body context -->
<div>{{ user.bio }}</div>

<!-- Attribute context: payload breaks out with a quote -->
<input value="{{ name }}">   <!-- name: "><svg onload=alert(1)> -->

<!-- JS string context: HTML encoding is useless here -->
<script>const who = "{{ name }}";</script>  <!-- name: ";alert(1)// -->

HTML-entity encoding is correct inside HTML text and quoted attributes; it does nothing inside a <script> string or a URL context (href="javascript:…" survives entity encoding). Framework defaults help — React and Vue escape interpolations — but the escape hatches are where bugs live: dangerouslySetInnerHTML, v-html, href={userUrl} without scheme checks, and any Markdown renderer that passes inline HTML through.

The fix stack, in order:

  1. Contextual output encoding — let the framework escape; reach for textContent, not innerHTML.
  2. Sanitization when you must accept markup — DOMPurify (current major is v3) with a strict config, not a regex.
  3. Cookie blast-radius control — HttpOnly so a payload can't read the session cookie; SameSite so it's not sent cross-site.
  4. CSP as backstop — a script-src without unsafe-inline turns a surviving payload into a blocked one. It is a seatbelt, not the brake — lesson 8 covers doing it right.

Try this

Make a scratch HTML file with three sinks — a <div>, a quoted value= attribute, and a const x = "…" script string — and write a payload that pops alert(1) in each. If your attribute breakout (" onmouseover="alert(1)) and JS-string breakout (";alert(1)//) work but a <script> tag only works in the first, you understand contexts. Now render the same inputs through a real framework and watch which still fire.

Pitfalls

  • Blocklists lose. <script> removed? <scr<script>ipt> reassembles it after one strip pass.
  • Encoding the wrong context is worse than none — it looks defended.
  • Markdown isn't sanitizer output. Render it, then sanitize the HTML.
  • XSS isn't just cookie theft — a payload can act as the user: change email, mint API keys, post.