Cross-Site Scripting
The scanner plants a harmless canary with angle brackets in every parameter it can find and reads the HTML that comes back. If the brackets survive, the browser would parse them as markup, so the page is injectable. If only the text survives, it tries a breakout payload matched to where the canary landed. It also tests POST bodies and looks for stored copies on read-back.
Reflected XSS: the canary test
The scanner never fires a real exploit first. It sends a unique string wrapped in < > and checks whether the server hands those two characters back untouched. That is the whole signal: unescaped brackets in an HTML response.
1For each GET endpoint and each parameter, the scanner builds a canary such as <canary_xss_8f3a>. Parameters come from the URL itself, then the ones discovery found, then a small fallback list (q, search, name, …), at most 5 per endpoint.
2The canary goes into the query string with the session's auth headers. A response with status 400 or higher is skipped.
3Flag only if the whole probe string, brackets included, is in the body and the Content-Type contains text/html. That is reported as Confirmed reflected XSS, High, confidence 0.95.
4If the brackets were encoded to < but the bare text canary_xss_8f3a is still in the page, the scanner does not stop. It looks at where the text landed and tries a breakout for that spot.
5The guard: a JSON endpoint that echoes the canary raw produces nothing, because both checks require text/html. This keeps API echoes out of the report. POST-body probes use the same guard, and the quote-only probes cannot produce the "Confirmed reflected" result on their own; they only feed the context check.
Context-aware breakouts
Encoding of < and > does not protect a canary that sits inside a quoted attribute or a script string, because there the attacker only needs a quote. The scanner reads the surrounding markup to find out which case it is in.
1A canary came back with its brackets encoded but its text intact, here inside a double-quoted value="…". Brackets are not needed to break out of an attribute, so this is worth a second look.
2Seven patterns describe where text can sit: two attribute quote styles, two JavaScript string styles, a template literal, an href, and an HTML comment. They are tried in that order against the response.
3The first pattern that matches decides the payload. For a double-quoted attribute it is " onmouseover=alert(1) x=", sent in the same parameter.
4Flag only if that exact payload comes back unescaped in a response with status below 400. The result is "Confirmed XSS: HTML attribute breakout (double-quoted)", High, 0.95.
5If the payload was encoded, the loop moves on to the next context whose pattern also matches. When none confirm, the scanner reports "Possible reflected XSS" at Low, confidence 0.40, and says encoding appears to be active.
| Context | Pattern around the canary | Breakout payload sent |
|---|---|---|
| Attribute, double-quoted | ="[^"]*CANARY[^"]*" | " onmouseover=alert(1) x=" |
| Attribute, single-quoted | ='[^']*CANARY[^']*' | ' onmouseover=alert(1) x=' |
| JS string, double-quoted | "[^"]*CANARY[^"]*" | ";alert(1);// |
| JS string, single-quoted | '[^']*CANARY[^']*' | ';alert(1);// |
| JS template literal | `[^`]*CANARY[^`]*` | ${alert(1)} |
| URL in href | href="[^"]*CANARY | javascript:alert(1) |
| HTML comment | <!--[^>]*CANARY | --><script>alert(1)</script><!-- |
POST bodies and stored XSS
Many injectable inputs are forms, not query strings. For every POST, PUT or PATCH endpoint the scanner canaries the form's own fields, and when the reply shows nothing it reads the resource back, which is where stored XSS shows up.
1Only POST, PUT and PATCH endpoints take part. The scanner uses the field names the crawler read from the real <form>, up to 25 of them, so app-specific names like firstName are covered. A fixed list of eight generic names is the fallback.
2Each field gets its own canary, <canary_xss_ID_field>. The body goes out form-urlencoded first because real forms use it, then as JSON. Whichever variant reflects a canary is the one recorded.
3If a canary appears whole in a text/html POST reply (JSON and plain-text echoes are ignored, including for the Low result), the finding is "Confirmed reflected XSS via POST body", High, 0.90. If only the text appears, it is Low, 0.40.
4Nothing in the reply? The value may be stored and rendered later. Flag only if a GET of the same URL returns an HTML page containing the canary unescaped. That is Stored XSS, Critical, 0.95.
5The read-back is restricted to text/html. A JSON list that returns the stored string is not a false positive here; the browser-side scanner is the one that decides if the app renders it unsafely.
Quick vs deep
The scanner has three phases. What runs depends on the scan depth, and each network phase works against a time budget so a large site cannot starve the rest of the scan.
1Three phases run in order against the discovered inventory: reflected GET, POST body, and a static pass over the JavaScript the crawler collected.
2At quick depth only the two network phases run, each against a 900 second budget (XSS_QUICK_SECONDS). Reflected and stored XSS on protected endpoints are still caught in a fast scan.
3At --depth deep each network phase gets 3600 seconds and the static pass is added.
4The static pass looks for 12 sink patterns such as .innerHTML =, eval(, document.write( and dangerouslySetInnerHTML. A pattern whose every match is a plain string assignment is dropped. Anything else yields one Low finding per pattern.
This scanner's "DOM XSS" mode is a regex hint. The page that proves DOM XSS by running the app in Chromium and watching sinks is DOM XSS in a real browser.
Why it matters
XSS runs the attacker's JavaScript inside your users' sessions:
- Steal session tokens. Read
localStorageand cookies and send them to an attacker server. - Impersonate users. Make API calls as the victim: transfer money, change passwords, delete data.
- Deface the application. Change what users see, inject fake login forms.
- Deliver malware. Redirect users to malicious downloads.
- Bypass CSRF protections. Script running in context can submit any form with valid tokens.
Real-world breaches
The Magecart group injected malicious JavaScript into BA's payment page, skimming 380,000 customers' payment card details. BA was fined $26 million by the ICO. The attack was possible because there was no Content Security Policy and user input was rendered without sanitization.
Attackers injected malicious JavaScript into eBay product listings, redirecting users to phishing pages to steal credentials.
The fix: encode on output
The scanner's signal disappears when untrusted text is turned into text before it reaches the page. Watch the same payload under three renderers.
1The vulnerable pattern. User text is concatenated into HTML, the browser parses it, and the canary test succeeds.
2Escape by default. React JSX turns < into <. The probe string never matches, so there is no High finding.
3When raw HTML must be returned, sanitize it with DOMPurify, and add a Content Security Policy as a second line of defense.
Vulnerable vs fixed
// BAD: User input reflected directly in HTML
export async function GET(request: Request) {
const query = new URL(request.url).searchParams.get("q")
return new Response(`<h1>Results for: ${query}</h1>`, {
headers: { "Content-Type": "text/html" }
})
}
// BAD: React dangerouslySetInnerHTML with user data
function Comment({ text }) {
return <div dangerouslySetInnerHTML={{ __html: text }} />
}// GOOD: JSX auto-escapes by default
function Comment({ text }) {
return <div>{text}</div> // React escapes HTML entities
}
// GOOD: If you must return raw HTML, sanitize it
import DOMPurify from "dompurify"
const clean = DOMPurify.sanitize(userInput)
// GOOD: Set a Content Security Policy header (next.config.js)
headers: [{ key: "Content-Security-Policy", value: "default-src 'self'" }]Limits and what it deliberately stays quiet about
| Aspect | Behaviour |
|---|---|
| Reflected scope | GET endpoints, or any endpoint whose URL already has a query string. Up to 5 parameters per endpoint, ranked by an XSS priority score. No endpoint cap, only the time budget. |
| Confirmed means | The payload string is in the response body. The scanner does not execute the page, so a CSP or an unusual parser could still stop a flagged payload. |
| Severity ladder | Stored read-back Critical (0.95) · confirmed reflected, context-confirmed High (0.95) · POST body High (0.90) · "possible" Low (0.40) · static sink Low (0.60). |
| Time budgets | Each network phase: 900 s at quick depth, 3600 s at deep. Endpoints are tried in priority order and the scanner returns what it has when time runs out. |
| Quiet on | JSON echoes (reflected and read-back both require text/html), responses with status 400 or higher, and fully encoded output. |
| Static pass | Deep only, regex only, LOW, one finding per sink pattern, no source-to-sink proof. |