81d9a71bf3fe87ceee2879ae834a005b181ab861 REGRESSION(316338@main): 'Copy to clipboard' button in Bitbucket does not always copy the first time
Triage note: Introduces UserGestureTokenIdentifier-tracked forced activations that are revoked when the forcing gesture ends, and adds WebPageProxy::validatedUserActivationSenderProcess which rejects a DidNotifyUserActivation/DidRevokeForcedUserActivation whose forcedActivationToken->processIdentifier() != the sender process. Under site isolation this stops a web process from forging user-activation state in other processes/frames.
The bug at a glance
A site-isolation PolicyBypass: user-activation state is propagated between WebContent processes through the UIProcess (DidNotifyUserActivation / UpdateUserActivationState), and the new concept of a “forced” activation (injected by the embedder via evaluateJavaScript: with forceUserGesture) is carried by a UserGestureTokenIdentifier that embeds the originating process id. Pre-patch there was no per-token identity and no revocation across processes, and the UIProcess did not bind an activation/revocation message to the process that owns the token. The fix adds WebPageProxy::validatedUserActivationSenderProcess, which rejects any DidNotifyUserActivation / DidRevokeForcedUserActivation whose forcedActivationToken->processIdentifier() does not equal the sender’s core process id, stopping a compromised web process from forging or revoking forced user-activation in other processes/frames. Medium severity: this gates activation-dependent capabilities (popups, fullscreen, autoplay, navigations) across origins, not memory safety; impact is spoofed/stolen transient activation in cross-origin frames, not RCE.
Transient activation that the app “forces” into a page has to be taken back when the forcing gesture ends — so the fix gives each forced activation a process-stamped token, and the interesting hook is that the UIProcess now refuses to relay a token whose stamped process id does not match the sender.
Root cause
The invariant: an activation-state message crossing the site-isolation boundary must be attributable to the process that legitimately owns the activation, and a forced activation must be revocable exactly when the gesture that forced it is destroyed. Pre-patch, a forced gesture (ScriptController::executeScriptInWorld with forceUserGesture==Yes and removeTransientActivation==Yes) installed a destruction observer that called window->consumeTransientActivation() for each impacted document when the UserGestureToken died; cross-process, notifyActivated simply sent frame->loader().client().didNotifyUserActivation(activationTime) with only a timestamp, and the UIProcess relayed UpdateUserActivationState(frameIDs, activationTime) to the other processes. There was no token identity, so a forced activation injected in one process could propagate to cross-origin frames in other processes and was not individually revoked when the gesture ended — and nothing bound the (new) notion of a forced token to its originator.
The patch reshapes this into a tracked, process-stamped model. UserGestureToken gains a const m_identifier (UserGestureTokenIdentifier::generate()) and remembers which windows/frames it force-activated (m_windowsWithForcedActivation, m_frameWithForcedActivationInOtherProcesses). LocalDOMWindow stores a Vector{grantingToken, timestamp}; lastActivationTimestamp() now folds in the max forced timestamp, and ~UserGestureToken revokes them (window->revokeForcedActivation(identifier()) and, cross-process, frame->loader().client().didRevokeForcedUserActivation(identifier())). The new DidRevokeForcedUserActivation IPC and the augmented DidNotifyUserActivation now carry std::optional forcedActivationToken.
The security gate is WebPageProxy::validatedUserActivationSenderProcess(connection, sourceFrameID, forcedActivationToken): it still requires sourceFrame->process() == senderProcess, and additionally, if a forcedActivationToken is present, requires forcedActivationToken->processIdentifier() != senderProcess->coreProcessIdentifier() to fail. Because UserGestureTokenIdentifier is a process-scoped identifier whose processIdentifier() names its minting process, a compromised WebContent process can no longer send an UpdateUserActivationState/revoke carrying a token stamped with another process’s id — the UIProcess drops it before forwarding to forEachWebContentProcess. Pre-patch chain that is now blocked: 1. compromised process picks a forcedActivationToken value naming a victim process; 2. sends DidNotifyUserActivation or DidRevokeForcedUserActivation with it; 3. UIProcess forwards UpdateUserActivationState/RevokeForcedUserActivation to the victim frame’s process; 4. the victim cross-origin frame gains or loses forced transient activation it never legitimately held. All objects here are RefCounted/WeakPtr (UserGestureToken is CrossTaskToken-derived, windows/frames tracked via WeakHashSet/WeakPtr); this is a provenance/authorization fix, not a lifetime fix.
Key code
UIProcess binds the forced-activation token’s process id to the sender
RefPtr<WebProcessProxy> WebPageProxy::validatedUserActivationSenderProcess(IPC::Connection& connection, FrameIdentifier sourceFrameID, std::optional<UserGestureTokenIdentifier> forcedActivationToken)
{
Ref senderProcess = WebProcessProxy::fromConnection(connection);
RefPtr sourceFrame = WebFrameProxy::webFrame(sourceFrameID);
if (!sourceFrame)
return nullptr;
if (&sourceFrame->process() != senderProcess.ptr())
return nullptr;
if (forcedActivationToken && forcedActivationToken->processIdentifier() != senderProcess->coreProcessIdentifier())
return nullptr;
return senderProcess;
}
Fix approach
The fix replaces the timestamp-only, destruction-observer model with a first-class forced-activation record keyed by a process-stamped UserGestureTokenIdentifier. Each LocalDOMWindow keeps a Vector and derives its effective lastActivationTimestamp() from m_lastActivationTimestamp plus the latest live forced entry; when the forcing UserGestureToken is destroyed, its destructor revokes the matching entry locally (revokeForcedActivation) and, for cross-process frames, emits DidRevokeForcedUserActivation. On the authority side, both the notify and revoke paths funnel through validatedUserActivationSenderProcess, which collapses the hot path to: accept only if the source frame belongs to the sender and (for forced tokens) the token’s embedded process id equals the sender’s. updateActivation also self-heals, dropping expired forced entries if a revoke was lost (e.g. process death). Net: a web process can grant/revoke forced activation only for tokens it actually minted, and forced activation is bounded by the gesture’s lifetime rather than persisting after it.
Patch walkthrough
Source/WebCore/bindings/js/ScriptController.cpp— Stops hand-rolling a destruction observer that consumed transient activation; instead passes removeTransientActivation into the UserGestureIndicator so the token itself carries the forced-and-revocable semantics.Source/WebCore/dom/UserGestureIndicator.cpp/.h— Adds m_identifier, removesTransientActivation, didGrantForcedActivation(window)/didGrantForcedActivationInOtherProcesses(frame), and revocation in ~UserGestureToken; threads RemoveTransientActivation through the indicator constructors.Source/WebCore/page/LocalDOMWindow.cpp/.h— Introduces ForcedActivation records, updateActivation(time, optional token), revokeForcedActivation(token), and a lastActivationTimestamp() that folds forced entries; notifyActivated now takes a UserGestureToken& and routes forced vs normal activation.Source/WebCore/loader/FrameLoaderClient.h + WebKit Web*FrameLoaderClient/WebRemoteFrameClient— Extends didNotifyUserActivation to carry the optional token and adds didRevokeForcedUserActivation across the client hierarchy.Source/WebKit/UIProcess/WebPageProxy.cpp/.h/.messages.in— Adds validatedUserActivationSenderProcess and the DidRevokeForcedUserActivation message; both notify and revoke now validate the token’s process id against the sender before forwarding UpdateUserActivationState/RevokeForcedUserActivation.Source/WebKit/WebProcess/WebPage/WebPage.cpp/.h/.messages.in— Adds revokeForcedUserActivation and threads forcedActivationToken through updateUserActivationState so receiving processes apply/revoke the correct forced entry.Source/WebKit/WebProcess/WebProcess.cpp— userGestureTokenIdentifier now reuses token->identifier() instead of generating a fresh id, so the token’s process-stamped identity is authoritative.
Background
Transient / forced user activation — Transient activation is the short-lived “user just interacted” state that gates popups, fullscreen, autoplay, downloads and some navigations; a forced activation is one the embedder injects programmatically (e.g. WKWebView evaluateJavaScript: with forceUserGesture) and must be withdrawn when that call’s gesture ends.
UserGestureToken / UserGestureTokenIdentifier — UserGestureToken (a JSC::CrossTaskToken) represents an active gesture; the new UserGestureTokenIdentifier is a process-scoped id whose processIdentifier() names the minting process, letting the UIProcess attribute a token to a process.
Site isolation / cross-process activation propagation — With site isolation, frames of different origins live in different WebContent processes; activating one frame must update sibling/ancestor frames elsewhere, so activation flows WebContent→UIProcess→other WebContent via DidNotifyUserActivation/UpdateUserActivationState.
coreProcessIdentifier() / WebProcessProxy — The UIProcess-side identity of a WebContent process; comparing a token’s embedded processIdentifier() to the sender’s coreProcessIdentifier() is the authorization check that a process only speaks for its own tokens.
MESSAGE_CHECK semantics for activation IPCs — These messages are [EnabledBy=SiteIsolationEnabled]; the UIProcess must treat sourceFrameID and the token as untrusted and verify both against the connection before relaying.
Vulnerability window
- Setup — Site isolation enabled; attacker controls a WebContent process that participates in cross-process activation messaging.
- Forge — It crafts a DidNotifyUserActivation or DidRevokeForcedUserActivation carrying a forcedActivationToken stamped with a victim process’s id.
- Relay (pre-patch) — UIProcess, lacking the token/process binding, forwards UpdateUserActivationState / RevokeForcedUserActivation to the victim frame’s process.
- Effect — A cross-origin victim frame gains spoofed transient activation (enabling an activation-gated action) or has legitimate forced activation revoked.
- Post-patch — validatedUserActivationSenderProcess drops the message because the token’s process id != sender, closing the forge/revoke path.
Proof of concept
No regression test is included in the diff. Conceptual trigger needs site isolation and a compromised/instrumented WebContent process that sends WebPageProxy::DidNotifyUserActivation or DidRevokeForcedUserActivation with a forcedActivationToken whose processIdentifier() names another process; observing the spoofed activation in a cross-origin frame confirms it. The web-reachable consequence (which activation-gated API is abused) is (inferred).
Exploitation
- Reach — Compromised WebContent process under site isolation (the model these [EnabledBy=SiteIsolationEnabled] messages exist for).
- Trigger — Emit a notify/revoke IPC with a forged forcedActivationToken naming a victim process id (pre-patch, unvalidated).
- Primitive — Inject or revoke forced transient activation in a cross-origin frame hosted by another process — i.e. forge the “user just interacted” state across the isolation boundary.
- Escalation — Chaining into an activation-gated capability (popup, fullscreen, autoplay, history/navigation action) in the victim frame; whether a given capability is reachable purely from spoofed activation gates escalation and is (inferred).
Detection & hunting
For defenders and detection engineers:
- Activation/revoke IPC with mismatched token process id — Log validatedUserActivationSenderProcess rejections where forcedActivationToken->processIdentifier() != sender coreProcessIdentifier().
- Forced-activation buildup — Watch m_forcedActivations growth in LocalDOMWindow and the expiry-sweep path in updateActivation for missing revokes (possible process death or forgery).
- DidNotifyUserActivation sourceFrame/process mismatch — Alert when sourceFrame->process() != sender, the existing-but-now-centralized check.
Remaining risk in the patch
- Forced activation still valid for its full transient window — Between grant and the token’s destruction, a legitimately-minted forced activation propagates to same-origin descendant and ancestor frames; the fix bounds provenance and lifetime but a compromised process can still forge activation for frames it legitimately hosts within its own origin.
- Expiry-sweep fallback — updateActivation relies on a time-based removeAllMatching to clean up forced entries when a DidRevokeForcedUserActivation is never delivered (e.g. process death); until transientActivationDuration elapses a stale forced entry can keep lastActivationTimestamp() live.
Audit directions
- Other [EnabledBy=SiteIsolationEnabled] WebPageProxy messages — Enumerate cross-process state-propagation IPCs (frame-tree sync, viewport, intersection observers) and verify each validates sourceFrameID against the sending process.
- Identifier types that embed a process id — Audit other *Identifier types used across processes to confirm the UIProcess checks the embedded process id matches the sender before trusting them.
- Lost-revoke / process-death handling — Review the updateActivation expiry sweep and WebProcess teardown to ensure forced activations cannot persist or be re-asserted after a process dies.