August 6, 2026
XSS Lab in Cloudflare Pages
I Built a Tiny Vulnerable Static Website for DOM XSS — and Accidentally Helped Fix a Scanner’s Detection Logic

By Saskia Ostermann Toutatis
3 min read
I Built a Tiny Vulnerable Static Website for DOM XSS — and Accidentally Helped Fix a Scanner's Detection Logic
Reflected XSS test targets are everywhere. Point any scanner at testphp.vulnweb.com and you'll get a wall of familiar findings in seconds. But DOM-based XSS is a different animal, and good test fixtures for it are much harder to find.
That gap is why I built XSS Lab: a small, static, intentionally vulnerable website with no backend at all. Every vulnerability lives entirely in client-side JavaScript reading location.search or location.hash and writing that input into the DOM unsafely. No server, no reflection, no WAF log entry — just the browser doing exactly what the code tells it to.
What's actually in it?
Four vectors, side by side, so the differences are visible instead of theoretical:
Sink Source Notes 1 innerHTML ?q= query param Classic query-string DOM XSS 2 innerHTML # URL fragment Never touches the server — invisible to logs and WAFs 3 document.write ?name= query param Different sink behavior — can inject raw <script> tags 4 textContent ?safe= query param Same input, properly escaped — the control case
You can try it directly:
https://xss-lab.pages.dev/?q=<img src=x onerror=alert(1)>
https://xss-lab.pages.dev/?name=<script>alert(2)</script>
https://xss-lab.pages.dev/#<img src=x onerror=alert(3)>https://xss-lab.pages.dev/?q=<img src=x onerror=alert(1)>
https://xss-lab.pages.dev/?name=<script>alert(2)</script>
https://xss-lab.pages.dev/#<img src=x onerror=alert(3)>Or point a scanner at it. I used Dalfox:
dalfox scan "https://xss-lab.pages.dev/?q=<img src=x onerror=alert(1)>" --skip-discoverydalfox scan "https://xss-lab.pages.dev/?q=<img src=x onerror=alert(1)>" --skip-discoveryThat's where the project stopped being just a demo website and turned into something more interesting.
The flag that "did nothing"…
Running Dalfox v3 against the lab, I noticed --skip-mining-dom didn't seem to change the results at all. On a website this small, that felt like a bug worth reporting — so I filed an issue on the project's GitHub repository.
What came back from Dalfox's maintainer was a genuinely useful breakdown of how the tool actually works under the hood — three independent subsystems, not one pipeline:
- Parameter mining — harvesting candidate parameter names from HTML attributes.
- AST source→sink analysis — statically tracing data flow from sources like
location.search/location.hashto sinks likeinnerHTML/document.write, inside a fetched response's JavaScript. - Reflection / DOM classification — checking whether and how a payload shows up in that response (or, for blind XSS, whether an out-of-band callback confirms it).
--skip-mining-dom only ever touched the first one. It was never the mechanism producing this lab's findings — which is exactly why toggling it appeared to change nothing.
The distinction that actually matters
The most useful thing I took away from that conversation: Dalfox v3 has no headless browser and no CDP client — dropping that dependency was a deliberate design choice moving from v2 to v3, not a gap waiting to be filled.
A [V] (vulnerable) finding doesn't mean a browser executed the payload. It means the available evidence — marker matching, executable schemes in dangerous attributes, AST inspection of the response's own script content, inline-handler breakouts, or an out-of-band callback — was judged sufficient.
This lab's findings surface as [A] rather than [V]/[R], and that's not a lesser result — it's a different kind of evidence. The vulnerability here is written into the DOM entirely by client-side JavaScript, after the browser has already received a static, unchanging response. The payload never appears in the raw HTTP bytes at all, so --only-poc v correctly returns nothing on this site — not because the scan failed, but because none of the response-matching evidence types have anything to match against.
The real axis, per the maintainer, isn't "static vs. dynamic." It's how much confidence the available evidence supports — and different vulnerability shapes produce evidence in different places.
What the issue actually led to
The conversation didn't stay theoretical. It surfaced real, shipped fixes:
- A misleading summary line (
XSS found 0) that had been printing above real[A]findings with no indication they existed. - AST-derived source→sink descriptions now shown directly in terminal output, instead of being buried in JSON-only.
- A parameter/source-name bug where AST findings were reporting a placeholder - instead of the actual name.
- A permanent "Detection Model" documentation page, defining
[V],[A],[R], and[I]for good. - New
detection_methodandconfidencefields attached to every finding, separating how something was found from how sure the tool is that it's real.
A separate proposal to add an opt-in headless-verification pass was also considered and explicitly declined — the maintainer confirmed dropping the browser dependency was permanent, not a temporary limitation. If you're evaluating a DOM-XSS finding anywhere that isn't backed by [V]/[R], manual confirmation in a real browser is meant to stay part of the workflow, not a stopgap.
Why is this worth your time?
Two things, if you work anywhere near web security tooling:
- If you're scanning for DOM XSS specifically, a tiny fixture like this is worth more than a big reflected-XSS playground, as it isolates exactly the failure mode you're trying to catch.
- Reading a scanner's detection tiers as "confidence, not confirmation" changes how you should triage its output. A
[V]isn't a browser telling you "this fired." It's the tool telling you "I found enough evidence to be confident" — and that evidence can come from static analysis just as validly as from a live callback.
The lab is a static website with no build step, deployed straight from the repo via Cloudflare Pages. If you maintain or evaluate DOM-XSS tooling, it's a two-minute way to see the difference between "the payload appears in the response" and "the payload only ever exists after the response has already loaded" — and why scanners have to treat those very differently.
XSS Lab is deliberately insecure by design, for security testing and learning only — don't reuse any of the vulnerable patterns in production code.