c1ab79ebcc0fff886aad0217b9d1a9b8f20a20cf [Site Isolation] CSP upgrade-insecure-requests misses cross-origin iframe-to-top navigations
Triage note: FrameLoader::changeLocation() only called upgradeInsecureRequestIfNeeded() on the current document's CSP; the fix also invokes it on frameRequest.requester().contentSecurityPolicy(), so a cross-origin iframe's upgrade-insecure-requests directive is enforced and insecure top navigations are no longer allowed to go over HTTP.
Contents
The bug at a glance
A cross-origin iframe carrying upgrade-insecure-requests could drive window.top.location to a plaintext http:// URL and have that top-level navigation proceed over HTTP instead of being upgraded to HTTPS, silently defeating the iframe’s declared HTTPS-only posture and exposing the top navigation to network downgrade/MITM. Reachability is entirely web-content driven (any embedded iframe can attempt window.top.location) but impact is confidentiality/integrity of a single navigation rather than memory corruption, and the top frame’s own transport is not weakened, so CVSS 5.3 (network, low complexity, no privileges, no UI, limited scope) is appropriate.
upgrade-insecure-requests is a per-document promise: ‘any insecure request I initiate should be upgraded to https.’ WebKit implemented the upgrade by consulting only the CSP of the frame being navigated - the target - even though the request was authored by a different document, the requesting iframe. When a sandboxed cross-origin iframe sets window.top.location = “http://iframe-origin/…”, the upgrade code asks the top frame’s CSP origin set whether that URL’s origin should be upgraded; the top frame has never heard of the iframe’s origin, so nothing matches and the request sails out over HTTP. The requester’s own upgrade-insecure-requests directive - the one that actually applies - was never consulted. The fix makes changeLocation() upgrade against the requester’s CSP.
Root cause
The vulnerable state lives in WebCore::FrameLoader::changeLocation(FrameLoadRequest&&, …). Before the patch it computed the insecure-request upgrade by fetching the CSP of frame->document() - the document owning the frame that will actually load the request - and calling upgradeInsecureRequestIfNeeded(frameRequest.resourceRequest(), InsecureRequestType::Navigation) on it. For a top-level navigation triggered from within an iframe, frame->document() is the top document, whose ContentSecurityPolicy and its upgrade origin set describe only the top origin’s directives.
upgradeInsecureRequestIfNeeded decides whether to rewrite http:// to https:// based on whether the request’s origin is in the CSP’s set of origins that opted into upgrade-insecure-requests. The directive that opted in belongs to the iframe (the requester), and the URL being navigated to is in the iframe’s own origin. Because the check ran against the top document’s CSP, the iframe’s origin was absent from that set, upgradeInsecureRequestIfNeeded found no match, and the request was left as plaintext http://. The web-platform-test iframe-top-navigation-upgrade-2.sub.html captured exactly this: it timed out because the expected https:// navigation never happened.
The reaching path is: a cross-origin iframe with Content-Security-Policy: upgrade-insecure-requests executes window.top.location = “http://…”; the browsing-context navigation is packaged as a FrameLoadRequest whose requester() is the iframe’s document; FrameLoader::changeLocation() runs on the top frame and consults the wrong CSP; loadFrameRequest() proceeds with the un-upgraded, insecure URL.
The fix reorders the authority. changeLocation() now first takes the requester’s CSP - if (CheckedPtr requesterCSP = frameRequest.requester().contentSecurityPolicy()) - and calls upgradeInsecureRequestIfNeeded on it, so the directive of the document that actually initiated the navigation is honored. It still additionally upgrades against frame->document()’s CSP, but only when that CSP object differs from the requester’s, preserving the target-frame’s own upgrade semantics without double-applying. For the site-isolation case where the requester lives in another process, WebPageProxy::receivedNavigationActionPolicyDecision() is extended: after the target frame’s cspOriginsThatUpgradeInsecureNavigations() misses, it now looks up the originating frame via navigation.originatingFrameInfo() and WebFrameProxy::webFrame(frameID) and consults that originating frame’s upgrade set before deciding shouldUpgrade.
Key code
FrameLoader::changeLocation now upgrades against the requester’s CSP, not just the target frame’s
- if (RefPtr document = frame->document())
- protect(document->contentSecurityPolicy())->upgradeInsecureRequestIfNeeded(frameRequest.resourceRequest(), ContentSecurityPolicy::InsecureRequestType::Navigation);
+ if (CheckedPtr requesterCSP = frameRequest.requester().contentSecurityPolicy())
+ requesterCSP->upgradeInsecureRequestIfNeeded(frameRequest.resourceRequest(), ContentSecurityPolicy::InsecureRequestType::Navigation);
+
+ if (RefPtr document = frame->document()) {
+ if (document->contentSecurityPolicy() != frameRequest.requester().contentSecurityPolicy())
+ protect(document->contentSecurityPolicy())->upgradeInsecureRequestIfNeeded(frameRequest.resourceRequest(), ContentSecurityPolicy::InsecureRequestType::Navigation);
+ }
Patch walkthrough
Source/WebCore/loader/FrameLoader.cpp— In changeLocation(), the single upgrade call against frame->document()’s CSP is replaced. The requester’s CSP (frameRequest.requester().contentSecurityPolicy()) is now consulted first and unconditionally, so the initiating document’s upgrade-insecure-requests directive governs the navigation. The target document’s CSP is still upgraded against, but guarded by a check that it is a different ContentSecurityPolicy object than the requester’s, avoiding redundant work while retaining the target frame’s own opt-in.Source/WebKit/UIProcess/WebPageProxy.cpp— For remote-frame (site-isolation) navigations upgraded in the UIProcess, receivedNavigationActionPolicyDecision() previously only checked the target frame’s cspOriginsThatUpgradeInsecureNavigations() set. It now falls back to the originating frame: if the target set does not contain the URL’s origin, it resolves navigation.originatingFrameInfo() to a WebFrameProxy and checks that frame’s upgrade set, then calls navigation.upgradeCurrentInsecureRequest() if either matches.LayoutTests/imported/w3c/web-platform-tests/upgrade-insecure-requests/link-upgrade.sub.https-expected.txt— The expected result for iframe-top-navigation-upgrade-2.sub.html flips from TIMEOUT (the upgrade never fired, so the test hung) to PASS, confirming the cross-origin iframe-to-top upgrade now occurs.LayoutTests/platform/ios-site-isolation/TestExpectations— Removes the [ Failure ] expectation line for link-upgrade.sub.https.html under the site-isolation configuration, since the UIProcess-side fix now makes that suite pass with site isolation enabled.
Background
upgrade-insecure-requests — A CSP directive that instructs the user agent to treat all of a document’s insecure (http://) URLs as though they were https://, rewriting them before the request leaves the browser. It is scoped to the document that declares it and, per spec, applies to navigations that document initiates - including navigations of other browsing contexts it is allowed to navigate.
FrameLoadRequest::requester() — The security context (document) that initiated a load, as distinct from the frame that will receive it. For window.top.location = … from an iframe, the requester is the iframe’s document while the receiving frame is the top frame - the exact mismatch this bug turned on.
cspOriginsThatUpgradeInsecureNavigations() — In the UIProcess (WebFrameProxy), a per-frame set of SecurityOriginData recording which origins declared upgrade-insecure-requests, used to reproduce the upgrade decision for remote-frame navigations that cross process boundaries under site isolation.
Vulnerability window
- Directive declared — A cross-origin iframe is served with Content-Security-Policy: upgrade-insecure-requests (optionally also sandboxed), populating its document CSP’s upgrade origin set with its own origin.
- Navigation initiated — Script in the iframe sets window.top.location = “http://iframe-origin/resource”, creating a top-level FrameLoadRequest whose requester() is the iframe document but whose target frame is the top frame.
- Wrong CSP consulted — FrameLoader::changeLocation() calls upgradeInsecureRequestIfNeeded on frame->document()’s CSP (the top document), whose origin set does not contain the iframe’s origin, so no upgrade is applied.
- Insecure request emitted — loadFrameRequest() proceeds with the plaintext http:// URL; the web-platform-test hangs (TIMEOUT) waiting for the https navigation that never happens.
- Fix — changeLocation() upgrades against frameRequest.requester()’s CSP first (and the target’s only when different); WebPageProxy::receivedNavigationActionPolicyDecision() adds an originating-frame fallback for site isolation.
Proof of concept
Reconstructed from the WPT case iframe-top-navigation-upgrade-2.sub.html whose expectation flips from TIMEOUT to PASS. The iframe declares upgrade-insecure-requests and navigates the top frame to an http:// URL in its own origin; pre-patch the upgrade is skipped because the top document’s CSP is consulted, leaving the navigation on HTTP.
<!-- Top document at https://top.example/ embeds a cross-origin iframe -->
<iframe src="https://sub.example/uir-frame.html"></iframe>
<!-- https://sub.example/uir-frame.html served with:
Content-Security-Policy: upgrade-insecure-requests -->
<script>
// Per the fixed WPT iframe-top-navigation-upgrade-2 case:
// navigate the TOP frame to an insecure URL in the iframe's own origin.
window.top.location = "http://sub.example/landing.html";
// Pre-patch: top frame navigates over plaintext HTTP (no upgrade).
// Post-patch: requester (iframe) CSP applies, navigation upgraded to https.
</script>
Exploitation
- Position — Attacker either controls an embedded cross-origin iframe (e.g. an ad, widget, or compromised subresource origin) that declares upgrade-insecure-requests, or is a network attacker who wants a target’s top-level navigation to occur over plaintext HTTP.
- Trigger — The iframe issues window.top.location = “http://…” pointing at an origin absent from the top document’s upgrade set; the top navigation leaves the browser unencrypted, contradicting the iframe’s declared HTTPS-only policy.
- Leverage — An on-path attacker observes or tampers with the plaintext request/response (downgrade), or the developer’s assumption that upgrade-insecure-requests guarantees HTTPS for iframe-initiated top navigations is silently violated. No memory-safety primitive results; impact is transport confidentiality/integrity of that navigation.
Detection & hunting
For defenders and SOC / detection engineers:
- Plaintext navigations despite UIR —
- Cross-frame top navigations —
- WPT regression —
Audit directions
- Requester vs target CSP — Audit every site in WebCore that enforces a CSP-derived policy (mixed content, block-all-mixed-content, sandbox, require-trusted-types) during a navigation for whether it consults the target frame’s CSP where it should consult frameRequest.requester()’s. The requester/target split recurs across FrameLoader and DocumentLoader.
- Site-isolation origin sets — Review other UIProcess re-implementations of WebProcess security decisions (like cspOriginsThatUpgradeInsecureNavigations) to confirm they consult the originating frame, not only the target frame, when a navigation crosses processes - the same originatingFrameInfo() fallback pattern added here.
- Named/other-context navigations — Check window.open, form target=, and _blank/named-frame navigations initiated cross-origin for the same mis-scoped upgrade behavior, since they also flow through changeLocation/loadFrameRequest with a requester distinct from the receiving frame.