cc6b337ba1540c4c071c16103cd2c0fdb8e32564 CSP sandbox does not prevent cross-document view transition state transfer
Triage note: Test asserts a CSP-sandboxed destination (no allow-same-origin) must not receive an inbound cross-document view transition or previous-page keyframe data; fix blocks the transition, closing a cross-document info-leak across the origin boundary.
Contents
The bug at a glance
A same-origin navigation whose response carries Content-Security-Policy: sandbox (without allow-same-origin) becomes an opaque-origin document, yet it still received the previous page’s captured cross-document view transition - element names, geometry, and rendered snapshots (including sensitive on-screen content) - leaking that state across the very origin boundary the sandbox was meant to erect. Reachability requires the victim to navigate to an attacker-influenced sandboxed response and the previous page to have opted into view transitions, and the leak is a rendered-snapshot/geometry information disclosure rather than arbitrary read, so CVSS 5.3 (network, low complexity, no privileges/UI, confidentiality-only) holds.
View transitions hand the destination document a package of the source page’s rendered state so it can animate between the two - a transfer that is only safe when the two documents share an origin. WebKit decided same-origin-ness using the URL, at DocumentLoader::navigationCanTriggerCrossDocumentViewTransition time, before the response’s headers were parsed. But Content-Security-Policy: sandbox in the response turns the destination into an opaque origin, and that verdict never flips: the early same-origin check said ‘yes’ and was never re-run. So a document that the browser now treats as a foreign origin still inherited the previous page’s captured element snapshots and keyframes. The fix stamps the old document’s SecurityOrigin into ViewTransitionParams at pageswap and re-checks it against the new document’s final origin in resolveInboundCrossDocumentViewTransition, after CSP has been applied.
Root cause
Cross-document view transitions capture the outgoing page’s animatable state at the pageswap boundary and replay it into the incoming document. WebCore::DocumentLoader::navigationCanTriggerCrossDocumentViewTransition gates this transfer with a same-origin check, but that check runs against the origin derived from the destination URL, at a point in the navigation before the response’s Content-Security-Policy headers have been parsed and applied.
The vulnerable state is the mismatch between that early, URL-derived origin decision and the document’s final, header-derived origin. A response to a same-origin URL that includes Content-Security-Policy: sandbox allow-scripts (notably without allow-same-origin) forces the resulting Document into a fresh opaque origin. Because the same-origin gate already returned true and is never re-evaluated, the inbound view-transition state is transferred into this now-opaque-origin document.
The reaching path: the old document opts into view transitions (@view-transition { navigation: auto }) and captures elements (e.g. a .target with view-transition-name). On navigation, Document::dispatchPageswapEvent takes the old view transition’s params (oldViewTransition->takeViewTransitionParams()) into m_inboundViewTransitionParams. The new document is created, its CSP sandbox applied giving it an opaque origin, and during Document::reveal() the engine calls ViewTransition::resolveInboundCrossDocumentViewTransition to bind the captured params. Pre-patch this function checked only the capture timeout, active-transition state, etc. - never re-validating origin - so it resolved the transition and exposed the previous page’s captured element names, geometry, and rendered content (the test’s SENSITIVE .target snapshot) plus the resulting animation keyframes to the sandboxed document.
The fix threads the origin through. ViewTransitionParams gains a RefPtr<SecurityOrigin> oldDocumentOrigin. Document::dispatchPageswapEvent records it (m_inboundViewTransitionParams->oldDocumentOrigin = &securityOrigin()) at capture time when the origin is still the trusted old-document origin. resolveInboundCrossDocumentViewTransition then re-checks: if oldDocumentOrigin is null or !oldDocumentOrigin->isSameOriginAs(document.securityOrigin()) it returns nullptr, rejecting the transition. Because this runs during reveal() after CSP headers have established the new document’s opaque origin, isSameOriginAs now correctly reports the mismatch and no state is transferred.
Key code
Re-check old vs new document origin at reveal time, after CSP sandbox has been applied
if (MonotonicTime::now() - inboundViewTransitionParams->startTime > defaultTimeout)
return nullptr;
+ // Re-check against the new document's final origin, which may differ from the URL-derived
+ // origin used by DocumentLoader::navigationCanTriggerCrossDocumentViewTransition.
+ if (!inboundViewTransitionParams->oldDocumentOrigin
+ || !inboundViewTransitionParams->oldDocumentOrigin->isSameOriginAs(document.securityOrigin()))
+ return nullptr;
+
if (document.activeViewTransition())
return nullptr;
// Document::dispatchPageswapEvent:
+ m_inboundViewTransitionParams->oldDocumentOrigin = &securityOrigin();
Patch walkthrough
Source/WebCore/dom/Document.cpp— In dispatchPageswapEvent, immediately after moving the old view transition’s params into m_inboundViewTransitionParams and stamping startTime, the patch records m_inboundViewTransitionParams->oldDocumentOrigin = &securityOrigin(). This snapshots the outgoing document’s real (trusted) SecurityOrigin while it is still available, before the new document’s headers redefine the origin.Source/WebCore/dom/ViewTransition.cpp— resolveInboundCrossDocumentViewTransition gains a second origin gate after the capture-timeout check: if inboundViewTransitionParams->oldDocumentOrigin is null, or it is not isSameOriginAs(document.securityOrigin()), the function returns nullptr and the inbound transition is abandoned. This runs during Document::reveal(), i.e. after CSP sandbox has finalized the new document’s (opaque) origin, catching the case the early URL-based check missed.Source/WebCore/dom/ViewTransition.h— Adds RefPtr<SecurityOrigin> oldDocumentOrigin to the ViewTransitionParams struct so the captured origin can travel with the params from pageswap to reveal. Also removes a duplicate forward declaration of class RenderLayerModelObject (a cleanup, not security-relevant).LayoutTests/.../navigation/no-view-transition-with-csp-sandbox.html (+resources)— New WPT: an opener opens csp-sandbox-old.html which captures a SENSITIVE .target and navigates to csp-sandbox-new.html, served with Content-Security-Policy: sandbox allow-scripts. The new page reports via postMessage whether it received a viewTransition and any animation keyframes; the test asserts hasViewTransition is false and keyframes.length is 0, proving no previous-page state crossed into the sandboxed opaque-origin document.
Background
Cross-document view transitions — A CSS feature (@view-transition) that animates between two documents in the same navigation by capturing named elements’ geometry and rendered snapshots on the outgoing page and replaying them on the incoming page. The transfer is only permitted between same-origin documents because it exposes rendered pixels and layout.
CSP sandbox / opaque origin — Content-Security-Policy: sandbox forces the document into the sandboxed browsing context flags; without allow-same-origin the document gets a fresh, opaque origin that is same-origin with nothing - so no data from any real origin, including the one it was loaded from, should flow into it.
Origin decided pre- vs post-headers — A navigation’s provisional same-origin decision can be made from the request URL, but the document’s authoritative origin is only settled once response headers (CSP among them) are parsed. Security checks made on the URL-derived origin must be re-validated against the final origin, which this bug failed to do.
ViewTransitionParams — The bundle of captured state (element names, sizes, viewport metrics, page zoom, start time) carried from the old document to the new one across pageswap; the patch adds the old document’s SecurityOrigin to it so the destination can validate the source.
Vulnerability window
- Opt-in and capture — The outgoing same-origin document declares @view-transition { navigation: auto } and a named element; on navigation the engine captures its geometry and rendered snapshot into an old ViewTransition.
- Early (URL) origin check — DocumentLoader::navigationCanTriggerCrossDocumentViewTransition approves the transition based on the destination URL’s origin, before response headers are parsed.
- Pageswap transfer — Document::dispatchPageswapEvent moves the captured params into m_inboundViewTransitionParams for delivery to the new document.
- Sandbox applied — The response’s Content-Security-Policy: sandbox (no allow-same-origin) makes the new document an opaque origin - but the earlier approval is never revisited.
- Leak at reveal — During Document::reveal(), resolveInboundCrossDocumentViewTransition (pre-patch) resolves the transition, exposing the previous page’s element names, geometry, snapshot, and keyframes to the opaque-origin document.
- Fix — The stamped oldDocumentOrigin is re-checked with isSameOriginAs against the final document origin; mismatch returns nullptr and no state transfers.
Proof of concept
This is the shipped WPT (no-view-transition-with-csp-sandbox and its csp-sandbox-old/new resources). The old page captures a SENSITIVE named element and navigates to a same-origin URL whose response is CSP-sandboxed to an opaque origin; the destination reports whether it received a view transition and keyframes. Pre-patch the sandboxed document observes the transition and previous-page keyframe data; post-patch both are absent.
<!-- csp-sandbox-old.html (same-origin, opted into view transitions) -->
<style>
@view-transition { navigation: auto; }
.target { view-transition-name: target; width:480px; height:270px;
transform: rotate(7deg); background: red; }
</style>
<div class="target">SENSITIVE</div>
<script>
onload = () => requestAnimationFrame(() => requestAnimationFrame(() => {
location.replace("csp-sandbox-new.html"); // same-origin URL
}));
</script>
<!-- csp-sandbox-new.html served with header:
Content-Security-Policy: sandbox allow-scripts (no allow-same-origin) -->
<script>
addEventListener("pagereveal", event => {
const hasViewTransition = !!(event && event.viewTransition);
// enumerate document.getAnimations() -> effect.getKeyframes()
// pre-patch: hasViewTransition === true and keyframes describe the
// previous page's captured 'target' snapshot/geometry.
// post-patch: hasViewTransition === false, keyframes empty.
window.opener?.postMessage({ hasViewTransition, keyframes: [] }, "*");
});
</script>
Exploitation
- Set up capture — Attacker gets the victim navigating within an origin that uses cross-document view transitions and renders sensitive content in a view-transition-named element (or any content captured into the transition snapshot).
- Force sandboxed destination — The navigation lands on a response that carries Content-Security-Policy: sandbox without allow-same-origin - e.g. attacker-controlled content served under the target origin, an upload endpoint, or a response the attacker can influence the headers of - producing an opaque-origin document that the attacker’s script controls.
- Read transferred state — In the opaque document, script inspects event.viewTransition and enumerates document.getAnimations()/getKeyframes() to recover the previous page’s captured element names, geometry, transforms, and rendered snapshot data - a cross-origin information leak of on-screen content the opaque origin should never see.
Detection & hunting
For defenders and SOC / detection engineers:
- Transition into opaque origin —
- CSP sandbox on transition targets —
- WPT —
Audit directions
- URL-origin vs final-origin gates — Audit other navigation-time security decisions computed from the destination URL’s origin (before headers) that are not re-validated against the document’s final SecurityOrigin after CSP/sandbox/COOP headers apply - the same early/late origin desync that caused this bug.
- State carried across pageswap — Review everything transferred in ViewTransitionParams and adjacent pageswap/pagereveal machinery for origin validation; confirm captured snapshots, geometry, and keyframes are gated on isSameOriginAs against the receiving document, not merely on timeout/active-transition state.
- Opaque-origin invariants — Search for features that inherit or receive data across a navigation into a sandboxed/opaque-origin document (BFCache state, session/history state, form autofill, transition snapshots) and verify each treats the opaque destination as same-origin with nothing.