September 14, 2026
Framework Escape Hatches: One Grep, Your Whole Attack Surface
Frameworks made interpolation safe by default. Every one of them ships a hatch, and developers use it every day.

By Nitin yadav
6 min read
Hello, I am Nitin.
Day fourteen, and the last post of week two.
You will hear this constantly: "we use React, so we do not have XSS." It is the most common security claim in modern web development and it is half true, which is the most dangerous kind of true.
Here is the accurate version. Modern frameworks made one specific context safe by default: interpolating a value into the rendered output. That was historically where most XSS lived, so this was a genuine and enormous win. Escaping is now automatic, and a developer has to actively go around it to be vulnerable.
But every framework ships escape hatches, because sometimes you really do need to render HTML. Those hatches are deliberately given ugly, alarming names โ and developers use them anyway, every single day, usually because a product requirement said "users should be able to format their comments."
So your job on a framework app is not to find a missed escape. It is to find where somebody opted out. That turns out to be a search you can automate almost completely, which makes this one of the most beginner-friendly days in the month.
First: identify the framework and version
Two minutes, and it decides everything after.
In the console:
// React
window.React?.version
document.querySelector('#root, #app')?._reactRootContainer
Object.keys(window).filter(k => k.startsWith('__REACT'))
// Vue
window.Vue?.version
document.querySelector('[data-v-app]')
// Angular
window.ng?.version?.full
document.querySelector('[ng-version]')?.getAttribute('ng-version')
// Next.js / Nuxt / Svelte
window.__NEXT_DATA__?.buildId
window.__NUXT__
document.querySelector('[class*="svelte-"]')// React
window.React?.version
document.querySelector('#root, #app')?._reactRootContainer
Object.keys(window).filter(k => k.startsWith('__REACT'))
// Vue
window.Vue?.version
document.querySelector('[data-v-app]')
// Angular
window.ng?.version?.full
document.querySelector('[ng-version]')?.getAttribute('ng-version')
// Next.js / Nuxt / Svelte
window.__NEXT_DATA__?.buildId
window.__NUXT__
document.querySelector('[class*="svelte-"]')From the page source, the giveaways are just as fast: a root div with a known id, an attribute carrying the framework version, or a state global in an inline script.
Write the version down. Old major versions carry known bugs that are fixed in current ones, and knowing the version tells you which of the hatches below are even reachable.
The escape hatches, framework by framework
React
The inner-HTML prop. Named to be alarming, used constantly anyway:
<div dangerouslySetInnerHTML={{ __html: userContent }} /><div dangerouslySetInnerHTML={{ __html: userContent }} />Grep:
grep -rn "dangerouslySetInnerHTML" pretty/grep -rn "dangerouslySetInnerHTML" pretty/Every hit is a candidate. For each, trace what feeds __html. If it is a server response field that reflects stored user data, you have the stored-XSS-into-framework-sink pattern.
URL props. React escapes text, not URL semantics. A user-controlled value in an href or src is day five all over again:
<a href={user.website}>Profile</a><a href={user.website}>Profile</a>React has blocked the javascript scheme in href since version 18, so check the version first. On older versions this works directly. On current versions, look for URLs assembled and assigned imperatively via a ref, which bypasses the check.
Refs reaching real DOM. Once code holds a DOM node, all of day eight's sinks are back:
grep -rnE "useRef|createRef|\.current\.(innerHTML|outerHTML|insertAdjacentHTML)" pretty/grep -rnE "useRef|createRef|\.current\.(innerHTML|outerHTML|insertAdjacentHTML)" pretty/Vue
The html directive, the direct equivalent:
grep -rn "v-html" pretty/grep -rn "v-html" pretty/Dynamic components and expressions. Vue templates evaluate expressions, so a user-controlled component name or a template compiled at runtime is a much deeper hole than an inner-HTML write. Look for the runtime compiler being used at all โ apps built with the full build can compile templates from strings, and if any part of that string is user-controlled, you have expression evaluation rather than markup injection.
grep -rnE "v-html|:is=|component\s+:is|compile\(|template:\s*[`'\"]" pretty/grep -rnE "v-html|:is=|component\s+:is|compile\(|template:\s*[`'\"]" pretty/Angular
Angular is strict by default and makes you announce your opt-out loudly:
grep -rnE "bypassSecurityTrust(Html|Url|ResourceUrl|Script|Style)|trustAs(Html|Url|ResourceUrl)" pretty/grep -rnE "bypassSecurityTrust(Html|Url|ResourceUrl|Script|Style)|trustAs(Html|Url|ResourceUrl)" pretty/Every one of those is a deliberate "I promise this is safe" from a developer. Check whether the promise holds by tracing the input.
Also worth checking: whether any part of a template is built from user input. Angular template injection is client-side expression evaluation, and it is considerably more powerful than HTML injection.
Svelte
grep -rn "@html" pretty/grep -rn "@html" pretty/Svelte's raw-HTML block is the equivalent hatch, and because Svelte compiles away, the compiled output can be harder to spot. Source maps (day nine) help a lot here.
Server-side rendering, the one people forget
This is the highest-value item on the page and it applies across all of them.
The hydration state blob is not JSX. It is a script block containing serialised JSON, and it is escaped by a completely different code path from the one that escapes your components. Everything from day four applies to it.
curl -s <https://target.com/page> | grep -oE '__NEXT_DATA__|__NUXT__|__remixContext|__sveltekit|window\.__[A-Z_]+__'curl -s <https://target.com/page> | grep -oE '__NEXT_DATA__|__NUXT__|__remixContext|__sveltekit|window\.__[A-Z_]+__'Then map which fields in that blob you control, and test both routes from day four: breaking the JSON string, and breaking out of the script element.
A profile field that renders safely through JSX everywhere in the app can still be unescaped in the hydration payload, because those are two different serialisers. That is a very common and very findable bug.
The one grep that covers everything
Run this first on any framework app:
grep -rnE "dangerouslySetInnerHTML|v-html|@html|bypassSecurityTrust|trustAs(Html|Url|ResourceUrl)|\.current\.innerHTML|innerHTML\s*=" pretty/ > hatches.txt
wc -l hatches.txtgrep -rnE "dangerouslySetInnerHTML|v-html|@html|bypassSecurityTrust|trustAs(Html|Url|ResourceUrl)|\.current\.innerHTML|innerHTML\s*=" pretty/ > hatches.txt
wc -l hatches.txtOn a mature application this typically returns somewhere between five and fifty hits. That is a completely tractable list to read by hand, and it is the entire attack surface for this bug class in that app.
For each hit, ask the three questions from day nine: is the value a literal (dead), does it come from an API response (check whether you control that stored data), or does it trace to a URL or client-side source (day eight)?
Where these hatches actually get used
Predict where the opt-outs live and you will find them faster:
- Rich text display. Comments, descriptions, articles, wiki pages. Someone needed formatting and reached for raw HTML.
- Markdown rendering. The renderer emits an HTML string, and something has to insert it. That insertion is always one of these hatches. Day 18 is entirely on this.
- Email and notification previews.
- Internationalisation strings that contain markup for bold or links. Translation files are rarely treated as untrusted, and sometimes translations are crowd-sourced.
- Charts, dashboards, and tooltips, where a library wants an HTML string for labels.
- Third-party embedded components, where the hatch is inside a dependency rather than the app's own code.
- Anything rendering a server-provided error message verbatim.
That internationalisation one is worth dwelling on. If translations are user-contributed or come from an external service, the string is untrusted input that ends up in an inner-HTML sink and nobody in the pipeline ever thought of it as user input.
Trusted Types: knowing when to walk away
Trusted Types is a browser feature that stops these sinks accepting plain strings at all. Under enforcement, assigning a raw string to inner HTML throws instead of rendering. React integrates with it, so the inner-HTML prop demands a TrustedHTML object rather than a string. It reached cross-browser availability in early 2026, so you will meet it more often from now on.
Check for it in one line:
curl -sI <https://target.com/page> | grep -i "content-security-policy"curl -sI <https://target.com/page> | grep -i "content-security-policy"Look for require-trusted-types-for in the policy. In the console, window.trustedTypes tells you whether the API is present.
If enforcement is on, the framework hatches above are mostly closed and your time is better spent elsewhere on the target โ which is genuinely useful to know early rather than after two hours. Where it gets interesting is the policy itself: an application that defines a permissive policy which passes strings through unchanged has adopted the API without the protection, and that is worth reporting on its own.
Impact ladder
- Informational โ hatch used with a hardcoded or fully controlled value. Not a bug, but note it for retesting.
- Low โ hatch fed by a value only you can set, visible only to you. Self-XSS; see day 27.
- Medium โ hatch fed by a URL parameter. Reflected XSS through a framework sink.
- High โ hatch fed by stored data another user can set, rendering for other users on load.
- Critical โ hydration payload injection, or a hatch on an admin or support screen. The hydration case is especially good because it often escapes notice completely: the same field is safe everywhere in the UI and unsafe in the state blob.
Conclusion โ steal this checklist
- Frameworks made interpolation safe. They did not make the application safe. Hunt the opt-outs.
- Identify framework and version first, in the console, in two minutes. Version decides which hatches are reachable.
- One grep covers the whole class: the inner-HTML prop, the html directive, the raw-html block, the trust-bypass calls, and direct inner-HTML writes.
- Five to fifty hits on a mature app is normal, and that list is the entire attack surface. Read it by hand.
- React blocks the javascript scheme in href from version 18. Check the version, and look for URLs assigned imperatively through refs.
- Refs give components real DOM nodes, which puts every day-eight sink back in play.
- Vue and Angular template compilation from strings is expression evaluation, not just markup injection. Much deeper.
- The hydration state blob is a script block, not JSX, and uses a different serialiser. A field safe everywhere in the UI can be unsafe there.
- Predict where hatches live: rich text, markdown, email previews, translation strings, chart labels, third-party components.
- Crowd-sourced or external translation strings are untrusted input nobody treats as untrusted.
- Check for Trusted Types enforcement early. If it is on, move on โ unless the policy itself passes strings through unchanged, which is its own finding.
That closes week two. Tomorrow we start week three: how HTML sanitizers actually work, and the five ways they break.
If you Love reading my blogs. Check my Youtube Channel too.