DAST scanner

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.

DASTLow–CriticalInjection Riskxss_scannerdeep: static sink pass

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.

Scanner · xss_scanner params: in the URL, then discovered, then a fallback list; 5 per endpoint Request GET /search?q=<canary_xss_8f3a> auth headers or cookie when a session exists Response 200 · text/html <h1>Results for: <canary_xss_8f3a></h1> brackets arrive as brackets 200 · text/html <h1>Results for: &lt;canary_xss_8f3a&gt;</h1> brackets encoded, text survives 200 · application/json {"q":"<canary_xss_8f3a>"} unescaped, but not HTML status 400 or higher: probe skipped Decision probe string in body:yes Content-Type has text/html:yes High · 0.95 probe string in body:no canary text (no brackets):yes context test next probe string in body:yes Content-Type has text/html:no no finding Probes tried in order, fresh 8-hex id per parameter: <canary_xss_8f3a>"canary_xss_8f3a"'canary_xss_8f3a' Confirmed reflected XSS · parameter q Evidence: the full probe value with < and > intact sits in an HTML response. Not confirmed yet Framework encoding is active for this context. If no breakout confirms (next scene), the result is "Possible reflected XSS": Low, 0.40. Silent on purpose A browser does not parse JSON as markup. Only text/html responses can count.

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 &lt; 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.

Reflected, brackets encoded <input type="text" name="q" value="canary_xss_8f3a"> text survived, inside a quoted value parameter q, GET Breakout for that context " onmouseover=alert(1) x=" same parameter, new GET closes the value, adds an attribute Is the payload in the reply? yes, unescaped (status below 400) High · 0.95 Confirmed XSS, attribute breakout no, quotes were encoded too next matching context none confirm: Low · 0.40 Seven contexts are checked, each by a regex around the canary: Checked in this order. The first whose regex matches gets its payload sent: Context 1 matched, so its payload went out. The order continues only if that fails: attr "…" attr '…' js string "…" js string '…' js template `…` href="…" <!-- comment --> A canary in an attribute also matches the js-string pattern "[^"]*canary[^"]*", so that payload ";alert(1);// is tried next. The check is containment: the payload text is in the response body. The scanner does not render the page.

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.

ContextPattern around the canaryBreakout 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 hrefhref="[^"]*CANARYjavascript: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.

Crawled form POST /profile inputs: firstName, lastName, bio taken from the real <input name>, at most 25 fields Body, one canary per field firstName=<canary_xss_8f3a_firstName> bio=<canary_xss_8f3a_bio> sent form-urlencoded first, then JSON the variant that reflects is kept POST reply 200 · text/html <canary_xss_8f3a_bio> full string back: High · 0.90 text only: Low · 0.40 Read back: GET /profile only when the POST reply had no canary and was below status 400 200 · text/html <li><canary_xss_8f3a_bio> Stored XSS · Critical 0.95 200 · application/json [{"bio":"<canary…>"}] ignored: not HTML No discovered fields? Eight generic names are used instead: name · comment · description · title · message · bio · content · text Checked per field. A reply that is not HTML still counts here,because an API that echoes the field is itself the finding. A JSON API that returns the storedvalue is not flagged here. The value was saved, then rendered raw.

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.

1 · Reflected GET canary per parameter context breakouts network 2 · POST body form fields, stored read-back POST, PUT, PATCH network 3 · Static DOM sinks regex over the JS bundles 12 sink patterns deep only QUICK (default) budget 900 s budget 900 s skipped DEEP budget 3600 s budget 3600 s runs The Semgrep taint layer already gives a DOM-XSS floor at quick depth. What the static pass does with each pattern: el.innerHTML = "<b>Loading</b>"el.innerHTML = params.get("q") static string right after the sink: safe, skipped not provably static: counts as a match A pattern is reported only if at least one match is not safe, and only once per pattern. DOM-based XSS risk: dangerous sink · Low · 0.60 A hint that code exists. It proves nothing about a flow from user input.

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.

Real browser confirmation lives elsewhere

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

Impact

XSS runs the attacker's JavaScript inside your users' sessions:

  • Steal session tokens. Read localStorage and 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

British Airways · 2018

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.

eBay · 2015–2016

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.

User input <script>alert(1)</script> Template string`<h1>${query}</h1>` JSX auto-escape<div>{text}</div> DOMPurify + CSPDOMPurify.sanitize(input) Browser receives <script>alert(1)… &lt;script&gt;alert… (script tag removed) Scanner: brackets intact in text/html → Confirmed XSS Scanner: brackets encoded → no confirmed finding At most a Low "possible" note if a quote context exists and a breakout is tried, which fails. Content-Security-Policy: default-src 'self' blocks inline script if a bug slips through The scanner does not rely on the header. It tests the page's output either way.

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 &lt;. 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 }} />
}

Limits and what it deliberately stays quiet about

AspectBehaviour
Reflected scopeGET 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 meansThe 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 ladderStored 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 budgetsEach 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 onJSON echoes (reflected and read-back both require text/html), responses with status 400 or higher, and fully encoded output.
Static passDeep only, regex only, LOW, one finding per sink pattern, no source-to-sink proof.