Skip to content

About

A deliberately vulnerable retail dashboard, live at a real HTTPS origin, built as the demonstration target for es-chromium: 21 DOM-XSS flows across 8 sources and 18 sinks, each reachable from one URL. Every flaw sits inside a feature that has a reason to exist.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

vulnerable-javascript

OpenSSF Scorecard

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

Warning: this application is deliberately insecure

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.io precisely because that domain is on the Public Suffix List, so a payload here cannot set cookies for anything above it.


The point in one picture

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.


Quickstart

Against the hosted app

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/

Without installing anything

Open in GitHub Codespaces Open in Ona (formerly Gitpod)

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.

In a container

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

Against a local checkout

./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.

By hand

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.


What it demonstrates

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.

Safe twins

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.


Scope, catalogued

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.

Files

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

A note on the widget

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.


License

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.

About

A deliberately vulnerable retail dashboard, live at a real HTTPS origin, built as the demonstration target for es-chromium: 21 DOM-XSS flows across 8 sources and 18 sinks, each reachable from one URL. Every flaw sits inside a feature that has a reason to exist.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages