backend / security / 03_xss_csrf.md

XSS and CSRF

4 min read source

XSS and CSRF

Two of the oldest web vulnerabilities. Defenses are well-known but easy to bypass when frameworks are misused.

XSS — Cross-Site Scripting

Attacker injects script that runs in another user’s browser, in your site’s origin. From the browser’s POV, the script is yours: it can read cookies (unless HttpOnly), DOM, localStorage, and call any same-origin API.

Three flavors

Type Injection vector Example
Stored Script saved in DB, served to all users Comment field that renders raw HTML
Reflected Script in URL, reflected in response ?q=<script>...</script> echoed in search results page
DOM-based JS in the page reads URL/storage and writes DOM document.body.innerHTML = location.hash.slice(1)

The fundamental defense: contextual output encoding

Where the data ends up determines what to escape.

<!-- HTML body -->
<p>Hello {{ name }}</p>            <!-- escape: < > & " ' -->

<!-- HTML attribute -->
<input value="{{ name }}">          <!-- escape: < > & " ' -->

<!-- JavaScript -->
<script>const u = "{{ name }}"</script>   <!-- escape: < > & ' " \ ; (and never raw user input here — use json_script) -->

<!-- URL -->
<a href="/u/{{ name }}">           <!-- urlencode -->

Framework defaults

  • Jinja2 — autoescape ON for .html/.htm/.xml templates by default. {{ var }} HTML-escapes. {{ var | safe }} opts out — never on user data.
  • Django templates — autoescape ON. {% autoescape off %} opts out.
  • React JSX{value} escapes. dangerouslySetInnerHTML={{__html: ...}} opts out (the name is a warning — don’t pass user data).
  • FastAPI + Jinja — same as Jinja above.
# Django: passing data to inline JS — use json_script
{% load json_script %}
{{ user_data | json_script:"data" }}
<script>const data = JSON.parse(document.getElementById("data").textContent);</script>

Never embed Python strings directly into a <script> tag — context-encoding for JS is its own minefield.

Content Security Policy

A second line of defense — even if XSS slips through, CSP can prevent script execution.

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{{ nonce }}'; object-src 'none'; base-uri 'self'
  • script-src 'self' — scripts only from same origin.
  • 'nonce-{{ random }}' — inline scripts allowed only if <script nonce="..."> matches.
  • 'unsafe-inline' — disable CSP’s main XSS protection. Avoid.
  • Report-only mode: Content-Security-Policy-Report-Only plus report-uri to canary new policies.

In FastAPI:

@app.middleware("http")
async def csp(request, call_next):
    resp = await call_next(request)
    resp.headers["Content-Security-Policy"] = (
        "default-src 'self'; "
        "script-src 'self'; "
        "object-src 'none'; "
        "base-uri 'self'; "
        "frame-ancestors 'none'"
    )
    return resp

HttpOnly + Secure cookies

response.set_cookie(
    "session", token,
    httponly=True,    # JS can't read — XSS can't steal session
    secure=True,      # HTTPS only
    samesite="lax",   # CSRF defense (see below)
)

HttpOnly doesn’t prevent XSS but limits damage — XSS can still call APIs as the user but can’t exfiltrate the session token directly.

Sanitization (when raw HTML is required)

If you must accept HTML (e.g., user-formatted comments), allowlist tags/attributes — don’t blocklist.

import bleach
clean = bleach.clean(
    user_html,
    tags={"p", "b", "i", "a", "ul", "li", "code"},
    attributes={"a": ["href", "title"]},
    protocols={"http", "https", "mailto"},
)

html-sanitizer is another option; both wrap a parsed HTML tree.

CSRF — Cross-Site Request Forgery

Attacker tricks the user’s browser into making a request to your site, riding the user’s existing session cookie.

<!-- attacker's site -->
<form action="https://yourbank.com/transfer" method="POST">
    <input name="to" value="attacker">
    <input name="amount" value="1000000">
</form>
<script>document.forms[0].submit()</script>

If the user is logged into yourbank.com, the cookie ships with the request, and the transfer happens.

Defenses

SameSite cookies — modern default protection.

response.set_cookie("session", token, samesite="lax")
# "lax" — sent on top-level GET nav, not on cross-site POST
# "strict" — never sent cross-site (breaks login flows starting from external links)
# "none" — sent always; requires Secure

SameSite=Lax is the default in modern Chrome / Firefox / Edge. Most CSRF attacks die here. But:

  • Older browsers don’t enforce it.
  • Login flows that need cross-site requests (OAuth callbacks) need none + Secure.

CSRF tokens — for defense-in-depth or when SameSite isn’t enough:

<form method="POST">
    <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
    ...
</form>

Server generates a per-session (or per-form) random token, embeds in the page, and validates on POST. Attacker’s cross-site request can’t read your origin’s pages, so can’t get the token.

  • Django: built-in via CsrfViewMiddleware and {% csrf_token %}.
  • Flask: flask-wtf for CSRFProtect.
  • FastAPI: no built-in; use fastapi-csrf-protect or wire your own.

Double-submit cookie — token in cookie + same token as hidden form field. Server compares. Slightly weaker than per-session tokens but stateless.

When CSRF doesn’t apply

Token-authenticated APIs (Authorization: Bearer <jwt> header) are CSRF-immune by construction — browsers don’t auto-attach Authorization headers cross-origin (unlike cookies). If your API is fully token-based, no CSRF tokens needed.

The catch: if you accept both cookie auth and bearer auth on the same endpoint, you still need CSRF tokens for the cookie path.

CSRF + JSON

JSON POSTs aren’t application/x-www-form-urlencoded, so cross-site <form> submissions can’t generate them by default. But fetch from a malicious origin can — modern apps need CSRF protection on JSON endpoints too unless they’re bearer-auth’d.

Browser’s CORS preflight provides some protection: a JSON Content-Type: application/json triggers preflight, and unless your CORS policy allows the attacker origin, the request never goes through. Don’t rely on this alone.

Interview angle

  • Q: “What’s XSS and how do you prevent it?” — script in your origin via injected content; output encoding contextually, CSP, HttpOnly cookies.
  • Q: “Difference between stored, reflected, and DOM XSS?” — where the script comes from / is rendered.
  • Follow-up: “What’s CSRF and what defenses are most effective?” — riding the user’s session cookie; SameSite cookies (default), CSRF tokens (defense-in-depth).
  • Follow-up: “Are token-auth APIs CSRF-vulnerable?” — no, browsers don’t auto-attach Authorization headers cross-origin.

See 01_owasp_top_10.md, 11_authentication/.