The scanner says the input is reflected. Is it XSS? Output context, encoding, and how a DAST finding gets confirmed

DedSec Application Security ·

Consider a hypothetical: a dynamic scan of a staging site returns two findings with the same title, "Cross-site scripting (reflected)", and the same evidence. In both, the scanner sent a parameter containing a marker string and found it in the response body. One is on /search?q=. The other is on /api/v2/profile?fields=. The developer assigned to the second opens the response, sees a JSON error message, and closes it as a false positive. The developer assigned to the first finds the payload in the results heading with its angle brackets HTML-encoded, and closes it too.

The second developer is probably right. The first is probably wrong, because the marker on /search lands in two places: once in the heading, where the template's HTML escaping handles it, and once inside a script block that sets up analytics, where a helper escapes quotes for a JavaScript string and leaves < alone. The evidence in both findings was the same, and so was the scanner's reason for raising them. What separates them is where in the response the input landed, how it was encoded, and whether a browser would ever parse that response as a page.

This post is for whoever has to triage that finding. It covers what a reflection check observes, how output context decides exploitability, what the response Content-Type changes, why stored and DOM-based XSS look different to a scanner, how a finding moves from "reflected" to "confirmed", where Content-Security-Policy fits, and where framework auto-escaping ends. It builds on earlier posts: which security headers matter for CSP in depth, what a SAST false positive is for sanitisers credited to the wrong sink, DAST coverage for what a scan never reached, and out-of-band testing for callbacks.

Sources: the OWASP Cross Site Scripting Prevention Cheat Sheet and DOM based XSS Prevention Cheat Sheet; the WHATWG HTML Standard, sections 13.1 (the HTML syntax) and 13.2 (parsing HTML documents); the WHATWG URL Standard's basic URL parser; RFC 3986 section 3.5 on fragments; the W3C Content Security Policy Level 3 specification; MDN's pages on the script-src directive, X-Content-Type-Options and Element.innerHTML; the React, Vue and Angular security documentation; the Jinja API and template documentation; and Flask's templating page. All were retrieved on 29 October 2026. The scenario is hypothetical, the payloads are the textbook ones, and no number in this post is a measurement of any tool, ours included.

Reflection is not execution

A reflection check is the cheapest test a dynamic scanner can run. It puts a unique, harmless marker in a parameter, something like zq7xk2, sends the request, and searches the response for the marker. A hit means the input reaches the response. A scanner may then send the marker again with a few special characters attached, such as zq7xk2<"'>, and record which characters come back unchanged, which come back encoded, and which disappear.

That evidence answers one question: does this input come back? Cross-site scripting needs four more, and a reflection check answers none of them.

  1. Where did it land? The HTML parser is a state machine, and the state it is in at the point of reflection decides which characters end that state. The OWASP cheat sheet puts it simply: "There are many different output encoding methods because browsers parse HTML, JS, URLs, and CSS differently."
  2. What survived? Only the characters that matter in that context count. A raw angle bracket inside a quoted attribute is harmless; a raw double quote there is not.
  3. Will a browser parse it as a page? A response the browser treats as JSON or plain text does not run markup, whatever it contains.
  4. Can a victim be made to send it? A reflection of a query parameter can be delivered with a link. A reflection of a custom request header generally cannot, because an attacker's page cannot make a victim's browser set that header on a cross-site navigation.

A finding that answers only the first question is a lead. Triage is working through the other four.

Output context decides which characters matter

The HTML Standard's tokenizer has separate states for ordinary data, for attribute values (double-quoted, single-quoted and unquoted), for RCDATA, for raw text, for script data and for comments. Each state ends on different input, and decodes character references (such as &lt;) in some states and not others. An encoder that is correct for one state can be useless in the next. Below, each example shows a template, a payload in the q parameter, and what the response contains.

HTML body text

Template: <p>Results for {q}</p>.

In this context the angle bracket is the character that matters, and HTML entity encoding is the right defence. The OWASP sheet's examples of encoded values cover five characters: ampersand, less-than, greater-than, double quote and single quote. A scanner that saw < come back raw here has a real lead.

Quoted attribute value

Template: <input name="q" value="{q}">.

Angle brackets are irrelevant here. A filter that strips < and > and leaves quotes alone has not protected this attribute at all, and a scanner that reports "angle brackets encoded" has not established safety. In a single-quoted attribute the character that matters is the single quote, so check that your encoder handles it; not every encoder does by default.

Unquoted attribute value

Template: <input value={q}>.

The payload contains no angle bracket, no quote and no ampersand, so all five characters on the encoding list can be encoded perfectly and the injection still works. Whitespace ends an unquoted value, as the OWASP sheet notes: "The HTML parsing rules distinguish quoted values from unquoted ones, where whitespace ends the value." The fix is to quote every attribute, which the same sheet says to do in every case. A reflection with a raw space in an unquoted attribute is a stronger signal than one with a raw angle bracket.

URL attributes: href, src, action, formaction

Template: <a href="{q}">Your website</a>.

There is nothing for HTML encoding to encode. Encoding a character inside the scheme does not help either: javascript&#58;alert(1) in an attribute value is decoded by the tokenizer, because character references are processed in attribute values, so the URL parser receives the same string. Inserting whitespace does not stop it. The WHATWG URL Standard's parser says to "Remove any leading and trailing C0 control or space from input" and to "Remove all ASCII tab or newline from input" before it reads the scheme, so a tab inside the word javascript is removed before the scheme is checked.

The fix is validation, not encoding: parse the URL and allow only the schemes you mean, typically https and http, after the same whitespace stripping a browser applies. The markdown renderer that produced this page does that before it emits a link: it allows relative, http, https and mailto targets only, and strips whitespace and control characters before testing the scheme, so java<TAB>script: renders as plain text. The OWASP sheet's guidance for a value placed into a URL, as opposed to a whole URL, is URL encoding followed by HTML attribute encoding; that covers a parameter value, not a scheme.

Inside a script block, in a JavaScript string

Template: <script>var q = "{q}";</script>.

There are two ways out, and they need different characters.

HTML entity encoding is the wrong tool in this context. Script content is not decoded for character references, so an encoded quote reaches the JavaScript engine as the literal six characters &quot;. That happens to block the quote breakout while corrupting the data, but it does not encode backslashes or line terminators, and in an unquoted position such as var page = {q}; the payload alert(1) needs no special characters at all. The OWASP sheet asks for an encoder "documented for both the JavaScript string and the enclosing HTML script", which in practice means escaping the quote, the backslash, line terminators and < (as \u003c, for example), and never placing input outside a quoted string.

The same applies to state serialised into the page for a front end to hydrate, such as <script>window.__STATE__ = {"q":"..."}</script>. JSON.stringify, like many other serialisers, escapes quotes and backslashes but not < or /, so a value containing </script> ends the element. Jinja's tojson filter is documented for this case: its output "is safe to render in HTML documents and <script> tags", with the exception of double-quoted HTML attributes. This site's own build had the unescaped version until 22 October 2026: the prerenderer serialised schema data into JSON-LD script blocks without escaping <. The data was our own, so nothing was exploitable, but a post title containing </script> would have ended the element. The fix escapes <, >, & and the Unicode line terminators U+2028 and U+2029.

Event-handler attributes

Template: <button onclick="track('{q}')">Search</button>, with the server HTML-encoding the value.

That looks encoded, and the source of the page shows no raw quote. But the tokenizer decodes character references in attribute values, so the value handed to the JavaScript compiler is track('');alert(1);//'). The OWASP sheet: "HTML attribute encoding alone does not protect JavaScript in an event-handler attribute: the HTML parser decodes character references before the JavaScript is interpreted. Encoding more characters as HTML entities does not remove that second context."

This is the case where reading the raw response misleads a human reviewer most, because the evidence looks safe. The defence the sheet prefers is to stop interpolating into handlers: register handlers with addEventListener and pass the value as data, for example from a data- attribute.

CSS

Template: <div style="background-color: {q}"> or <style>.banner { color: {q} }</style>.

Running script from inside a CSS value was a legacy browser behaviour. What remains is breaking out of the CSS context into a more dangerous one: a quote ends the style attribute and the payload becomes new attributes, as in the quoted attribute case, and </style> ends a style element for the same raw-text reason as </script>. Injected CSS that stays in its context can still restyle the page, overlay a fake login form, or use attribute selectors and url() to leak attribute values to an outside server. The Vue security guide gives the clickjacking example: user-supplied styles turning a link into a transparent box over the "Log in" button. That is HTML or CSS injection rather than script execution, and it deserves a finding of its own with its own severity. The OWASP sheet's rule: "Variables should only be placed in a CSS property value."

textarea and title: RCDATA

Template: <textarea name="bio">{q}</textarea>.

This context is a reliable source of false positives for string matching. The full payload appears verbatim in the response, unencoded, and nothing runs. The test that matters is whether the closing tag survives. HTML comments are similar: input inside <!-- --> matters only if --> survives.

Contexts in one list

Will a browser parse it as a page?

The second finding in the opening scenario was a JSON response: {"error":"unknown field <img src=x onerror=alert(1)>"}, served with Content-Type: application/json. A victim who follows a link to that URL gets a JSON document, which the browser shows as text or in a JSON viewer. It does not build a DOM from the body, so the image tag is not a tag.

The X-Content-Type-Options header makes that explicit. MDN describes its effect on navigations: "the browser uses the supplied Content-Type as-is instead of examining the content to infer the type", and gives the example that with Content-Type: text/plain and nosniff "the browser will not interpret it as HTML, even if the content contains HTML markup." Historically, browsers guessed a response's type from its content when the declared type looked wrong, which is why markup in a text response was once a real risk. With a correct Content-Type and nosniff, that guess is off the table.

Check four things before closing a JSON reflection:

Two delivery details change severity without changing whether the injection exists. A response sent with Content-Disposition: attachment downloads rather than renders. A reflection that only happens on a POST request can still be delivered from an attacker's page by a form that submits itself, unless an anti-CSRF token rejects the request before the value is rendered, or a SameSite cookie means it arrives without the victim's session, which limits what the script could reach.

Stored XSS is two requests

A reflected finding has its source and sink in the same request. Stored XSS separates them: the payload goes in through one request, a profile update or a support ticket, and comes out in another, often on a different page, often for a different user. A support agent's ticket view is the classic case.

A scanner that diffs each request against its own response cannot see this. Finding it needs a marker unique to each injection point, stored on submission, and a later search for that marker across every response the scan fetches afterwards. Even then the scan only finds the stored value if it later visits the page that renders it, as an identity that can see it. A payload rendered only in an admin console is invisible to a scan run as an ordinary user, which is the same two-identity problem the IDOR post describes for authorisation.

When the rendering page is out of reach entirely (an internal tool, an email, a PDF export processed later), the general practice is a payload that calls home when it runs. The confirmation is the callback, possibly hours later, from a browser the tester never saw. The out-of-band post covers how that correlation works and what a callback does and does not prove.

DOM-based XSS: nothing to diff

DOM-based XSS happens entirely in the browser. The OWASP DOM cheat sheet's definition: "For server-side XSS, the server inserts untrusted data into a response without making it safe for its output context. DOM-based XSS arises from unsafe client-side processing."

The textbook case reads the URL fragment and writes it into the page:

The fragment never reaches the server. RFC 3986 says "the fragment identifier is separated from the rest of the URI prior to a dereference, and thus the identifying information within the fragment itself is dereferenced solely by the user agent". The server returns the same bytes with or without the payload, so a scanner that compares requests with responses has nothing to compare. The vulnerable code is in a JavaScript file that is identical for every visitor.

Other client-side sources fail the same way, even when they are sent to the server: a query parameter read by URLSearchParams and written into the DOM, a postMessage handler that does not check the origin, a value from localStorage, a field from an API response. The server-side reflection, if there is one, may be harmless, while the client-side sink is not.

The sink also changes which payloads work. MDN notes that innerHTML "does prevent <script> elements from executing when they are injected", while remaining "susceptible to many other ways that attackers can craft HTML to run malicious JavaScript". A test that only tries a script tag against an innerHTML sink gets nothing back and reports clean. An image with an error handler works.

A headless browser check adds the missing half: load the page with a payload in each client-side source, let the scripts run, and see what happens. It can observe a dialog or a callback, or it can instrument the sinks themselves and record when a marker reaches innerHTML, document.write, eval or a script src. Its limits are the crawl's limits: the vulnerable code path may only run after a click, a route change, a feature flag or a particular API response, and a check that loads the page and waits does not take those paths.

How a finding moves from reflected to confirmed

What follows is the general sequence, not a description of any particular scanner. Each step removes a class of false positive, and each adds cost.

  1. Probe. Send a unique marker in each parameter and record every place it comes back. Cheap, and everything after depends on it.
  2. Locate the context. Parse the response with an HTML parser, not a regular expression, and find the node that holds the marker: a text node, an attribute (which one, and how it is quoted), a script element, a style element, a comment, an RCDATA element. For a script context, tokenise the JavaScript to find whether the marker sits inside a string, which quote character opened it, or in code.
  3. Test the characters that matter. For that context only: the quote for a quoted attribute, whitespace for an unquoted one, the scheme for a URL attribute, the delimiter and </script for a script string, the closing tag for RCDATA. Record how each came back.
  4. Build a payload for the context. The smallest one that would execute there, and a second variant in case the first is filtered.
  5. Confirm structurally. Parse the new response and check that the payload changed the document's structure: a new element exists, or the input element now has an onfocus attribute. Finding the payload string is not enough, because it can appear in RCDATA, in an encoded attribute or in a JSON body without creating anything.
  6. Confirm by execution. Load the response in a real browser with a marker-specific callback: an overridden dialog function, or a request to a unique URL. A callback is proof that script ran in that browser, for that response.

The trade-offs, stage by stage:

And the false negatives that no confirmation stage fixes, because they happen before the probe:

Content-Security-Policy: mitigation, not fix

A strict policy changes what an injection can do. It does not remove the injection, so the finding stays open with different severity context. The security headers post covers policy design; this is what matters when triaging an XSS finding against it.

With a policy like script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none':

What it does not block, from the contexts above:

And what undoes the policy:

For triage, record the policy in force on that response, which of the protections above apply to this context, and what an attacker can still do. A reflection in HTML text under a strict nonce policy is a real bug with reduced immediate impact. A reflection in a nonced script string is close to unmitigated. The OWASP sheet's position is the one to hold to: CSP "works best" when "Used as a defense-in-depth technique."

Framework auto-escaping, and where it stops

Most modern templates escape by default, so in an application built on one, the places to look are where that default was switched off or does not apply. The OWASP sheet says these frameworks "steer developers towards good security practices" and lists the escape hatches that undo it.

Auto-escaping is HTML escaping. It makes a value safe in HTML text and in a quoted attribute. It does nothing for a javascript: URL in an href, which contains no character it escapes; it is the wrong encoding inside a script block; and it cannot help an unquoted attribute or an unquoted value in JavaScript. A template with autoescape on can still have the reflection from the opening scenario, if the variable sits inside a script element.

This is where static and dynamic testing meet. A static rule can find the escape hatches, dangerouslySetInnerHTML, v-html, the safe filter, bypassSecurityTrust calls, and with taint analysis it can say whether request data reaches them. It can also produce false positives when it does not model the framework's escaping, or credits HTML escaping against a URL or script sink; the SAST false positive post covers both. A dynamic test can confirm that a request actually reaches a sink and executes, but only for the routes and states it reached. Use each to triage the other. For a DAST reflection, find the template and the line that renders the parameter, and check for an escape hatch or a non-HTML context. For a SAST finding on an escape hatch, ask whether any route a scan can reach puts input into it, and test that route directly.

A triage checklist for a reflected XSS finding

  1. Find every place the marker landed. View the raw response, not the rendered page. A single parameter can reflect in several contexts, and the worst one decides the finding.
  2. Name the context for each. HTML text, quoted or unquoted attribute, URL attribute, script string or script code, event handler, CSS, RCDATA or comment.
  3. Check only the characters that matter there. Quotes in attributes, whitespace in unquoted ones, the scheme in URLs, the delimiter, backslash and </script in scripts, the closing tag in RCDATA. Ignore encoded angle brackets in contexts where angle brackets do not matter.
  4. Read the Content-Type and nosniff on the reflecting response, including on error paths. JSON or text/plain with nosniff does not render on navigation.
  5. If it is an API response, find who consumes the field. A client-side innerHTML sink makes it DOM XSS, with the fix in the front end.
  6. Establish delivery. Query string, POST form, path, header or cookie, and whether an attacker can make a victim send it with the victim's session.
  7. Confirm in a browser. Load a payload built for the context and observe a dialog or a callback. Record the browser and the response, so a reopened finding can be retested the same way.
  8. Record the CSP on that response, and which of the context's payloads it blocks. Lower the urgency if it is strict and the context is not a script string; do not close the finding.
  9. Find the line in source. The template, the variable and the escape hatch or wrong-context encoder responsible. Fix it with the encoder for the context, a scheme allowlist for URLs, or a change from string interpolation to data plus addEventListener.
  10. For stored and DOM sources, test what a reflection check cannot see: a later page or another role for stored values, and the fragment, postMessage and client-side storage for DOM.
  11. Before closing a parameter as not reflected, check the response was the application's. A block page, a login redirect or a validation error means the parameter was not tested.

Back to the two findings. The JSON reflection stays closed once the Content-Type and nosniff are confirmed on its error path and nothing on the front end writes that field into the page. The search reflection is reopened: the analytics helper escapes quotes for a JavaScript string and leaves < alone, so </script> ends the element and whatever follows is parsed as markup. Whether a handler in that markup runs today depends on the CSP on that response, which goes in the finding next to the context, and the way to settle it is to load the payload in a browser. The evidence was the same in both findings; the context was not.

Where AppSecD fits

AppSecD's dynamic and API testing runs Nuclei, Dalfox, SQLMap, Commix, Katana, Arjun, FFuf and Interactsh for out-of-band detection, plus more than 150 native checks and API discovery across more than 50 web frameworks. Its static analysis includes dataflow taint analysis that traces source to sink across functions.

That is the whole claim. This post does not claim how AppSecD or Dalfox decides that a reflection is cross-site scripting, which output contexts either one recognises, whether either executes payloads in a headless browser, how AppSecD treats a Content-Security-Policy when it rates a finding, or which framework escape hatches its static rules model. The confirmation stages and the checklist above are general practice, and they apply to any scanner's output, ours included.

More about AppSecD · All posts