Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in History
DescriptionInsufficient policy enforcement in History
ComponentHistory
Bug ClassLogic Error
Tracker506392934
Fix commit337507076527 (chromium/src) +152/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/intercept-cross-origin-focus-without-user-activation.html
modified
while
third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing-user-initiated.html
modified

Files Changed

  • third_party/blink/renderer/core/navigation_api/navigate_event.cc
  • third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/intercept-cross-origin-focus-without-user-activation.html
  • third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing-user-initiated.html
  • third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing.html
From 337507076527a18f3cc6d645d5d02bd9477eeaaa Mon Sep 17 00:00:00 2001
From: Noam Rosenthal <nrosenthal@chromium.org>
Date: Wed, 29 Apr 2026 10:01:49 -0700
Subject: [PATCH] Use navigate event's is_user_initiated for activation check

This correctly propagates activation from the navigation to the focusing behavior.

Bug: 506392934
Change-Id: I16379e18a7e8376ff54acfaa62450b60805c23cc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7800163
Reviewed-by: Nate Chapin <japhet@chromium.org>
Commit-Queue: Noam Rosenthal <nrosenthal@google.com>
Cr-Commit-Position: refs/heads/main@{#1622537}
---

diff --git a/third_party/blink/renderer/core/navigation_api/navigate_event.cc b/third_party/blink/renderer/core/navigation_api/navigate_event.cc
index 2cfc826a..218e29ab 100644
--- a/third_party/blink/renderer/core/navigation_api/navigate_event.cc
+++ b/third_party/blink/renderer/core/navigation_api/navigate_event.cc
@@ -680,7 +680,8 @@
   }
 
   if (Element* focus_delegate = document->GetAutofocusDelegate()) {
-    focus_delegate->Focus(FocusParams(FocusTrigger::kUserGesture));
+    focus_delegate->Focus(FocusParams(
+        user_initiated_ ? FocusTrigger::kUserGesture : FocusTrigger::kScript));
   } else {
     document->ClearFocusedElement();
     document->SetSequentialFocusNavigationStartingPoint(nullptr);
diff --git a/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/intercept-cross-origin-focus-without-user-activation.html b/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/intercept-cross-origin-focus-without-user-activation.html
new file mode 100644
index 0000000..13e2e850
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/intercept-cross-origin-focus-without-user-activation.html
@@ -0,0 +1,86 @@
+<!doctype html>
+<title>
+  NavigateEvent: intercept() should not bypass focus-without-user-activation for
+  cross-origin iframes
+</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="/common/get-host-info.sub.js"></script>
+<body>
+  <input type="text" id="input" />
+  <script>
+    const wait_for_message = (expected_source) => {
+      return new Promise((resolve) => {
+        const handler = (e) => {
+          if (e.source === expected_source) {
+            window.removeEventListener("message", handler);
+            resolve(e.data);
+          }
+        };
+        window.addEventListener("message", handler);
+      });
+    };
+
+    // Test 1: Without user activation
+    promise_test(async (t) => {
+      const input = document.querySelector("#input");
+
+      const iframe = document.createElement("iframe");
+      // Explicitly deny focus-without-user-activation
+      iframe.allow = "focus-without-user-activation 'none'";
+      iframe.src = new URL(
+        "/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing.html",
+        get_host_info().REMOTE_ORIGIN,
+      );
+      document.body.appendChild(iframe);
+      input.focus();
+
+      t.add_cleanup(() => iframe.remove());
+
+      const msg = await wait_for_message(iframe.contentWindow);
+      assert_equals(msg, "done");
+
+      // Since the iframe is cross-origin and does not have focus-without-user-activation,
+      // the iframe's same-document navigation + intercept() should not steal focus from the top-level frame.
+      assert_equals(
+        document.activeElement,
+        input,
+        "Top-level document should not lose focus to the cross-origin iframe without user interaction.",
+      );
+    }, "Navigation API intercept() focus reset shouldn't bypass focus-without-user-activation permissions policy");
+
+    // Test 2: With user activation
+    promise_test(async (t) => {
+      const iframe = document.createElement("iframe");
+      iframe.style.width = "200px";
+      iframe.style.height = "200px";
+      // Explicitly deny focus-without-user-activation
+      iframe.allow = "focus-without-user-activation 'none'";
+      iframe.src = new URL(
+        "/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing-user-initiated.html",
+        get_host_info().REMOTE_ORIGIN,
+      );
+      document.body.appendChild(iframe);
+      input.focus();
+      t.add_cleanup(() => iframe.remove());
+
+      const ready_msg = await wait_for_message(iframe.contentWindow);
+      assert_equals(ready_msg, "ready");
+
+      // Click the iframe to grant user activation and trigger the navigation.
+      // The iframe contains a button covering the whole viewport that initiates the navigation.
+      await test_driver.click(iframe);
+      input.focus();
+
+      const focus_msg = await wait_for_message(iframe.contentWindow);
+      assert_equals(
+        focus_msg,
+        "focused",
+        "Cross-origin iframe should gain focus if the navigation was user-initiated.",
+      );
+      assert_equals(document.activeElement, iframe);
+    }, "Navigation API intercept() focus reset should work if navigation was user-initiated");
+  </script>
+</body>
diff --git a/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing-user-initiated.html b/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing-user-initiated.html
new file mode 100644
index 0000000..1d53756
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing-user-initiated.html
@@ -0,0 +1,46 @@
+<!doctype html>
+<script>
+  navigation.onnavigate = (e) => {
+    if (new URL(e.destination.url).hash !== "#nav") {
+      return;
+    }
+    // Delay the intercept handler by 5.5 seconds to allow the transient user activation
+    // (which lasts 5 seconds) to expire. This ensures that the focus reset relies on
+    // the navigation's user_initiated_ state rather than an active transient user activation.
+    e.intercept({
+      async handler() {
+        while (navigator.userActivation.isActive) {
+          await new Promise((resolve) => requestAnimationFrame(resolve));
+        }
+      },
+    });
+  };
+
+  navigation.addEventListener("navigatesuccess", () => {
+    parent.postMessage(
+      document.activeElement.id === "autofocus-input"
+        ? "focused"
+        : "not_focused",
+      "*",
+    );
+  });
+
+  onload = () => {
+    parent.postMessage("ready", "*");
+  };
+</script>
+<style>
+  html,
+  body {
+    margin: 0;
+    width: 100%;
+    height: 100%;
+  }
+  a {
+    display: block;
+    inset: 0;
+    position: absolute;
+  }
+</style>
+<a href="#nav">Click me</a>
+<input id="autofocus-input" autofocus />
diff --git a/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing.html b/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing.html
new file mode 100644
index 0000000..be9dc3a3
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing.html
@@ -0,0 +1,18 @@
+<!doctype html>
+<script>
+  navigation.onnavigate = e => {
+    e.intercept({ handler: async () => {} });
+  };
+
+  onload = async () => {
+    // We trigger a same-document navigation. This will be intercepted.
+    // The empty intercept handler resolves immediately, triggering focus reset.
+    // This should not focus the autofocus element if the frame lacks permission.
+    await navigation.navigate('#test').finished;
+    // Yield to the event loop to allow any asynchronous focus updates to propagate
+    requestAnimationFrame(() => {
+      parent.postMessage("done", "*");
+    });
+  };
+</script>
+<input id="autofocus-input" autofocus>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/intercept-cross-origin-focus-without-user-activation.html b/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/intercept-cross-origin-focus-without-user-activation.html
new file mode 100644
index 0000000..13e2e850
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/intercept-cross-origin-focus-without-user-activation.html
@@ -0,0 +1,86 @@
+<!doctype html>
+<title>
+  NavigateEvent: intercept() should not bypass focus-without-user-activation for
+  cross-origin iframes
+</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/resources/testdriver.js"></script>
+<script src="/resources/testdriver-vendor.js"></script>
+<script src="/common/get-host-info.sub.js"></script>
+<body>
+  <input type="text" id="input" />
+  <script>
+    const wait_for_message = (expected_source) => {
+      return new Promise((resolve) => {
+        const handler = (e) => {
+          if (e.source === expected_source) {
+            window.removeEventListener("message", handler);
+            resolve(e.data);
+          }
+        };
+        window.addEventListener("message", handler);
+      });
+    };
+
+    // Test 1: Without user activation
+    promise_test(async (t) => {
+      const input = document.querySelector("#input");
+
+      const iframe = document.createElement("iframe");
+      // Explicitly deny focus-without-user-activation
+      iframe.allow = "focus-without-user-activation 'none'";
+      iframe.src = new URL(
+        "/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing.html",
+        get_host_info().REMOTE_ORIGIN,
+      );
+      document.body.appendChild(iframe);
+      input.focus();
+
+      t.add_cleanup(() => iframe.remove());
+
+      const msg = await wait_for_message(iframe.contentWindow);
+      assert_equals(msg, "done");
+
+      // Since the iframe is cross-origin and does not have focus-without-user-activation,
+      // the iframe's same-document navigation + intercept() should not steal focus from the top-level frame.
+      assert_equals(
+        document.activeElement,
+        input,
+        "Top-level document should not lose focus to the cross-origin iframe without user interaction.",
+      );
+    }, "Navigation API intercept() focus reset shouldn't bypass focus-without-user-activation permissions policy");
+
+    // Test 2: With user activation
+    promise_test(async (t) => {
+      const iframe = document.createElement("iframe");
+      iframe.style.width = "200px";
+      iframe.style.height = "200px";
+      // Explicitly deny focus-without-user-activation
+      iframe.allow = "focus-without-user-activation 'none'";
+      iframe.src = new URL(
+        "/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing-user-initiated.html",
+        get_host_info().REMOTE_ORIGIN,
+      );
+      document.body.appendChild(iframe);
+      input.focus();
+      t.add_cleanup(() => iframe.remove());
+
+      const ready_msg = await wait_for_message(iframe.contentWindow);
+      assert_equals(ready_msg, "ready");
+
+      // Click the iframe to grant user activation and trigger the navigation.
+      // The iframe contains a button covering the whole viewport that initiates the navigation.
+      await test_driver.click(iframe);
+      input.focus();
+
+      const focus_msg = await wait_for_message(iframe.contentWindow);
+      assert_equals(
+        focus_msg,
+        "focused",
+        "Cross-origin iframe should gain focus if the navigation was user-initiated.",
+      );
+      assert_equals(document.activeElement, iframe);
+    }, "Navigation API intercept() focus reset should work if navigation was user-initiated");
+  </script>
+</body>
diff --git a/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing-user-initiated.html b/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing-user-initiated.html
new file mode 100644
index 0000000..1d53756
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing-user-initiated.html
@@ -0,0 +1,46 @@
+<!doctype html>
+<script>
+  navigation.onnavigate = (e) => {
+    if (new URL(e.destination.url).hash !== "#nav") {
+      return;
+    }
+    // Delay the intercept handler by 5.5 seconds to allow the transient user activation
+    // (which lasts 5 seconds) to expire. This ensures that the focus reset relies on
+    // the navigation's user_initiated_ state rather than an active transient user activation.
+    e.intercept({
+      async handler() {
+        while (navigator.userActivation.isActive) {
+          await new Promise((resolve) => requestAnimationFrame(resolve));
+        }
+      },
+    });
+  };
+
+  navigation.addEventListener("navigatesuccess", () => {
+    parent.postMessage(
+      document.activeElement.id === "autofocus-input"
+        ? "focused"
+        : "not_focused",
+      "*",
+    );
+  });
+
+  onload = () => {
+    parent.postMessage("ready", "*");
+  };
+</script>
+<style>
+  html,
+  body {
+    margin: 0;
+    width: 100%;
+    height: 100%;
+  }
+  a {
+    display: block;
+    inset: 0;
+    position: absolute;
+  }
+</style>
+<a href="#nav">Click me</a>
+<input id="autofocus-input" autofocus />
diff --git a/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing.html b/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing.html
new file mode 100644
index 0000000..be9dc3a3
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing.html
@@ -0,0 +1,18 @@
+<!doctype html>
+<script>
+  navigation.onnavigate = e => {
+    e.intercept({ handler: async () => {} });
+  };
+
+  onload = async () => {
+    // We trigger a same-document navigation. This will be intercepted.
+    // The empty intercept handler resolves immediately, triggering focus reset.
+    // This should not focus the autofocus element if the frame lacks permission.
+    await navigation.navigate('#test').finished;
+    // Yield to the event loop to allow any asynchronous focus updates to propagate
+    requestAnimationFrame(() => {
+      parent.postMessage("done", "*");
+    });
+  };
+</script>
+<input id="autofocus-input" autofocus>
Loading diff…

Original Bug Report

reported by vm...@google.com

Navigation API bypasses BlockingFocusWithoutUserActivation via hardcoded FocusTrigger::kUserGesture

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: When handling a navigate event with an empty intercept handler, the Navigation API potentially resets focus to an autofocus element. However, it hardcodes FocusTrigger::kUserGesture, bypassing the focus-without-user-activation permissions policy and enabling focus stealing by cross-origin iframes without user interaction.

Affected files:

  • third_party/blink/renderer/core/navigation_api/navigate_event.cc

Estimated timestamp from git blame: 2023-08-22

Description

A logic flaw exists in the Navigation API’s focus reset behavior that allows cross-origin iframes to bypass the focus-without-user-activation permissions policy.

When a script intercepts a navigation event (e.g., via navigation.onnavigate = e => e.intercept()), the Navigation API waits for the intercept handler’s promises to settle. Once settled, NavigateEvent::PotentiallyResetTheFocus() is invoked to manage focus transitions.

In third_party/blink/renderer/core/navigation_api/navigate_event.cc, if an autofocus delegate element is present, the function explicitly requests focus using a hardcoded FocusTrigger::kUserGesture:

  if (Element* focus_delegate = document->GetAutofocusDelegate()) {
    focus_delegate->Focus(FocusParams(FocusTrigger::kUserGesture));
  } else {
    // ...
  }

This misclassification is problematic because it bypasses the Document::IsFocusAllowed() security checks (in third_party/blink/renderer/core/dom/document.cc). IsFocusAllowed() explicitly permits any focus request flagged with kUserGesture and skips the permissions policy checks against network::mojom::PermissionsPolicyFeature::kFocusWithoutUserActivation. Consequently, a cross-origin iframe can steal focus from the parent document entirely via script, without requiring transient user activation or the appropriate permissions policy.

Potential Reproduction Steps

  1. Run Chrome with --enable-features=BlockingFocusWithoutUserActivation (or ensure the feature is enabled via an Origin Trial or standard rollout).
  2. Create a main page (Origin A) that embeds an iframe from Origin B.
  3. Ensure the iframe at Origin B does not have the focus-without-user-activation permissions policy granted.
  4. Inside the iframe (Origin B), insert an element with the autofocus attribute (e.g., <input autofocus>).
  5. In the iframe, register a navigation listener that intercepts the navigation with an empty async handler: navigation.onnavigate = e => e.intercept({handler: async()=>{}});.
  6. Trigger a same-document navigation within the iframe via script: navigation.navigate('#test');.
  7. The navigation is intercepted, the promise resolves immediately, and PotentiallyResetTheFocus() is called.
  8. Due to the hardcoded kUserGesture, the iframe successfully pulls focus to its input element, stealing focus from the parent document without user interaction.

Note: These are potential steps based on code analysis; a working proof-of-concept has not been verified in a live environment.

Suggested Fix

NavigateEvent::PotentiallyResetTheFocus() should not unconditionally use FocusTrigger::kUserGesture. Instead, it should determine the trigger based on whether the navigation itself was user-initiated.

The NavigateEvent object possesses a user_initiated_ boolean. The focus call should be updated to use FocusTrigger::kUserGesture only if user_initiated_ is true, and fall back to FocusTrigger::kScript otherwise. Additionally, ensuring that the correct initiator context is provided in the FocusParams might be necessary to enforce the policy against the correct originating frame.

Evaluated with Chrome root at commit: a1e33f5848218e21d4a16ae2c1bc94e815c30c7f


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker