← WebKit Silent-Fix Report — 2026-W22

5021f999fe  Don't log interaction from temporary gesture on storage access rejection

severity medium class Bypass confidence 0.75 WebKit Storage Access / Tracking Prevention exploitable-grade
Charlie Wolfe Fri May 29 13:56:23 2026 -0700 full: 5021f999fefdad2d467accbaec86b73268b5942e bug report ↗ view on GitHub ↗
Primitive: silent storage-access grant without user gesture
Triage note: A rejected no-gesture storage-access request logged interaction that could silently grant cross-site cookie access, a tracking-prevention bypass.
Contents

The bug at a glance

OBSERVED: DocumentStorageAccess::enableTemporaryTimeUserGesture now constructs its temporary UserGestureIndicator with UserGestureType::ActivationTriggering and, critically, UserGestureIndicator::ProcessInteractionStyle::Never instead of the default Immediate. INFERRED: the no-gesture requestStorageAccess() rejection path installs a temporary gesture so callers keep gesture-gated abilities, but with Immediate it logged user interaction as a side effect; in a popup with an opener that fabricated interaction triggered requestStorageAccessUnderOpener(), silently granting cross-site cookie access with no prompt. Medium: a tracking-prevention / privacy bypass yielding cross-site storage access without consent.

A rejection path fabricated user interaction. To preserve gesture-gated capabilities on the no-gesture rejection branch, WebKit installs a short-lived UserGestureIndicator; but the default ProcessInteractionStyle::Immediate has a side effect of logging the domain as having had user interaction. That logged interaction is exactly the signal ITP uses to auto-grant storage access under an opener. So merely calling requestStorageAccess() without a gesture, and getting rejected, silently whitelisted the tracker.

Root cause

DocumentStorageAccess implements the Storage Access API. When requestStorageAccess() is called without a user gesture, WebKit rejects the promise but deliberately preserves a temporary user gesture so that the caller can still perform other gesture-gated actions (such as window.open) that the platform allows on this path. This is done via enableTemporaryTimeUserGesture(), which constructs an m_temporaryUserGesture UserGestureIndicator.

The pre-patch construction was makeUnique<UserGestureIndicator>(IsProcessingUserGesture::Yes, protect(m_document).ptr()). Relying on the default arguments, this created an ActivationTriggering-style gesture with ProcessInteractionStyle::Immediate. Immediate means the act of installing the indicator immediately logs user interaction for the document’s domain – it records, in the tracking-prevention store, that the user interacted with that origin.

That logged interaction is precisely the input WebKit’s Intelligent Tracking Prevention uses when deciding whether to auto-grant storage access. In a popup that has an opener, the presence of recorded user interaction on the third-party origin causes requestStorageAccessUnderOpener() to fire, which silently grants the popup’s cross-site iframe storage (cookie) access under the opener relationship – with no user prompt. So a no-gesture requestStorageAccess() call, even though the promise itself was rejected, laundered a fake interaction that flipped the origin from blocked to granted, defeating cookie blocking.

The fix passes explicit arguments: UserGestureType::ActivationTriggering and UserGestureIndicator::ProcessInteractionStyle::Never. Never suppresses the interaction-logging side effect while still installing a gesture, so gesture-gated actions remain possible but no fabricated interaction is recorded, and requestStorageAccessUnderOpener() is not spuriously triggered. OBSERVED: the added layout test opens a popup whose cross-site iframe calls requestStorageAccess() without a gesture, confirms the promise is rejected, then verifies the first-party cookie is NOT delivered afterward (storage access was not silently granted).

Key code

Suppress interaction logging on the temporary no-gesture indicator

void DocumentStorageAccess::enableTemporaryTimeUserGesture()
{
-    m_temporaryUserGesture = makeUnique<UserGestureIndicator>(IsProcessingUserGesture::Yes, protect(m_document).ptr());
+    m_temporaryUserGesture = makeUnique<UserGestureIndicator>(IsProcessingUserGesture::Yes, protect(m_document).ptr(), UserGestureType::ActivationTriggering, UserGestureIndicator::ProcessInteractionStyle::Never);
}

Patch walkthrough

  • Source/WebCore/dom/DocumentStorageAccess.cpp — enableTemporaryTimeUserGesture() changed its UserGestureIndicator construction from the default (IsProcessingUserGesture::Yes, document) – which implies ProcessInteractionStyle::Immediate – to explicitly pass UserGestureType::ActivationTriggering and UserGestureIndicator::ProcessInteractionStyle::Never. Never prevents the temporary gesture from logging user interaction, so the no-gesture rejection path no longer fabricates the interaction that ITP would treat as consent to grant storage access under an opener.
  • LayoutTests/http/tests/storageAccess/no-gesture-rejection-should-not-grant-storage-access-under-opener.https.html — Main test: marks a third-party origin prevalent with prior interaction, enables cookie blocking, opens a popup to that origin which embeds a cross-site iframe. The iframe calls requestStorageAccess() with no gesture; the test asserts the request is rejected and then that the first-party cookie is not received, i.e. access was not silently granted.
  • LayoutTests/http/tests/storageAccess/resources/request-storage-access-without-gesture-in-iframe.html / request-storage-access-without-gesture-and-report-back.html — Support frames: the iframe invokes document.requestStorageAccess() at load (no gesture) and posts ‘resolved’/‘rejected’ back up; the popup relays the result to the opener for assertion.

Background

Storage Access API — document.requestStorageAccess() lets an embedded cross-site frame request first-party cookie access. WebKit gates it on user activation and tracking-prevention state; the no-gesture path rejects but preserves a temporary gesture for other capabilities.

ProcessInteractionStyle::Immediate vs Never — A UserGestureIndicator option. Immediate logs user interaction with the document’s domain as a side effect of installing the gesture; Never installs the gesture without recording any interaction.

requestStorageAccessUnderOpener() — An ITP mechanism that auto-grants a popup’s cross-site frame storage access based on recorded user interaction with the origin under the opener relationship, without a prompt.

Intelligent Tracking Prevention (ITP) — WebKit’s tracking-prevention subsystem. Recorded user interaction with a prevalent domain is a key input for relaxing cookie blocking; a fabricated interaction therefore directly weakens the policy.

Vulnerability window

  1. Design — The no-gesture requestStorageAccess() rejection path installs a temporary UserGestureIndicator to keep gesture-gated actions (window.open) available.
  2. Side effect — Default ProcessInteractionStyle::Immediate caused that temporary gesture to log user interaction for the origin.
  3. Silent grant — In a popup with an opener, the fabricated interaction triggered requestStorageAccessUnderOpener(), granting cross-site cookie access with no prompt.
  4. Discovery — bug 309504 / rdar://171546420 traced the silent grant to the interaction logged by the temporary gesture.
  5. Fix — Construct the temporary gesture with ProcessInteractionStyle::Never so no interaction is logged; test confirms cookies are not delivered post-rejection.

Proof of concept

VERBATIM from request-storage-access-without-gesture-in-iframe.html. A cross-site iframe inside an opened popup calls document.requestStorageAccess() at load with no user gesture. The promise is rejected, but pre-patch the temporary Immediate-style gesture logged interaction, so requestStorageAccessUnderOpener() then silently granted first-party cookie access. The harness verifies the first-party cookie is NOT subsequently received.

function run() {
    document.requestStorageAccess().then(
        function() {
            window.parent.postMessage({
                type: "storage-access-result",
                result: "resolved",
                error: null
            }, "*");
        },
        function(e) {
            window.parent.postMessage({
                type: "storage-access-result",
                result: "rejected",
                error: e.toString()
            }, "*");
        }
    );
}
// body onload="run()" -- no user gesture

Exploitation

  1. Setup — Tracker origin is already prevalent/has prior interaction (as ITP records for common third parties); attacker opens a popup to that origin from the opener.
  2. Fabricate interaction — The popup’s cross-site iframe calls requestStorageAccess() without a gesture; pre-patch the rejection path’s Immediate gesture logs user interaction for the origin.
  3. Silent cross-site cookies — requestStorageAccessUnderOpener() auto-grants the frame first-party cookie access with no prompt, enabling cross-site tracking that tracking-prevention was meant to block.

Detection & hunting

For defenders and SOC / detection engineers:

  • Interaction logged on no-gesture rejection
  • Unprompted storage grant under opener

Audit directions

  • Temporary gesture constructions
  • Interaction-logging inputs to ITP
  • requestStorageAccessUnderOpener triggers

Before / after

Loading diff…