Skip to content

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 onerror handler (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:

  1. Contextual output encoding (primary). Render user data as text, so <img …> becomes the visible string &lt;img …&gt; instead of a DOM element. Frameworks do this by default — the bug is opting out.
  2. 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> and onerror= 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-dompurify for 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:html reappears later. Add a security-headers middleware. The app already has src/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 inline onerror handler on a broken screenshot embed. Without 'unsafe-inline' in script-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/*.js files, which script-src 'self' allows. If you later add genuinely inline scripts, prefer a per-request nonce (script-src 'nonce-…') over 'unsafe-inline'.

    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}=\)`; }

    `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:up
    
    }; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_MAX_AGE_MS / 1000)

    Test Before After (fixed)
    Post <code>203.0.113.44</code> in a shift note Renders monospace IOC Shows literal <code>…</code>
    Post broken screenshot embed with onerror overlay Fake 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 HTML

    The "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 -v style: 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, and eval are 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 / dangerouslySetInnerHTML on user-controlled data.
    • [ ] User content renders as visible text, not HTML.
    • [ ] Content-Security-Policy header present without 'unsafe-inline' in script-src.
    • [ ] Session cookie is HttpOnly + SameSite.
    • [ ] A fresh onerror payload does not execute after the fix.