A self-hosted, hands-on Cross-Site Scripting practice academy — 72 labs, beginner through pro. All 72 are built and live.
unzip xss-bugs-lab
cd xss-bugs-lab
npm install
npm run dev
Then open http://localhost:3000 — it redirects straight to the dashboard.
npm run dev starts two things: the main app on port 3000 (unchanged), and a small zero-dependency static server on port 3001 used only by the postMessage labs (PM1-PM3) and a couple of CSP/clobbering labs to provide a genuinely different origin for realistic cross-origin testing — see scripts/dev.js. Nothing else changes; it's still one command, no Docker, no external database, no signup. Progress is stored in a local SQLite file at data/xss-lab.sqlite, created automatically on first run.
All 9 categories, 72 labs total:
- Reflected XSS (8) — query params, path params, POST bodies, attribute breakout, error pages, redirect params, header reflection, mixed encoding
- Stored XSS (8) — comments, profile bio, username, forum replies, support tickets (admin-only view), product reviews, file names, private messages
- XSS Contexts (8) — HTML body, quoted attribute, unquoted attribute, event handler, inline JS string, URL/href, CSS, JSON-in-
<script> - DOM XSS (10) — hash, query string, in-memory client state, localStorage, sessionStorage, referrer, live template rendering, unsafe navigation, multi-step transform, insertAdjacentHTML
- Filtering & Sanitization (10) — case-variation bypass, incomplete tag/attribute blacklists, double-decode bypass, client-only sanitization, create-vs-update inconsistency, allowlist misconfiguration, parser mismatch, single-pass filter recombination, context-mismatched sanitization
- Modern Framework XSS + postMessage (8) — real live React components, a real live Vue 3 app, a faithfully-reproduced Angular pattern, client-side template injection, three postMessage labs with a genuinely separate attacker origin
- CSP & Trusted Types (8) — unsafe-inline, unsafe-eval, a whitelisted-domain JSONP bypass, nonce reuse, a missing base-uri base-tag hijack, a weak Trusted Types policy, a misconfigured default policy, a real mutation-XSS (mXSS) sanitizer bug
- DOM Clobbering & Prototype Pollution (6) — implicit-global clobbering, getElementById clobbering, pollution via URL parameters, pollution chained into an unrelated gadget, pollution via a shared library file, a combined clobbering+pollution chain
- Black-Box / Real-World (6) — realistic apps with no vulnerability type disclosed; you find it the way you would on a real engagement
- Open the lab from the dashboard → its category → the lab itself.
- The right panel has the description, objective, hints (up to 3), and a flag box.
- The left panel embeds the actual vulnerable target app (also viewable in its own tab).
- Find the injection point and get your payload to call
window.__labImpact('<LAB_ID>')— every vulnerable page exposes this function. It's not a fixed payload string to memorize; any technique that gets your JavaScript to run and call that function works. - Once it fires, the target page shows a banner with your flag. Copy it into the flag box to complete the lab and earn XP.
- "Reset lab" clears your progress and any stored content for that lab so you can retry from scratch.
For the six Black-Box labs specifically, the vulnerability type is intentionally not named anywhere in the lab description — that's the point of that category.
I built this in 8 phases (Reflected → Stored → Contexts → DOM → Filtering → Framework/postMessage → CSP/Trusted Types → Clobbering/Pollution → Black-Box), testing as I went. Where I could verify something with curl (every server-rendered lab: all of Reflected, Stored, Contexts, Filtering, most of Framework, CSP header values, and all the injection-point plumbing), I actually did — hand-crafting the exact exploit payload and confirming the real server-side code produces the expected unescaped output, not just eyeballing the code.
Where I couldn't verify with curl — anything that only manifests through real browser JavaScript execution — I've flagged clearly below, roughly in order of how much browser-specific subtlety is involved (least to most):
- DOM XSS (D1-D10) and the DOM-XSS-flavored Black-Box labs (BB3, and BB6's reflected half) are pure client-side JS (innerHTML/document.write/insertAdjacentHTML assignments). I hand-traced the source-to-sink logic and it's standard, well-established DOM API behavior — I'm confident in it, but it's untested by an actual browser on my end.
- Prototype Pollution (PP1-3) is pure JavaScript prototype-chain mechanics, no browser-parsing involved. I hand-traced each one's exact property lookup/assignment sequence line by line to confirm
Object.prototypegenuinely gets polluted app-wide (not just on a local copy) — this is where I'm most confident of anything non-curl-testable in the project. It also caught a real mistake: my first draft of PP3 used a "clone" function that, on close tracing, would not have achieved true global pollution — only a merge-into-existing-target pattern does, which is what's actually implemented. - DOM Clobbering (DC1-3) relies on browser-specific named-property-exposure behavior. I verified the exact technique (anchor-collection +
nameattribute) against current PortSwigger Research documentation rather than from memory, but couldn't confirm live execution myself. - CSP & Trusted Types (CSP1-5, TT1-2, MX1) is the most browser-subtle category here. CSP enforcement, Trusted Types enforcement, and MX1's mutation-XSS parsing quirk all require a real browser to observe. I specifically web-searched to verify the CSP5 base-tag bypass and the MX1
noscript/titlemXSS technique against real documented sources (including PortSwigger's own lab writeups) rather than relying on memory. This category is worth your own hands-on verification first, more than any other. - React/Vue specifics (FW1-FW5): FW1/FW2 are real React components (this whole app is Next.js/React) and FW3 is a real, live Vue 3 app using Vue's own official "global build," vendored locally with no CDN dependency. FW4 (Angular) is not a live Angular app — Angular's modern tooling needs a real bundler, which didn't fit this project's no-build-step approach for one lab, so it's a faithful vanilla-JS reproduction of the exact
bypassSecurityTrustHtmlvulnerability pattern instead, clearly labeled as such in the lab. Worth calling out: while building FW2 I initially assumed React doesn't validate URL schemes onhref— testing it directly showed that's wrong for the React version here (19.x blocksjavascript:hrefs by default now). Caught by testing, not by trusting the assumption; rebuilt FW2 around a different, still-real React gotcha (a ref-based DOM write that bypasses JSX escaping).
A few infrastructure decisions worth knowing about if you extend this:
- Uses Node's built-in
node:sqliteinstead ofbetter-sqlite3— no native compilation step, sonpm installstays simple everywhere, including offline/locked-down machines.node:sqlitereturns rows as null-prototype objects, which React Server Components refuse to pass to Client Components —src/lib/db.tsspreads every row into a plain object (toPlain()) before it can reach a component. This one actually broke the dashboard once real progress data existed, in an earlier build pass — caught it, fixed it at the source, and re-verified every page with a full dataset afterward. - Added
PRAGMA busy_timeoutafter a genuine stress test surfaced real "database is locked" errors under concurrent requests (the lab page polls its own progress every 2s, which can overlap with hint/flag/reset actions) — SQLite now retries for up to 5s instead of failing immediately. - PM1-PM3 (and CSP3/CSP5/DC1/DC2/DC3) use a real second origin (
http://localhost:3001) rather than simulating cross-origin behavior same-origin, specifically so origin-check bypasses are genuinely demonstrable, not just theoretical. - Uses system font stacks rather than
next/font/google, since Google Fonts requires a network fetch at build time that would break "works offline after npm install." - Single local learner account, no login system — progress tracked via an auto-created session cookie.