September 18, 2026
Reflected XSS via dangerouslySetInnerHTML: When the API Is Safe but the Frontend Isnβt
CWE-79: Improper Neutralization of Input During Web Page Generation CVSS 3.1: 6.1 (Medium) β AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
By Neel Chauhan
2 min read
Why I checked the API response before touching the browser
A search feature's request/response cycle is a good place to start, because it isolates two very different questions: does the backend encode data properly, and does the frontend render it safely. Those are separate failure points, and conflating them leads to misdiagnosing the fix.
GET /api/feedback?q=test HTTP/2
HTTP/2 200 OK
Content-Type: application/json
{
"ok": true,
"search_query": "test",
"results": []
}GET /api/feedback?q=test HTTP/2
HTTP/2 200 OK
Content-Type: application/json
{
"ok": true,
"search_query": "test",
"results": []
}Content-Type: application/json is the important detail here. The API layer is returning a properly typed JSON response, and JSON string encoding on its own already neutralizes anything that would be dangerous in an HTML context. If this were purely a server-side issue, I'd expect to see raw HTML in the Content-Type header instead. That told me, before writing a single payload, that if a vulnerability existed here, it was almost certainly a client-side DOM sink issue, not a backend encoding failure.
Finding the actual sink in the JS bundle
I pulled apart the frontend's compiled JavaScript to find exactly how search_query gets rendered onto the page. This is the line that mattered:
javascript
dangerouslySetInnerHTML: {
__html: "Results for: ".concat(p)
}dangerouslySetInnerHTML: {
__html: "Results for: ".concat(p)
}dangerouslySetInnerHTML is React's explicit opt-out of its default automatic HTML escaping. React named it that deliberately, as a warning label. Any value passed through it gets inserted into the DOM as raw HTML, tags, attributes, and all, executed exactly as if it were part of the page's original markup.
For comparison, right next to it in the same bundle, a different piece of feedback-message rendering used ordinary JSX interpolation:
javascript
<p>{e.message}</p> // safe: React escapes this automatically, no sink here<p>{e.message}</p> // safe: React escapes this automatically, no sink hereThat side-by-side comparison is worth including in any writeup, because it shows the vulnerability isn't a framework limitation, React's default behavior is already safe. This is a single deliberate override of that default, in one specific component.
The data flow
/api/feedback?q=<payload>
β JSON field "search_query"
β React state variable `p`
β dangerouslySetInnerHTML
β raw DOM insertion
β script execution/api/feedback?q=<payload>
β JSON field "search_query"
β React state variable `p`
β dangerouslySetInnerHTML
β raw DOM insertion
β script executionFive hops, and the escaping that should happen at the last step simply never runs.
Building and firing the PoC
GET /api/feedback?q=<img src=x onerror=alert(document.domain)> HTTP/2
HTTP/2 200 OK
Content-Type: application/json
{
"ok": true,
"search_query": "<img src=x onerror=alert(document.domain)>",
"results": []
}GET /api/feedback?q=<img src=x onerror=alert(document.domain)> HTTP/2
HTTP/2 200 OK
Content-Type: application/json
{
"ok": true,
"search_query": "<img src=x onerror=alert(document.domain)>",
"results": []
}The API faithfully returns the payload as an encoded JSON string value, exactly as it should, this confirms again that the backend is doing its job correctly. The vulnerability only manifests once the frontend takes that string and decides to render it as raw HTML instead of as text.
Loading the results page in an actual browser with this query fired the payload: a broken image tag triggers its onerror handler, which executes the injected JavaScript. alert(document.domain) is the standard, low-impact way to prove execution without doing anything invasive, if it fires, you have proven code execution in the page's own origin, full stop, no ambiguity.
Why I used a reflected GET-based payload for the PoC, and what that means for exploitability
Because the payload lives entirely in a URL query parameter and isn't stored anywhere server-side, this is reflected rather than stored XSS. That has a specific implication for how it gets weaponized in practice: an attacker needs to deliver a crafted link to the victim (a phishing email, a message, a malicious ad, a shortened URL), and the victim needs to click it while authenticated. It requires user interaction, which is reflected in the CVSS vector's UI:R, but a single click on a link that looks like it points to a legitimate feature on a trusted domain is a low bar in practice.
Why this is worse than it looks in isolation
Once arbitrary JavaScript executes inside an authenticated session on the app's own origin, a lot becomes reachable:
- Reading
document.cookieto exfiltrate the session token, though in this case the session cookie was correctly flaggedHttpOnly, which blocks that specific vector, worth calling out explicitly as a partial mitigation rather than ignoring it. - Making authenticated requests silently on the victim's behalf, using their live session, meaning this could be chained to trigger other state-changing actions the victim never intended.
- Injecting fake UI elements, like a convincing credential-harvesting overlay, directly into a page the victim already trusts because it genuinely is that page.