XSSMaze is an intentionally vulnerable web application for measuring and improving XSS detection in security testing tools. It serves 1000+ endpoints across 170+ categories — reflected, DOM, stored, and header/path/body injection, plus filter and WAF bypasses, CSP gadgets, prototype pollution, template injection, and modern DOM sink/source shapes that defeat naive taint analysis.
Every endpoint ships structured metadata, so a benchmark can score per vulnerability class instead of regex-guessing at the served HTML.

[!WARNING] This app is deliberately vulnerable. It binds to
127.0.0.1by default — only pass-b 0.0.0.0on a network you trust.
Install
docker run -p 3000:3000 ghcr.io/hahwul/xssmaze:main
Or from source:
shards install && shards build
./bin/xssmaze
Usage
./bin/xssmaze [options]
-b, --bind HOST address to bind 127.0.0.1
-p, --port PORT port to listen on 3000
-s, --ssl serve over HTTPS
--ssl-key-file FILE private key, PEM encoded
--ssl-cert-file FILE certificate, PEM encoded
-q, --quiet do not log requests
--no-banner start without the banner
--no-color disable ANSI colour
-v, --version print the version and exit
-h, --help print this help and exit
Colour is dropped automatically when the output is not a terminal, and
NO_COLOR is honoured.
Endpoint map
| Endpoint | Returns |
|----------|---------|
| /map/text | newline-separated URLs |
| /map/json | full metadata; filter with ?type=, ?q=, ?vuln=, ?reach=, ?exploitable= |
| /map/markdown | markdown table |
| /map/categories | categories with counts + class/reach rollups |
| /map/openapi | OpenAPI 3.0 catalog |
| /sitemap.xml | sitemap of all maze paths |
| /stats | aggregate counts by class, reach, source, and sink |
| /health | liveness probe (/healthz alias) |
| /version | version + counts |
| /random | 302 to a random maze |
curl "http://localhost:3000/map/json?vuln=dom" # only DOM flows
curl "http://localhost:3000/map/json?reach=server" # payload fits in an HTTP request
curl "http://localhost:3000/map/json?exploitable=false" # deliberate true negatives
The index page (/) has a client-side filter and links to every map above. Map responses
are built once at startup, cached, and gzip pre-compressed (Accept-Encoding: gzip cuts
the index by ~85%), so they are safe to poll from tooling.
Vulnerability metadata
Every endpoint in /map/json carries a vuln object:
{
"name": "dom-level7",
"url": "/dom/level7/",
"params": ["#hash"],
"vuln": {
"class": "dom",
"reach": "client",
"delivery": ["fragment"],
"sources": ["location.hash"],
"sinks": ["innerHTML"],
"exploitable": true,
"note": null
}
}
| Field | Meaning |
|-------|---------|
| class | reflected-html, reflected-attr, reflected-js, dom, stored, prototype-pollution, csti, non-xss-control, or unclassified |
| sources / sinks | DOM taint endpoints, e.g. location.hash → innerHTML |
| delivery | where the payload enters: query, path, body, header, cookie, referer, fragment, postmessage, window-name, … |
| reach | derived — server if any delivery channel fits an HTTP request, client if the payload only exists browser-side, unknown if untriaged |
| exploitable | false marks a deliberate control / true negative |
| note | caveats: required interaction, why it is a control, non-obvious parameter names |
Classification follows the injection context — where the bytes land — not what the
receiving API does with a well-formed argument. A value reflected raw into a JS string
literal is reflected-js however inert the function it is passed to.
Two things this exists to stop a benchmark getting wrong:
reach: "client"endpoints (fragment, postMessage, window.name, clipboard, drag-and-drop) cannot be reached by a request-only scanner at all. Counting them as misses measures the wrong thing.exploitable: falseendpoints are not bugs. The wholexsleakcategory is cross-site leaks, not XSS — a scanner that reports nothing there is correct.
Untriaged endpoints are "unclassified" with reach: "unknown", deliberately distinct
from "reviewed and found safe".
Security header overrides
To calibrate scanners against different defensive configurations, any endpoint accepts per-request header overrides via query params:
| Param | Sets |
|-------|------|
| set_csp | Content-Security-Policy (URL-encode spaces/quotes) |
| set_xcto | X-Content-Type-Options (e.g. nosniff) |
| set_xfo | X-Frame-Options (e.g. DENY) |
curl -i "http://localhost:3000/basic/level1/?query=a&set_csp=default-src%20%27self%27"
XS-Leaks
xsleak-* levels are cross-origin side-channels that vary response size, subresource
count, load/error behavior, timing, and redirect depth by a "secret" state. State comes
from either q=admin or the xsleak_role=admin cookie (set via GET /xsleak/login?as=admin).
| Level | Endpoint | Oracle |
|-------|----------|--------|
| 1 | /xsleak/search?q=admin | body size (admin returns more results) |
| 2 | /xsleak/frame?q=admin | frame count |
| 3 | /xsleak/avatar.gif?q=admin | load/error (admin 200, guest 404) |
| 4 | /xsleak/timing?q=admin | timing (guest path sleeps longer) |
| 5 | /xsleak/redirect?q=admin | redirect-chain depth |
These are leaks, not XSS — they are marked exploitable: false and a scanner reporting
nothing here is behaving correctly. Spot the difference from the CLI:
curl -s "http://localhost:3000/xsleak/frame?q=guest" | wc -c
curl -s "http://localhost:3000/xsleak/frame?q=admin" | wc -c
curl -sL -o /dev/null -w "%{time_total}\n" "http://localhost:3000/xsleak/timing?q=admin"
To measure them properly, host a page on a different origin and probe with load/error
handlers, timing, and iframe.contentWindow.length.
Benchmarking scanners
scripts/benchmark.py pulls every endpoint from /map/json, runs a scanner against
them, and prints a detection scorecard.
./bin/xssmaze -b 0.0.0.0 # terminal 1
cd scripts && ./benchmark.sh http://localhost:3000 # terminal 2
Nuclei is supported out of the box; any other tool can be wired in with
--custom-scanner "mytool {URL}". See scripts/README.md for
options, report formats, and how to add a scanner permanently.
License
MIT