← WebKit Silent-Fix Report — 2026-W22

2050ddd586  postMessage can indefinitely extend the lifetime of a user gesture token

severity medium class Bypass confidence 0.60 WebCore LocalDOMWindow postMessage exploitable-grade
Ryosuke Niwa Fri May 29 10:55:05 2026 -0700 full: 2050ddd5864a197c88e1ddf4ccc430f6c2e5fe1e bug report ↗ view on GitHub ↗
Primitive: user-gesture token lifetime extension (duplicated guard)
Triage note: Part of the user-gesture-token expiry fix series preventing popup-block bypass, with an apparently duplicated guard line.
Contents

The bug at a glance

Medium. This is a policy/lifetime bug in user-gesture propagation across postMessage, not memory corruption. OBSERVED: the guard that nulls out an expired user-gesture token is the security-relevant change; failing to clear it lets a stale gesture token continue to authorize gesture-gated actions (notably window.open popups) long after the real user interaction ended. Impact is a popup-blocker / user-activation bypass, an annoyance-to-abuse class issue rather than RCE. OBSERVED: this commit adds two byte-identical copies of the expiry guard back-to-back in processPostMessage (the diff shows the same if-block inserted twice). The duplication is harmless functionally — the second check is a no-op once the first has nulled the token — but it is a redundant artifact of the branch cherry-pick/merge process (Originally-landed-as 305413.591 on the safari-7624 branch), not intentional double-guarding.

When a frame forwards a user-gesture token through postMessage, an already-expired token was not cleared, so the receiving context could keep exercising gesture-gated privileges (e.g. opening popups) indefinitely.

Root cause

WebKit tracks transient user activation with a UserGestureToken. Certain privileged operations — most visibly window.open() without triggering the popup blocker — are permitted only while a live user-gesture token is in scope. To let legitimate cross-frame flows work, postMessage forwards the sender’s active user-gesture token into the dispatch of the message event, so a handler reacting to a genuine click can still open a window.

UserGestureToken has a bounded validity window: hasExpired(UserGestureToken::maximumIntervalForUserGestureForwarding) reports whether the token is older than the maximum interval allowed for forwarding. The intent is that a gesture can only be forwarded for a short time after the real interaction. In LocalDOMWindow::processPostMessage, the code captures userGestureToForward and is supposed to drop it (set it to nullptr) if it has already expired, before constructing the UserGestureIndicator that installs it as the active token for the duration of the event dispatch.

The bug being addressed by this series is that the expiry check was missing or incomplete, so an expired token could still be installed via UserGestureIndicator userGestureIndicator(userGestureToForward). Because postMessage can be pumped repeatedly (a page can post messages to itself or a child frame on a timer), a page could keep re-forwarding the same original gesture token across an unbounded number of message dispatches, effectively extending the lifetime of a single real user gesture indefinitely and using it to bypass the popup blocker well after the interaction.

The fix ensures that immediately before the token is installed, if userGestureToForward && userGestureToForward->hasExpired(…), it is reset to nullptr, so an expired gesture cannot authorize anything.

OBSERVED: this commit adds two byte-identical copies of the expiry guard back-to-back in processPostMessage (the diff shows the same if-block inserted twice). The duplication is harmless functionally — the second check is a no-op once the first has nulled the token — but it is a redundant artifact of the branch cherry-pick/merge process (Originally-landed-as 305413.591 on the safari-7624 branch), not intentional double-guarding.

Key code

processPostMessage — clear the token when the forwarded gesture has expired

+        if (userGestureToForward && userGestureToForward->hasExpired(UserGestureToken::maximumIntervalForUserGestureForwarding))
+            userGestureToForward = nullptr;
+

Patch walkthrough

  • Source/WebCore/page/LocalDOMWindow.cpp — In LocalDOMWindow::processPostMessage, an expiry guard is inserted so userGestureToForward is set to nullptr when hasExpired(UserGestureToken::maximumIntervalForUserGestureForwarding) is true, before the UserGestureIndicator is constructed. OBSERVED: this commit adds two byte-identical copies of the expiry guard back-to-back in processPostMessage (the diff shows the same if-block inserted twice). The duplication is harmless functionally — the second check is a no-op once the first has nulled the token — but it is a redundant artifact of the branch cherry-pick/merge process (Originally-landed-as 305413.591 on the safari-7624 branch), not intentional double-guarding.

Background

UserGestureToken — Object representing transient user activation; its presence in scope authorizes gesture-gated operations like non-blocked popups and certain permission prompts.

maximumIntervalForUserGestureForwarding — A UserGestureToken static bound limiting how long after the original interaction a gesture may be forwarded; hasExpired() compares against it.

UserGestureIndicator — RAII object that installs a given UserGestureToken as the active token for the current scope; if handed an expired token it would wrongly re-grant activation.

processPostMessage — LocalDOMWindow routine that dispatches a posted message event, forwarding the sender’s user-gesture token to the handler’s execution context.

User-activation / popup-blocker bypass — Retaining a live gesture token beyond its interval lets script open windows or take other gated actions without a fresh user interaction.

Vulnerability window

  1. Original — postMessage forwards the sender’s user-gesture token to authorize gesture-gated handler behavior.
  2. Flaw — An expired token is not cleared before UserGestureIndicator installs it, so stale activation is honored.
  3. Abuse — A page re-posts messages to keep re-forwarding one original gesture, extending activation indefinitely to bypass the popup blocker.
  4. Branch fix — Originally landed on the safari-7624 branch (305413.591, cb335a3aea4b) clearing the expired token.
  5. Mainline (313912@main / 314155@main) — The same expiry-clearing guard is applied on main.

Triggering

No test is included in this diff. Trigger concept: obtain a user gesture (click), then repeatedly call postMessage in a loop/timer so the gesture token is re-forwarded on each dispatch; after maximumIntervalForUserGestureForwarding elapses, attempt window.open() from a message handler — pre-fix the popup opens because the expired token was still installed; post-fix it is blocked.

Exploitation

  1. Seed gesture — Capture one legitimate user activation via a click or key event.
  2. Re-forward loop — Post messages between windows/frames repeatedly so the same gesture token is passed into each event dispatch.
  3. Gated action after expiry — Invoke window.open() (or other gesture-gated APIs) past the forwarding interval; the un-cleared expired token authorizes it, defeating the popup blocker.

Detection & hunting

For defenders and SOC / detection engineers:

  • Popups opened long after last interaction
  • UserGestureToken age at UserGestureIndicator construction

Audit directions

  • All UserGestureToken forwarding paths
  • hasExpired call sites
  • Duplicate/merged guards

Before / after

Loading diff…