← WebKit — 2026-W41

a39a3c616a346bbe57129787c3dfde7dfc457135  Determine whether a document is a secure context when it is created

severity medium class PolicyBypass confidence 0.62 signal score 10.42 Source/WebCore/dom patch-now AI review CONFIRMED
Primitive: A document inside an insecure cross-process (remote) ancestor is treated as a secure context, unlocking secure-context-gated APIs
Triage note: Document::isSecureContext is replaced by a cached m_isSecureContext computed once in computeIsSecureContext()/initSecurityContext, inheriting the parent's DocumentSecurityPolicy.isSecureContext which is now propagated cross-process (WebFrameProxy, serialization, workers). The old isFrameDocumentSecure returned true when a remote frame's security origin was unavailable, so an insecure ancestor behind a process boundary could be mis-treated as secure; this closes that secure-context determination gap.
Structural signals: smartptr_member_added (+6.47), null_guard (+1.95), area:Source/WebCore/dom (+2.0)

The bug at a glance

Document::isSecureContext previously walked ancestor frames each call via isFrameDocumentSecure(), which returned true when a remote (cross-process) ancestor’s SecurityOrigin was unavailable, so a document embedded under an insecure http:// ancestor behind a site-isolation process boundary could be mis-classified as a secure context and unlock secure-context-gated APIs (WebCrypto subtle, service workers, storage/permission features). This is a WebContent-process policy/logic bypass, not memory corruption; severity medium is appropriate. The effect is capability escalation (fail-open secure-context determination), not RCE, and no memory-safety primitive follows from the diff (inferred).

The hook is the fall-open branch ‘return !securityOrigin || securityOrigin->isPotentiallyTrustworthy()’: under site isolation the remote ancestor’s origin is routinely unavailable, so the OR with !securityOrigin silently answered ‘secure’ for exactly the cross-process case the check was supposed to cover.

Root cause

The violated invariant is that a document is a secure context only if every ancestor in its frame tree is potentially trustworthy. Pre-patch, Document::isSecureContext() iterated ancestorFrames(*m_frame) calling isFrameDocumentSecure(frame) on each. For a LocalFrame it checked the real document origin, but for a RemoteFrame (the local proxy of a cross-process frame) it did: RefPtr securityOrigin = frame.frameDocumentSecurityOrigin(); return !securityOrigin || securityOrigin->isPotentiallyTrustworthy(). Under WebKit site isolation a remote ancestor’s security origin is frequently not materialized in this process, so frameDocumentSecurityOrigin() yields null and the !securityOrigin disjunct returns true.

The concrete pre-patch sequence is: 1. a secure-context-gated API calls Document::isSecureContext(). 2. it loops ancestorFrames and reaches a RemoteFrame ancestor whose document lives in another process. 3. frame.frameDocumentSecurityOrigin() returns nullptr. 4. isFrameDocumentSecure returns true for that insecure-but-unavailable ancestor, so the loop never returns false and the document is treated as secure. The determination was also recomputed per call rather than fixed per navigation (the FIXME ‘Determine this once per navigation’ is removed by this patch).

The fix caches the result in a one-bit member m_isSecureContext computed once in computeIsSecureContext(), which reads the parent frame’s propagated DocumentSecurityPolicy via parentFrame->frameDocumentSecurityPolicy(); if that is absent or its isSecureContext == IsSecureContext::No it returns false (fail-closed) instead of falling open. IsSecureContext is added to the DocumentSecurityPolicy struct and serialized cross-process (WebCoreArgumentCoders.serialization.in), set from LocalFrame::frameDocumentSecurityPolicy() and FrameLoader::commitProvisionalLoad(), and WebFrameProxy seeds the initial about:blank proxy’s policy from its creator. Workers carry a snapshot bool: WorkerParameters gains IsSecureContext, WorkerGlobalScope::isSecureContext() returns m_isSecureContext instead of m_topOrigin->isPotentiallyTrustworthy(). All types here are plain value structs / bit members; no lifetime object is involved, so this is a pure determination-logic fix.

Key code

Old fall-open on missing remote origin vs new fail-closed computeIsSecureContext()

// REMOVED (fell open when a remote ancestor's origin was unavailable):
//   RefPtr securityOrigin = frame.frameDocumentSecurityOrigin();
//   return !securityOrigin || securityOrigin->isPotentiallyTrustworthy();

bool Document::computeIsSecureContext() const
{
    RefPtr parentFrame = m_frame->tree().parent();
    if (!parentFrame)
        parentFrame = m_frame->loader().client().provisionalParentFrame();
    if (!parentFrame)
        return isDocumentSecure(*this);
    auto parentSecurityPolicy = parentFrame->frameDocumentSecurityPolicy();
    if (!parentSecurityPolicy || parentSecurityPolicy->isSecureContext == IsSecureContext::No)
        return false;
    return isDocumentSecure(*this);
}

Fix approach

The patch replaces the per-call ancestor walk with a value cached at security-context initialization: initSecurityContext() sets m_isSecureContext = computeIsSecureContext() and, for origin-inheriting documents, copies the owner’s bit directly (m_isSecureContext = ownerFrame->document()->m_isSecureContext). computeIsSecureContext() consults only the immediate parent’s DocumentSecurityPolicy, which now carries an IsSecureContext field propagated across the process boundary through serialization, LocalFrame::frameDocumentSecurityPolicy(), commitProvisionalLoad(), and WebFrameProxy (which marks the initial about:blank proxy as secure as its creator). The decisive semantic change is fail-closed: a missing or insecure parent policy yields false, where the old code fell open to secure. On the hot path isSecureContext() collapses to ‘return m_isSecureContext’ after the early service-worker/settings exits, and workers return a snapshot bool threaded through WorkerParameters rather than re-deriving trustworthiness from the top origin.

Patch walkthrough

  • Source/WebCore/dom/Document.cpp — Removes isFrameDocumentSecure() and the ancestorFrames() loop; adds computeIsSecureContext() (parent-policy based, fail-closed) and caches m_isSecureContext in initSecurityContext() (both the computed value and the inherited-owner copy); isSecureContext() now returns the cached bit.
  • Source/WebCore/dom/Document.h — Adds bit member m_isSecureContext : 1 { true }, the computeIsSecureContext() declaration, and the markAsInsecureContextForTesting() hook.
  • Source/WebCore/dom/DocumentSecurityPolicy.h — Adds an IsSecureContext field so the secure-context bit travels with COEP/COOP as the per-document security policy.
  • Source/WebCore/loader/FrameLoader.cpp — commitProvisionalLoad() now populates the DocumentSecurityPolicy with the restored document’s isSecureContext.
  • Source/WebCore/page/LocalFrame.cpp — frameDocumentSecurityPolicy() includes isSecureContext so a parent frame can hand its secure-context state to a child, including across processes.
  • Source/WebCore/testing/Internals.cpp — markContextAsInsecure() also flips the cached bit via markAsInsecureContextForTesting() so tests exercise the new path.
  • Source/WebCore/workers/WorkerGlobalScope.cpp / .h — Stores m_isSecureContext from WorkerParameters and returns it from isSecureContext() instead of recomputing from m_topOrigin trustworthiness.
  • Source/WebCore/workers/WorkerThread.{cpp,h}, WorkerMessagingProxy.cpp, service/ServiceWorkerThread.cpp, shared/SharedWorkerThreadProxy.cpp — Threads IsSecureContext through WorkerParameters (and isolatedCopy()): dedicated workers inherit the creating context’s value, service workers are forced Yes, shared workers derive it from the client top origin’s trustworthiness.

Background

Secure context — An environment (document/worker) deemed safe enough to expose powerful APIs; requires the document and every ancestor to be potentially trustworthy per w3c.github.io/webappsec-secure-contexts.

Potentially trustworthy origin — An origin that is https/wss, localhost, or otherwise allow-listed; SecurityOrigin::isPotentiallyTrustworthy() is the predicate used throughout.

Site isolation / RemoteFrame — Cross-origin frames may run in a separate WebContent process; locally they appear as a RemoteFrame proxy whose document (and thus securityOrigin) is not materialized in this process.

DocumentSecurityPolicy — A small value struct (COEP, COOP, and now IsSecureContext and agentClusterIdentifier) propagated to other processes so a frame can learn its cross-process parent’s security posture.

Worker secure context — A worker’s secure-context status must mirror its creating document; WorkerGlobalScope now carries a snapshot bit through WorkerParameters rather than re-deriving it.

Provisional frame — A frame not yet in the tree during a cross-process navigation; computeIsSecureContext() falls back to loader().client().provisionalParentFrame() to find a parent in that state.

Vulnerability window

  1. Setup — Attacker controls an insecure (http://) top-level or ancestor document that, under site isolation, is a RemoteFrame in the victim child’s process.
  2. Embed — A nested document is loaded such that its only insecure ancestor is behind the process boundary, so frameDocumentSecurityOrigin() is null for that ancestor.
  3. Trigger — The nested document calls a secure-context-gated API, invoking Document::isSecureContext().
  4. Bypass — isFrameDocumentSecure() returns true for the unavailable insecure ancestor, so the document is treated as secure and the gated API is exposed.
  5. Fix — computeIsSecureContext() reads the parent’s propagated policy and returns false when it is insecure or missing, closing the gate.

Proof of concept

No layout test ships in the diff, but it adds the test scaffolding (Internals::markContextAsInsecure + Document::markAsInsecureContextForTesting). Conceptual trigger (web path inferred): with site isolation enabled, load an insecure http:// document that embeds a cross-process child whose remaining ancestors are all remote; from the child evaluate a secure-context gate (e.g. ‘window.isSecureContext’ or attempt navigator.serviceWorker.register / crypto.subtle usage). Pre-patch the child reports/behaves as a secure context; post-patch it is insecure because the parent’s propagated DocumentSecurityPolicy.isSecureContext is No.

Exploitation

  1. Reach — Web content in a site-isolated child frame whose insecure ancestor is a RemoteFrame; reachable from any page an attacker can frame.
  2. Trigger — Invoke a secure-context-gated feature so Document::isSecureContext() runs and hits the null-remote-origin fall-open branch.
  3. Primitive — Primary effect: incorrect exposure of secure-context-only APIs to a context whose chain includes an insecure origin (capability/policy escalation).
  4. Escalation (inferred) — Open questions gating anything beyond capability bypass: which specific gated APIs become reachable and whether any of them (e.g. service worker registration scope, storage) can be leveraged for persistence or cross-origin data access; no memory-safety effect is implied by the diff.

Detection & hunting

For defenders and detection engineers:

  • isSecureContext mismatch across ancestry — Flag documents reporting isSecureContext=true while any ancestor URL is non-trustworthy, especially where an ancestor is a cross-process RemoteFrame.
  • Secure-context API use from http chains — Telemetry on WebCrypto subtle / service worker / gated storage usage where the committed ancestor chain contains an insecure origin.
  • Worker secure-context provenance — Audit WorkerGlobalScope secure-context values that disagree with their creating document’s committed policy.

Remaining risk in the patch

  • Default-open bit — m_isSecureContext defaults to true (bitfield ‘{ true }’); any path that queries isSecureContext() before initSecurityContext()/computeIsSecureContext() has set it would fail open to secure.
  • Initial about:blank policy is lossy — WebFrameProxy seeds the initial about:blank proxy with DocumentSecurityPolicy { { }, { }, creator->isSecureContext } — COEP/COOP are default-constructed, so only the secure-context bit is carried for that initial document.
  • Unverified fallback body — computeIsSecureContext() relies on loader().client().provisionalParentFrame() and the SharedWorker path derives the bit solely from clientOrigin.topOrigin trustworthiness; those bodies are not in the supplied diff, so the provisional and shared-worker chains are unverified.

Audit directions

  • Other !origin || trustworthy fall-opens — Grep for patterns like ‘!securityOrigin ||’ and ‘frameDocumentSecurityOrigin()’ uses that treat a null cross-process origin as safe.
  • Per-call vs per-navigation security determinations — Look for other security predicates recomputed by walking frames each call that could disagree across a process boundary (mixed content, COOP/COEP inheritance).
  • Initial about:blank / provisional frames — Verify every inherited-origin path (srcdoc, about:blank, data:) copies the parent’s isSecureContext, as initSecurityContext now does, and that the provisionalParentFrame fallback is always populated.
  • Default-true bit — Confirm no code path reads m_isSecureContext (default true) before computeIsSecureContext() runs during document setup.

Before / after

Loading diff…