CVE-2026-11309
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/web_tests/external/wpt/navigation-api/navigate-event/intercept-cross-origin-focus-without-user-activation.html |
modified | |
whilethird_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.ccthird_party/blink/web_tests/external/wpt/navigation-api/navigate-event/intercept-cross-origin-focus-without-user-activation.htmlthird_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing-user-initiated.htmlthird_party/blink/web_tests/external/wpt/navigation-api/navigate-event/resources/intercept-cross-origin-focus-stealing.html
Patch
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>
Regression Test / PoC
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>
Original Bug Report
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
- Run Chrome with
--enable-features=BlockingFocusWithoutUserActivation(or ensure the feature is enabled via an Origin Trial or standard rollout). - Create a main page (Origin A) that embeds an iframe from Origin B.
- Ensure the iframe at Origin B does not have the
focus-without-user-activationpermissions policy granted. - Inside the iframe (Origin B), insert an element with the
autofocusattribute (e.g.,<input autofocus>). - In the iframe, register a navigation listener that intercepts the navigation with an empty async handler:
navigation.onnavigate = e => e.intercept({handler: async()=>{}});. - Trigger a same-document navigation within the iframe via script:
navigation.navigate('#test');. - The navigation is intercepted, the promise resolves immediately, and
PotentiallyResetTheFocus()is called. - 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.