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:
- Contextual output encoding — let the framework escape; reach for
textContent, notinnerHTML. - Sanitization when you must accept markup — DOMPurify (current major is v3) with a strict config, not a regex.
- Cookie blast-radius control —
HttpOnlyso a payload can't read the session cookie;SameSiteso it's not sent cross-site. - CSP as backstop — a
script-srcwithoutunsafe-inlineturns 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.