A deliberately vulnerable single page web application, built as a live demonstration target for es-chromium, the taint tracking browser from EyalSec.
It is not a list of labelled XSS labs. It is a plausible product: Northwind Retail Analytics, a retail dashboard with saved views, a search box, a custom metric formula, a saved theme, an embedded partner widget and an export preview. Every one of those features has an independent reason to exist, and 21 of them carry real DOM-XSS.
Live: https://eyalsec.github.io/vulnerable-javascript/ Catalogue: https://eyalsec.github.io/vulnerable-javascript/catalog.html
Every screen runs attacker controlled input into a dangerous DOM operation on purpose. It is static, has no backend, stores nothing and has nothing to steal, so the only achievable outcome is self-XSS on a page that says so at the top of every screen. Do not copy code out of it, and do not host it on a domain you care about: it is published on
github.ioprecisely because that domain is on the Public Suffix List, so a payload here cannot set cookies for anything above it.
The app does not import, detect, or know anything about EyalSec. It is byte for byte identical between the two runs below. Only the browser changes.
| Open it in | What happens |
|---|---|
| an ordinary browser | The dashboard works. The payload executes. Nothing is recorded and nobody finds out. |
| es-chromium | Every flow from an attacker controlled source to a DOM sink is reported, with the source, the sink and the exact characters that were tainted. Zero changes to the page. |
A scanner guesses at whether a sink is reachable. This reports a flow that actually happened, in a real page, in a real browser, with the widget loaded.
git clone https://github.com/EyalSec/vulnerable-javascript
cd vulnerable-javascript
# drive every catalogued flow against the live site
./run-eschromium.sh /path/to/es-chromium https://eyalsec.github.io/vulnerable-javascript/Either button opens a private cloud environment with serve.py already
running on port 8000, which is handy for reading or changing the app. Keep the
forwarded port private.
docker run --rm -p 127.0.0.1:8000:8000 ghcr.io/eyalsec/vulnerable-javascript
./run-eschromium.sh /path/to/es-chromium http://127.0.0.1:8000/The image serves the same files with serve.py, for running the demo offline.
It is built from main for linux/amd64 and linux/arm64 by
this workflow, with a signed build
provenance attestation:
gh attestation verify oci://ghcr.io/eyalsec/vulnerable-javascript:latest -R EyalSec/vulnerable-javascript./serve.py # http://127.0.0.1:8000/
./run-eschromium.sh /path/to/es-chromium http://127.0.0.1:8000/Serve it over HTTP rather than opening index.html from disk. A file:// page
has no fragment to deep link with, and document.cookie, document.referrer
and both web storages are empty on it, which silently disables more than half
of the catalogue.
ES2_CHROMIUM_TEST_SINK=1 timeout 20 ./es-chromium \
'https://eyalsec.github.io/vulnerable-javascript/index.html#view=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E'ESEVENT where=Element.innerHTML origin=location.hash repr=<img src=x onerror=alert(1)>
ES2_CHROMIUM_TEST_SINK=1 prints detections to the console and needs no
dashboard and no token. Three things will trip you up if you drive it by hand:
the browser stays open like a browser and never exits on its own, so bound
every run with timeout and expect a non-zero exit; detections can arrive on
either stdout or stderr, so capture both streams; and the repr wraps the
attacker controlled span in two invisible characters (U+E000 and U+E001) so
you can see exactly which characters were tainted, which means a grep for a
payload that straddles that boundary will not match as plain text.
es-chromium itself is delivered through the EyalSec dashboard. See
EyalSec/es-chromium.
21 flows across 8 taint sources and 18 distinct sinks. Every row was observed firing against the build published 2026-08-04; nothing is claimed here that was not watched happening.
| Feature | Source | Sink |
|---|---|---|
| Deep linked saved view | location.hash |
Element.innerHTML |
| Search results header | location.search |
insertAdjacentHTML |
| Custom computed metric | location.hash |
eval |
| Auto refresh interval | location.hash |
setTimeout (string) |
| Custom tick handler | location.hash |
new Function |
| Embedded report widget | postMessage |
Element.innerHTML |
| Partner analytics tag | location.hash |
HTMLScriptElement.src |
| Staging API override | location.hash |
fetch |
| Staging API override, legacy | location.hash |
XMLHttpRequest.open |
| Live updates endpoint | location.hash |
WebSocket |
| Live updates fallback | location.hash |
EventSource |
| Saved dashboard theme | localStorage |
CSSStyleDeclaration.cssText |
| User avatar | location.hash |
Element.setAttribute |
| Continue where you left off | document.cookie |
Element.innerHTML |
| Continue where you left off, write | location.hash |
document.cookie |
| Share dialog handoff | window.name |
Element.innerHTML |
| Draft comment restore | sessionStorage |
Element.outerHTML |
| Export preview | location.hash |
DOMParser.parseFromString |
| Export preview, print frame | location.hash |
document.write |
| Charting web component | location.hash |
ShadowRoot.innerHTML |
| Back to where you came from | document.referrer |
location.assign |
The full map, with a one click trigger URL and the expected detector id for
each row, is at catalog.html.
That page and run-eschromium.sh read the same file, catalog.js, so the
demo script and the harness cannot drift apart.
Most vulnerable features have a neighbour that does the same job correctly:
the view label is rendered with innerHTML while the view description beside
it uses textContent; the search header interpolates the term while the result
rows below it do not; the widget body is trusted as markup while the widget
title from the same message is set as text.
These are load bearing, not decoration. A demo where the tool lights up on
everything proves nothing. Measured on a clean load with no payload, the app
produces one flow, and it is a real one: the widget genuinely passes data
into innerHTML even when the content is benign. Every safe twin is silent.
es-chromium follows taint through the string operations pages use most. Three features in this app pass the value through an operation that ends the trail, and they are catalogued here and in the harness so every release re-checks them. They are exactly as exploitable as the rows above.
| Feature | What happens | Observed |
|---|---|---|
| Region filter label | The region code is upper cased before display | Not reported: toUpperCase() and toLowerCase() end the trail. |
| Saved layout string | A layout is passed base64 encoded and decoded with atob |
Not reported: atob rebuilds the string, and the trail ends there. |
| Label slug pass | The label is taken apart per character and rejoined | Not reported: split("") to characters and join("") end the trail. Splitting on a real separator keeps it. |
All three were measured on the 2026-08-04 build. run-eschromium.sh re-checks
them on every run, so a build that reports one shows it as gap closed.
Two notes on reading results, neither of them specific to this app:
- es-chromium reports flows. The widget flow above fires on benign content
because the data really does reach
innerHTML. That is the right threat model for DOM-XSS, and it is why a data heavy site produces more flows than this one. - A sanitized value is still reported as a flow, because the taint survives the sanitizer. The event shows you the flow; you judge whether the sanitizer made it safe.
index.html the dashboard
app.js application logic; the vulnerable features live here
components.js the charting web component (ShadowRoot sink)
styles.css
widget.html the embedded "partner" report widget (postMessage sender)
share.html share dialog; parks a note in window.name and navigates back
depart.html the previous page, so document.referrer is genuinely set
catalog.html operator map: feature, source, sink, detector, trigger
catalog.js the catalogue itself, read by both catalog.html and the harness
run-eschromium.sh drives every trigger and reports what fired
serve.py stdlib http.server, for local and offline runs
The embedded widget is same origin with its host, because a GitHub Pages site
has only one origin. A cross origin frame is the more realistic shape for a
third party report, but the flow being demonstrated, postMessage data
reaching innerHTML in the host page with no event.origin check, is
identical either way.
MIT. See LICENSE. The same terms as
EyalSec/vulnerable-python, the
equivalent target for es-python. This software is intentionally insecure:
never deploy it to production or expose it to untrusted networks.
Northwind Retail Analytics is not a real company, a real product, or a real dataset. It exists to be attacked.