XSS Defense — Output Encoding + Content-Security-Policy
Defends against¶
Cross-Site Scripting (XSS) — stored XSS in the Shift collaboration thread on the Tools page (/tools).
Prerequisites¶
- Local INSECURE-threat-intel-app stack (Docker setup).
- You can trigger the "Found Cross-Site Scripting" flag with a broken screenshot embed using an
onerrorhandler (see the attack guide). - Editor open on
exercise-files/web-attacks-101/INSECURE-threat-intel-app.
Principle¶
XSS happens when attacker-controlled data is rendered as active content (HTML/JS) in another user's browser. The fix has two independent layers:
- Contextual output encoding (primary). Render user data as text, so
<img …>becomes the visible string<img …>instead of a DOM element. Frameworks do this by default — the bug is opting out. - Content-Security-Policy (defense in depth). A response header that tells the browser which script sources are allowed. Even if an encoding bug slips through, a good CSP blocks inline
<script>andonerror=handlers from executing.
Encode first. CSP is the seatbelt, not the brakes.
Step 1 — Locate the vulnerable sink¶
The comment body is rendered with Astro's set:html, which injects raw, unescaped HTML — the equivalent of React's dangerouslySetInnerHTML:
```123:131:exercise-files/web-attacks-101/INSECURE-threat-intel-app/src/pages/tools.astro
-
{
comments.slice(0, 8).map((c) => (
-
{c.author} · {c.created_at}
Note the storage side (`src/lib/lab/xss.ts`) is already parameterized — it safely *stores* the raw text. The vulnerability is purely at **output**. Storing raw and encoding on render is the correct model (store-what-they-typed, render-safely). ## Step 2 — Encode on output (primary fix) Replace `set:html={c.body}` with a normal expression. In Astro (and React/JSX), `{c.body}` is **auto-escaped** — the value is inserted as text: ```diff - <div class="mt-1 text-sm text-zinc-200" set:html={c.body} /> + <div class="mt-1 text-sm text-zinc-200">{c.body}</div>That single change neutralizes the stored payload: the browser now displays the literal characters
<img src=x onerror=...>instead of building an<img>element.If you genuinely need rich text
Some features (markdown comments, WYSIWYG) must emit HTML. In that case do not hand-roll escaping — run the HTML through a vetted sanitizer such as DOMPurify (
isomorphic-dompurifyfor SSR) with a strict allowlist of tags/attributes, and render the sanitized output. Never sanitize with regex.Step 3 — Add a Content-Security-Policy (defense in depth)¶
A response-wide CSP stops inline script from executing even if another
set:htmlreappears later. Add a security-headers middleware. The app already hassrc/middleware.ts; extend it to set headers on HTML responses:import { defineMiddleware } from "astro:middleware"; import { getSessionFromRequest, isPublicPath } from "./lib/auth"; const CSP = [ "default-src 'self'", "script-src 'self'", // no 'unsafe-inline' → inline & onerror handlers blocked "style-src 'self' 'unsafe-inline'", // Tailwind utility classes are fine; inline styles only "img-src 'self' data:", "connect-src 'self'", "frame-ancestors 'none'", // clickjacking protection "base-uri 'self'", "form-action 'self'", "object-src 'none'", ].join("; "); export const onRequest = defineMiddleware(async (context, next) => { const { pathname } = context.url; if (!isPublicPath(pathname)) { const session = getSessionFromRequest(context.request); if (!session) { if (pathname.startsWith("/api/")) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" }, }); } return context.redirect("/login"); } context.locals.username = session.username; } const response = await next(); if (response.headers.get("content-type")?.includes("text/html")) { response.headers.set("Content-Security-Policy", CSP); response.headers.set("X-Content-Type-Options", "nosniff"); response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); response.headers.set("X-Frame-Options", "DENY"); } return response; });Why
script-src 'self'matters: the lab payload relies on an inlineonerrorhandler on a broken screenshot embed. Without'unsafe-inline'inscript-src, the browser refuses to run it and logs a CSP violation in the console — a second, independent line of defense.Astro client islands
This app uses React islands (
client:only). Astro emits them as external_astro/*.jsfiles, whichscript-src 'self'allows. If you later add genuinely inline scripts, prefer a per-request nonce (script-src 'nonce-…') over'unsafe-inline'.Step 4 — Keep the session cookie
HttpOnly¶Already done in this app, but it is part of the XSS defense story — it limits what a successful XSS can steal:
``83:85:exercise-files/web-attacks-101/INSECURE-threat-intel-app/src/lib/auth.ts export function sessionCookieHeader(token: string): string { return\({SESSION_COOKIE}=\)`; }}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_MAX_AGE_MS / 1000)`HttpOnly` keeps `document.cookie` from reading the session, so script injection cannot directly exfiltrate the cookie. (It can still act *as* the user via same-origin requests — which is why encoding + CSP remain essential.) ## Step 5 — Verify the fix ```bash npm run docker:down && npm run docker:upTest Before After (fixed) Post <code>203.0.113.44</code>in a shift noteRenders monospace IOC Shows literal <code>…</code>Post broken screenshot embed with onerroroverlayFake Session expired modal No overlay; markup shown literally Browser devtools console — CSP blocks any inline handler that slips through Response headers No CSP Content-Security-Policy: …present on HTMLThe "Found Cross-Site Scripting" flag (
detectXssPayload) keys off stored payload text, so it may still recognize older rows in the database — but the payload no longer executes. Post a fresh payload after the fix and confirm it renders inert. Reset the DB volume (npm run docker:down -vstyle:docker compose -f docker/docker-compose.yml down -v) to clear historical payloads if you want a clean flag state.
Defense in depth¶
- Encode for the right context. HTML body, HTML attribute, JS, URL, and CSS each need different encoding. Use the framework's contextual escaping; avoid manual concatenation into any of them.
- Avoid dangerous sinks.
set:html,dangerouslySetInnerHTML,innerHTML,document.write, andevalare the usual suspects. Grep for them in review. - Sanitize, don't filter. If HTML is required, allowlist with DOMPurify; blocklists (
<script>only) are trivially bypassed (<img onerror>,<svg onload>). - Strict CSP with nonces/hashes,
object-src 'none',base-uri 'self'. - Trusted Types (Chromium) to lock down DOM XSS sinks for richer apps.
Verification checklist¶
- [ ] No
set:html/dangerouslySetInnerHTMLon user-controlled data. - [ ] User content renders as visible text, not HTML.
- [ ]
Content-Security-Policyheader present without'unsafe-inline'inscript-src. - [ ] Session cookie is
HttpOnly+SameSite. - [ ] A fresh
onerrorpayload does not execute after the fix.
Related¶