When Text Becomes Code#

Cross-Site Scripting (XSS) is what happens when an application puts untrusted data into a web page in a place where the browser treats it as executable code rather than as text. The browser cannot tell that the script was injected; because it arrived from your origin, it runs with your origin's full privileges. This is a member of the injection family in the OWASP Top 10, and understanding it is mostly about understanding where data lands in a page.

Why it matters: a script running on your origin can read anything in the page's DOM, call your APIs carrying the victim's session, submit forms on their behalf, and read or write client-side storage. That covers session hijacking, silent account changes, defacement, and keylogging. The point of learning it is to recognise the vulnerable patterns in your own code and remove them.

The Three Types#

1. Stored (persistent). The malicious input is saved server-side (a comment, a bio, a support ticket) and later served to every visitor of that page. Most dangerous, because a victim only has to view an ordinary page. A common real-world instance: user-supplied content that renders unescaped inside an internal admin dashboard, so the highest-privilege staff are the ones who trigger it.

2. Reflected (non-persistent). The input travels in the request (typically a query parameter) and the server echoes it straight back into the HTML of the response. The attacker has to lure the victim into opening a crafted link, so it is often paired with phishing (social engineering).

3. DOM-based. The server never sees the payload. Client-side JavaScript reads from a source it does not control (the URL fragment, postMessage, localStorage) and writes it into a dangerous sink such as innerHTML or eval. The whole vulnerability lives in the browser.

A Vulnerable Handler and Its Fix#

The root pattern is building HTML by string concatenation with user data:

from flask import Flask, request
from markupsafe import escape

app = Flask(__name__)

@app.route("/search")
def search():
    q = request.args.get("q", "")
    return f"<h1>Results for {q}</h1>"          # VULNERABLE: raw text into HTML

@app.route("/search-safe")
def search_safe():
    q = request.args.get("q", "")
    return f"<h1>Results for {escape(q)}</h1>"  # < > & " ' become HTML entities

In the vulnerable version, a q value containing angle-bracket markup is parsed by the browser as a new element instead of shown as text. escape() converts the five HTML-significant characters (<, >, &, ", ') into their entity equivalents (&lt;, and so on), so the browser renders them as literal characters. You can confirm what escaping produces:

from markupsafe import escape
print(escape('Alice & Bob <dev> "quoted"'))
# Alice &amp; Bob &lt;dev&gt; &#34;quoted&#34;

In practice you rarely call escape by hand. Template engines do it for you: Jinja2 (Flask's default), Django templates, and the JSX in React, Vue and Angular all autoescape by default. The bugs cluster where developers step around that default: Jinja's | safe filter, Django's mark_safe, React's dangerouslySetInnerHTML, and vanilla element.innerHTML = userData. Prefer element.textContent = userData, which never parses HTML.

Escaping Is Context-Sensitive#

The single most important nuance is that "escaping" is not one operation. The rules differ depending on where in the document the data lands:

Context Example position Correct handling
HTML body <div>HERE</div> HTML-entity encode
HTML attribute <input value="HERE"> Attribute-encode; always quote the attribute
URL in href/src <a href="HERE"> URL-encode and allowlist the scheme (block javascript:)
Inside a <script> var x = "HERE"; JavaScript string escaping, not HTML
Inside CSS style="width: HERE" CSS escaping

Data that is perfectly safe in one context is dangerous in another. An unquoted attribute is the classic trap: without quotes, a value can end the attribute and add a new event-handler attribute without ever using an angle bracket. So: quote every attribute, and never place untrusted data directly inside a <script> block or an inline event handler. If JavaScript needs server data, hand it over as JSON in a data attribute and read it with JSON.parse(el.dataset.x).

Validation Is Not Escaping#

Teams often assume input validation removes the need to escape. It does not. Validation rejects malformed input; XSS payloads are frequently well-formed text. O'Brien & Sons <Ltd> is a legitimate company name. The danger appears only when that value is rendered into HTML without context-appropriate encoding. So keep the two jobs separate: validate and normalise on input (length, type, allowed characters), and encode on output based on the destination context. Output encoding is the primary defence; input validation is a helpful secondary filter.

Tracing DOM-Based XSS#

For client-side XSS, the review technique is to trace sources to sinks. Sources are values an attacker can influence:

  • location.href, location.search, location.hash, document.URL
  • document.referrer
  • window.name, postMessage data
  • values read back from localStorage / sessionStorage

Sinks are functions that turn strings into markup or code:

  • element.innerHTML, outerHTML, insertAdjacentHTML
  • document.write
  • eval, Function, setTimeout/setInterval with a string argument
  • assigning to location or an element's src/href with a javascript: value

Any path from a source to a sink that skips encoding or sanitisation is a DOM-XSS. Linters catch many of these: ESLint's eslint-plugin-no-unsanitized flags dangerous DOM writes, and Semgrep rules cover both server and client patterns.

When You Must Allow HTML#

Some features genuinely need to accept rich text (a comment editor, a markdown field). You cannot escape it, because you want the markup to render. The answer is sanitisation: parse the HTML and drop anything not on an allowlist of safe tags and attributes. In the browser, use a maintained library like DOMPurify; on a Python backend, nh3 (Rust ammonia bindings) or bleach in maintenance mode:

import nh3

dirty = '<b>hi</b><img src=x onerror="steal()"><a href="javascript:go()">x</a>'
clean = nh3.clean(dirty)
print(clean)
# <b>hi</b><img src="x"><a rel="noopener noreferrer">x</a>

The library keeps the bold and the image element but strips the event handler and neutralises the javascript: link. Never write your own tag filter with regular expressions; HTML parsing has too many edge cases (comments, malformed tags, encoded characters) and blacklists always miss one.

Content Security Policy: The Safety Net#

Even with disciplined encoding, a single missed spot can be enough. A Content Security Policy (CSP) is an HTTP response header that tells the browser which script sources to trust, turning "a bug that runs a script" into "a bug that gets blocked":

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m'; object-src 'none'; base-uri 'self'

A strong CSP disables inline scripts and inline event handlers, so injected markup that tries to run code is refused by the browser even though it reached the DOM. The modern approach is a nonce or hash based policy: the server emits a fresh random nonce per response and only <script nonce="..."> tags matching it execute. Avoid unsafe-inline and unsafe-eval, which defeat the purpose. The security headers lesson covers CSP construction in detail.

Reducing the Damage#

Two cookie flags limit what a successful XSS can steal:

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax

HttpOnly hides the cookie from JavaScript, so an injected script cannot read the session token from document.cookie. It does not stop the script from making authenticated requests (the browser still attaches the cookie), so it is mitigation, not a cure. SameSite limits cross-site sending, which overlaps with CSRF defences.

Testing Your Own App#

Practise on deliberately vulnerable targets you are authorised to attack: OWASP Juice Shop, DVWA, and PortSwigger's Web Security Academy XSS labs. A workable review checklist for your own code:

  • For every place data leaves the server into a response, ask: what context is it, and is it encoded for that context?
  • Grep for innerHTML, dangerouslySetInnerHTML, mark_safe, | safe, document.write, and eval.
  • Confirm autoescaping is on in your template engine and note every place it is bypassed.
  • Confirm session cookies carry HttpOnly and a CSP header is present without unsafe-inline.
  • Test rich-text fields by submitting content with event-handler attributes and confirming the sanitiser strips them.