db89016bcf994511e112e3a12c158b6d5b47ebc7 Separate same-origin documents with a different Document-Isolation-Policy into different agent clusters
Triage note: JSDOMBindingSecurity (canAccessTargetOrigin/canAccessFrame/canAccessDocument/shouldAllowAccessToFrame), ScriptController::canAccessFromCurrentOrigin/executeJavaScriptURL and deriveShadowRealmGlobalObject are changed from SecurityOrigin::isSameOriginDomain to isPlatformObjectSameOrigin(origin, agentClusterIdentifier, ...). This enforces the HTML agent-cluster requirement so documents that are same-origin-domain but in different agent clusters (e.g. Origin-Agent-Cluster keyed) can no longer synchronously access each other — a same-origin-policy/isolation bypass reachable from web content.
The bug at a glance
WebCore’s binding-layer DOM-access checks used SecurityOrigin::isSameOriginDomain, which ignores agent clusters, so two documents that are same-origin-domain but in different agent clusters (e.g. one keyed by Origin-Agent-Cluster or Document-Isolation-Policy and one site-keyed) could synchronously reach each other’s JS objects and run script in each other. This is a same-origin-policy/agent-cluster isolation bypass reachable from web content in the WebContent process; triage severity high is defensible as an isolation-boundary break, though it is a cross-document access bypass, not memory corruption or RCE. The guarantee being broken is the agent-cluster isolation that OAC/DIP promise.
Every synchronous cross-document access funnels through a handful of binding helpers (canAccessTargetOrigin, canAccessFrame/Document, deriveShadowRealmGlobalObject, ScriptController), and all of them tested only origin, never agent cluster — so opting a document into origin-keyed/cross-origin isolation did not actually stop a same-origin peer from scripting it.
Root cause
The violated invariant is HTML’s ‘is platform object same origin’, which requires BOTH same origin-domain AND same agent cluster before one document may reach another’s platform objects or run script in it. Pre-patch the checks used only protect(origin)->isSameOriginDomain(targetOrigin). The patch introduces, in SecurityOrigin.h, isPlatformObjectSameOrigin(origin, agentClusterIdentifier, targetOrigin, targetAgentClusterIdentifier) = origin.isSameOriginDomain(targetOrigin) && canAccessAgentCluster(…), where canAccessAgentCluster = origin.hasUniversalAccess() || mayBeInSameAgentCluster(a, b) and mayBeInSameAgentCluster(a, b) = !a || !b || *a == *b.
The call sites rewired from isSameOriginDomain to isPlatformObjectSameOrigin are: JSDOMBindingSecurity::canAccessTargetOrigin (gained a targetAgentClusterIdentifier parameter) and its callers canAccessFrame (via targetFrame->frameAgentClusterIdentifier()) and canAccessDocument (via targetDocument->agentClusterIdentifier()); JSDOMGlobalObject::deriveShadowRealmGlobalObject (walks parent documents while same cluster); ScriptController::canAccessFromCurrentOrigin and executeJavaScriptURL (javascript: URL requester check); and DOMWindow::crossDomainAccessErrorMessage / isInsecureScriptAccess. Navigation::innerDispatchNavigateEvent nulls updatedSourceElement unless the source is platform-object-same-origin. DocumentWriter::begin now also requires canAccessAgentCluster() before reusing the default view.
To make this meaningful cross-process, the agent cluster is assigned authoritatively by the UIProcess: a new WebCore::AgentClusterIdentifier (ObjectIdentifier) and AgentClusterAssignment struct are added; BrowsingContextGroup::assignAgentCluster() computes the identifier from a site/origin/cross-origin-isolation-key tuple (origin-keyed when DIP != None or OAC), and it is propagated via DocumentSecurityPolicy, PolicyDecision/LoadParameters/GoToBackForwardItemParameters serialization, NavigationRequester, and inherited by origin-inheriting documents (about:blank in initSecurityContext, DocumentWriter, XSLTProcessor result docs). Document gains Markable m_agentClusterIdentifier and agentClusterID() is rewritten to emit ‘agent-cluster-’ (or ‘opaque-…’/Site) instead of the old browsingContextGroup/coi/oac string encoding; originAgentCluster() now also returns true whenever documentIsolationPolicy() != None. Frame gains a virtual frameAgentClusterIdentifier() implemented by LocalFrame (from its document) and RemoteFrame (from the synced frameDocumentSecurityPolicy). These are all value identifiers/ObjectIdentifiers; there is no lifetime bug, matching the CrossOrigin bugclass.
Key code
New combined same-origin + agent-cluster predicate and a rewired binding check
// SecurityOrigin.h
inline bool canAccessAgentCluster(const SecurityOrigin& origin, std::optional<AgentClusterIdentifier> a, std::optional<AgentClusterIdentifier> target)
{
return origin.hasUniversalAccess() || mayBeInSameAgentCluster(a, target);
}
inline bool isPlatformObjectSameOrigin(const SecurityOrigin& origin, std::optional<AgentClusterIdentifier> a, const SecurityOrigin& targetOrigin, std::optional<AgentClusterIdentifier> target)
{
return origin.isSameOriginDomain(targetOrigin) && canAccessAgentCluster(origin, a, target);
}
// JSDOMBindingSecurity canAccessTargetOrigin (was isSameOriginDomain only):
if (isPlatformObjectSameOrigin(protect(activeDocument->securityOrigin()), activeDocument->agentClusterIdentifier(), targetSecurityOrigin, targetAgentClusterIdentifier))
return true;
Fix approach
The patch threads an agent-cluster identity through every synchronous cross-document access check and conjoins it with the existing origin check. A UIProcess-assigned AgentClusterIdentifier (computed by BrowsingContextGroup::assignAgentCluster from a site/origin/cross-origin-isolation-key tuple, origin-keyed for OAC or any DIP) is propagated cross-process in DocumentSecurityPolicy/AgentClusterAssignment and inherited by origin-inheriting documents, then compared via isPlatformObjectSameOrigin = isSameOriginDomain && (hasUniversalAccess || same-or-unknown cluster). Semantically, agentClusterID() changes from a composed browsingContextGroup/coi/oac string to ‘agent-cluster-’, Page loses its browsingContextGroupIdentifier plumbing, and originAgentCluster() now reports true for any Document-Isolation-Policy. The hot binding path collapses to the single isPlatformObjectSameOrigin call, and remote frames currently satisfy it by syncing their cluster identifier (an explicit FIXME prefers making them inaccessible instead).
Patch walkthrough
Source/WebCore/page/SecurityOrigin.h— Adds canAccessAgentCluster() and isPlatformObjectSameOrigin(), the new combined origin+agent-cluster predicate used by all call sites.Source/WebCore/bindings/js/JSDOMBindingSecurity.cpp— canAccessTargetOrigin gains a targetAgentClusterIdentifier; canAccessFrame/canAccessDocument pass the target’s cluster id; adds the FIXME that remote-frame documents are really in another agent cluster.Source/WebCore/bindings/js/JSDOMGlobalObject.cpp— deriveShadowRealmGlobalObject walks up parent documents only while same origin AND same agent cluster.Source/WebCore/bindings/js/ScriptController.cpp— canAccessFromCurrentOrigin and the javascript: URL requester check in executeJavaScriptURL use isPlatformObjectSameOrigin.Source/WebCore/page/DOMWindow.cpp— crossDomainAccessErrorMessage emits a new ‘different agent cluster’ message, and isInsecureScriptAccess uses the combined predicate.Source/WebCore/dom/Document.{cpp,h}— Adds Markable m_agentClusterIdentifier with accessors, inherits it for about:blank in initSecurityContext, rewrites agentClusterID() to use the identifier, and makes originAgentCluster() true for any DIP != None.Source/WebCore/page/Frame.h, LocalFrame.{cpp,h}, RemoteFrame.{cpp,h}— Adds the virtual frameAgentClusterIdentifier(): LocalFrame reads its document; RemoteFrame reads the synced frameDocumentSecurityPolicy.agentClusterIdentifier.Source/WebCore/dom/DocumentSecurityPolicy.h, loader/NavigationRequester.{cpp,h}, loader/DocumentWriter.cpp, xml/XSLTProcessor.cpp— Carries/inherits the agent cluster id across security-policy propagation, navigation requester, default-view reuse (canAccessAgentCluster), and XSLT result documents.Source/WebCore/page/AgentClusterIdentifier.h, AgentClusterAssignment.h (new)— Define the ObjectIdentifier-based AgentClusterIdentifier, mayBeInSameAgentCluster(), and the UIProcess AgentClusterAssignment struct (identifier + isOriginKeyed + DocumentIsolationPolicy).Source/WebCore/loader/DocumentIsolationPolicy.{cpp,h}, FrameLoader.cpp, DocumentLoader.h— obtainDocumentIsolationPolicy loses its ScriptExecutionContext arg and is exported; DocumentLoader stores an AgentClusterAssignment (replacing m_isOriginKeyedFromUIProcess) that FrameLoader applies at didBeginDocument.Source/WebKit/UIProcess/BrowsingContextGroup.{cpp,h}, APINavigation.{cpp,h}, Shared/*serialization.in, Scripts/webkit/messages.py— Adds AgentClusterRequest and assignAgentCluster() in the UIProcess, stores the assignment on API::Navigation, registers AgentClusterIdentifier as a serialized identifier, and plumbs AgentClusterAssignment through PolicyDecision/LoadParameters/GoToBackForwardItemParameters; Page/PageConfiguration drop browsingContextGroupIdentifier.
Background
Agent cluster — HTML’s unit of synchronous same-origin scripting; two documents may reach each other’s platform objects only if they are in the same agent cluster, not merely same origin-domain.
Origin-Agent-Cluster (OAC) — A response header that requests an origin-keyed agent cluster, isolating a document from same-site but cross-origin documents (and, here, from same-origin site-keyed peers).
Document-Isolation-Policy (DIP) / cross-origin isolation — A policy that forces an origin-keyed, cross-origin-isolated agent cluster; after this patch originAgentCluster() is true for any DIP, and the cluster key includes a cross-origin-isolation key.
isSameOriginDomain vs isPlatformObjectSameOrigin — The former compares only origins (with document.domain relaxation); the latter is isSameOriginDomain AND same-or-unknown agent cluster (or universal access).
AgentClusterIdentifier — A UIProcess-assigned ObjectIdentifier; equality means same agent cluster. mayBeInSameAgentCluster treats a missing identifier on either side as ‘maybe same’ for WebKitLegacy/unassigned cases.
ShadowRealm global object derivation — deriveShadowRealmGlobalObject climbs to the top same-origin same-cluster document to pick the realm’s global; mis-scoping it could cross an isolation boundary.
Vulnerability window
- Setup — Attacker serves two same-origin documents where one opts into origin-keying (Origin-Agent-Cluster or Document-Isolation-Policy) and the other does not, placing them in different agent clusters.
- Frame — The two documents are brought into scripting range (e.g. embedded iframes or opener/opened windows).
- Trigger — One document synchronously accesses the other’s window/document object or runs a javascript: URL against it.
- Bypass — Pre-patch the isSameOriginDomain-only check passes, letting a document in one agent cluster script a document in another, defeating OAC/DIP isolation.
- Fix — isPlatformObjectSameOrigin additionally requires the same agent cluster, so the cross-cluster access is denied with a ‘different agent cluster’ SecurityError.
Proof of concept
No layout test ships in the diff. Conceptual trigger (web path reasonably derivable): host https://example/a served with ‘Origin-Agent-Cluster: ?1’ and https://example/b without it (or with a differing Document-Isolation-Policy), each in its own iframe of a common parent. From b attempt synchronous access such as frames[0].contentWindow.document.body or set frames[0].contentWindow.x. Pre-patch the access succeeds because both are same-origin-domain; post-patch it throws a SecurityError because their AgentClusterIdentifiers differ.
Exploitation
- Reach — Reachable from web content that can place two same-origin documents with differing agent-cluster keying into scripting range.
- Trigger — Perform a synchronous cross-document access (property get/set, document access, javascript: URL, ShadowRealm derivation) across the cluster boundary.
- Primitive — Primary: read/write access to another agent cluster’s JS/DOM objects that OAC/DIP were meant to isolate; secondary: script execution in the target’s realm via javascript: URLs or default-view reuse.
- Escalation (inferred) — Open questions: whether a victim relies on OAC/DIP to isolate secrets reachable this way, and (per the in-code FIXME) whether the cross-process remote-frame case is fully closed given the identifier is synced rather than remote frames being made inaccessible; no memory-safety escalation is implied.
Detection & hunting
For defenders and detection engineers:
- Cross-cluster same-origin access — Instrument the binding checks to log when isSameOriginDomain passes but canAccessAgentCluster fails; pre-patch such accesses were silently allowed.
- OAC/DIP documents being scripted — Monitor documents that set Origin-Agent-Cluster or Document-Isolation-Policy for synchronous access from other agent clusters.
- Unassigned agent cluster ids — Flag documents whose m_agentClusterIdentifier is unset in a site-isolated/UIProcess-assigned context, since mayBeInSameAgentCluster falls open for them.
Remaining risk in the patch
- Fail-open on unknown cluster — mayBeInSameAgentCluster returns true when either AgentClusterIdentifier is nullopt (!a || !b), so any document whose identifier was never assigned (WebKitLegacy, opaque origins per the FIXME, or any un-propagated path) reverts to same-origin-domain-only behavior and is not isolated.
- Remote frames sync rather than isolate — The explicit FIXME states that under site isolation a remote frame’s document is really in another agent cluster, yet the fix syncs its agentClusterIdentifier across processes instead of making remote frames inaccessible, so the cross-process boundary is enforced by propagation correctness, not by construction.
- Default-unset document id and truncated UIProcess body — Document::m_agentClusterIdentifier is a default-empty Markable, so a document whose init did not call setAgentClusterIdentifier compares as ‘maybe same cluster’; and the supplied BrowsingContextGroup.h diff is truncated at the private section, leaving the keymap/assignment internals partly unverified.
Audit directions
- Residual isSameOriginDomain call sites — Grep for remaining isSameOriginDomain uses that gate object access or script execution and should also require the agent cluster.
- Remote-frame accessibility — Follow the FIXME: with site isolation a remote frame’s document is in another agent cluster, so verify whether syncing the identifier is correct or remote frames should be made inaccessible.
- Agent-cluster assignment coverage — Audit BrowsingContextGroup::assignAgentCluster for opaque origins (the FIXME notes they get no identifier) and all inheritance paths (about:blank, srcdoc, data:, XSLT) for correct id propagation.
- nullopt fall-open — Review every path that can leave m_agentClusterIdentifier unset, since mayBeInSameAgentCluster returns true when either side is nullopt.