CVE-2026-5903
Overview
Background
- iframe sandbox / `allow-popups`
- The
sandboxattribute restricts an iframe’s capabilities; without theallow-popupstoken the frame is forbidden from opening new browsing contexts (popups/new tabs). - Emulated Ctrl+click
- A synthetic
MouseEventcreated in script withctrlKey/metaKeyset and dispatched on a link, which the platform would otherwise treat as a request to open the link in a new tab. - `TriggeringEventInfo::kFromUntrustedEvent`
- The marker distinguishing a navigation triggered by a script-synthesized (untrusted) DOM event from one triggered by a genuine user gesture.
- `FrameLoader::StartNavigation`
- The Blink entry point (per the commit subject) that decides whether and how a frame-initiated navigation proceeds, including whether it may open a new browsing context.
Root Cause Analysis
A sandboxed iframe loaded without allow-popups is supposed to be unable to open a new browsing context, but the navigation path did not reject new-context navigations whose triggering event was a script-synthesized “Ctrl+click.” Script inside such a frame could create a MouseEvent with ctrlKey/metaKey set and dispatch it on an anchor, and the resulting navigation request carried a non-kNavigationPolicyCurrentTab policy (open in new tab/window) without the popup-sandbox restriction being enforced at that point.
The fix adds an early guard in FrameLoader::StartNavigation: if the navigation policy is not kNavigationPolicyCurrentTab, the triggering event is TriggeringEventInfo::kFromUntrustedEvent, and the frame IsSandboxed(WebSandboxFlags::kPopups), it returns before the navigation proceeds. This blocks the emulated-gesture popup for popup-restricted sandboxed frames while leaving genuine user gestures and current-tab navigations unaffected. The regression test SandboxedIframePopupCtrlClick confirms the dispatched synthetic Ctrl+click never reaches BeginNavigation (the count stays at 1).
allow-popups could emulate a user gesture to open a new browsing context; the fix adds a guard in FrameLoader::StartNavigation that drops untrusted-event, non-current-tab navigations when the frame is popup-sandboxed.Attack Path
- Embed a popup-restricted sandboxed iframe
A page includes an iframe with
sandbox="allow-scripts"(noallow-popups), for example viasrcdoc, so the frame may run script but should not open popups. - Synthesize a Ctrl/Meta click
Script in the frame creates a
MouseEventwithctrlKey/metaKeyset and dispatches it on an anchor pointing at a URL (the PoC usesabout:blank). - Navigation requests a new context (pre-patch)
The synthetic click produces a navigation with a non-current-tab policy and
kFromUntrustedEvent, which pre-patch was allowed to proceed toward opening a new browsing context despite the missingallow-popups. - Sandbox policy bypass
A new top-level browsing context is created from a frame that should be barred from popups; any sandbox restrictions that the new context thereby escapes are
(inferred)beyond the popup creation the patch directly addresses.
Impact Assessment
allow-popups to open a new browsing context by emulating a Ctrl/Meta click in JavaScript, bypassing the iframe-sandbox popup policy in the renderer. This is reachable by ordinary web content in a stock build with no special flag, extension, or user gesture required; the vendor advisory classifies the component as IFrameSandbox with impact “Policy bypass in IFrameSandbox” at low severity, and any broader sandbox escape beyond the popup-creation bypass is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/frame/web_frame_test.cc |
modified | |
IframeBeginNavivationCountTestWebFrameClientthird_party/blink/renderer/core/frame/web_frame_test.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/frame/web_frame_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/frame/frame_test_helpers.hthird_party/blink/renderer/core/frame/web_frame_test.ccthird_party/blink/renderer/core/loader/frame_loader.ccthird_party/blink/renderer/core/testing/data/core_test_bundle_data.filelistthird_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html
Audit Directions
- Trusted vs. untrusted event gatingAudit navigation/window-open and other gesture-gated capabilities for whether they distinguish
kFromUntrustedEvent(script-synthesized) from genuine user activation before granting the capability. - Sandbox-flag enforcement pointsReview all code paths that can create a new browsing context (middle-click, Ctrl/Meta-click,
window.open, formtarget) to confirm each consultsIsSandboxed(kPopups)rather than relying on one chokepoint. - Synthetic modifier-key eventsSearch for places where modifier flags on a
MouseEventchange navigation disposition, ensuring synthesized events cannot elevate a same-tab navigation into a new-context one inside restricted frames.
Patch
From 874355605d632c59770046b1a1fc1582f68361dc Mon Sep 17 00:00:00 2001 From: Nate Chapin <[email protected]> Date: Fri, 20 Feb 2026 11:02:15 -0800 Subject: [PATCH] FrameLoader::StartNavigation should not allow emulated Ctrl+click from sandboxed iframes Fixed: 483771899 Change-Id: I1e134f0c9dcfbb4760d339d909a7d7339a5e3077 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7572487 Commit-Queue: Nate Chapin <[email protected]> Reviewed-by: Daniel Cheng <[email protected]> Cr-Commit-Position: refs/heads/main@{#1587969} --- diff --git a/third_party/blink/renderer/core/frame/frame_test_helpers.h b/third_party/blink/renderer/core/frame/frame_test_helpers.h index 740591fa..6b0e55e1 100644 --- a/third_party/blink/renderer/core/frame/frame_test_helpers.h +++ b/third_party/blink/renderer/core/frame/frame_test_helpers.h @@ -581,6 +581,14 @@ return sandbox_flags_; } + // Subclasses that override CreateChildFrame() to gave the child frame a + // custom TestWebFrameClient subclass lose the propagation of sandbox flags + // that is performed in TestWebFrameClient::CreateChildFrame(). This allows + // such cases to set the flags manually. + void set_sandbox_flags(network::mojom::WebSandboxFlags flags) { + sandbox_flags_ = flags; + } + void DestroyChildViews(); void SetFrameDetachedCallback(base::OnceClosure callback); diff --git a/third_party/blink/renderer/core/frame/web_frame_test.cc b/third_party/blink/renderer/core/frame/web_frame_test.cc index c274456..b12511d 100644 --- a/third_party/blink/renderer/core/frame/web_frame_test.cc +++ b/third_party/blink/renderer/core/frame/web_frame_test.cc @@ -7653,8 +7653,8 @@ // frame_test_helpers::TestWebFrameClient: void BeginNavigation(std::unique_ptr<WebNavigationInfo> info) override { + begin_navigation_call_count_++; if (ignore_navigations_) { - begin_navigation_call_count_++; return; } TestWebFrameClient::BeginNavigation(std::move(info)); @@ -14845,5 +14845,56 @@ EXPECT_EQ(old_parent->ConnectedSubframeCount(), 0u); EXPECT_EQ(new_parent->ConnectedSubframeCount(), 1u); } +class IframeBeginNavivationCountTestWebFrameClient + : public frame_test_helpers::TestWebFrameClient { + public: + IframeBeginNavivationCountTestWebFrameClient() = default; + ~IframeBeginNavivationCountTestWebFrameClient() override = default; + + // WebLocalFrameClient: + WebLocalFrame* CreateChildFrame( + mojom::blink::TreeScopeType scope, + const WebString& name, + const WebString& fallback_name, + const FramePolicy& frame_policy, + const WebFrameOwnerProperties&, + FrameOwnerElementType, + WebPolicyContainerBindParams policy_container_bind_params, + ukm::SourceId document_ukm_source_id, + FinishChildFrameCreationFn finish_creation) override { + auto client = std::make_unique<TestNewWindowWebFrameClient>(); + client_ = client.get(); + client_->set_sandbox_flags(frame_policy.sandbox_flags); + return CreateLocalChild(*Frame(), scope, std::move(client), + std::move(policy_container_bind_params), + finish_creation); + } + + TestNewWindowWebFrameClient* iframe_client() const { return client_; } + + private: + TestNewWindowWebFrameClient* client_ = nullptr; +}; + +TEST_F(WebFrameTest, SandboxedIframePopupCtrlClick) { + RegisterMockedHttpURLLoad("sandboxed-srcdoc-ctrl-click.html"); + IframeBeginNavivationCountTestWebFrameClient web_frame_client; + frame_test_helpers::WebViewHelper web_view_helper; + web_view_helper.InitializeAndLoad( + base_url_ + "sandboxed-srcdoc-ctrl-click.html", &web_frame_client); + + ASSERT_EQ(web_frame_client.iframe_client()->BeginNavigationCallCount(), 1); + + LocalFrame* child = To<LocalFrame>( + web_view_helper.GetWebView()->GetPage()->MainFrame()->FirstChild()); + Element* element = + child->GetDocument()->body()->getElementById(AtomicString("btn")); + To<HTMLElement>(element)->click(); + + // Clicking the button will attempt a synthetic Ctrl+Click from an iframe + // sandboxed without `allow-popups`. This should be blocked before reaching + // BeginNavigation(). + EXPECT_EQ(web_frame_client.iframe_client()->BeginNavigationCallCount(), 1); +} } // namespace blink diff --git a/third_party/blink/renderer/core/loader/frame_loader.cc b/third_party/blink/renderer/core/loader/frame_loader.cc index 64e74d0..f413c08d 100644 --- a/third_party/blink/renderer/core/loader/frame_loader.cc +++ b/third_party/blink/renderer/core/loader/frame_loader.cc @@ -868,6 +868,17 @@ return; } + // A sandboxed iframe without `allow-popups` should not be able to + // open a new browsing context by emulating a user gesture in JS. + // (e.g. Ctrl+click). + if (request.GetNavigationPolicy() != kNavigationPolicyCurrentTab && + request.GetTriggeringEventInfo() == + mojom::blink::TriggeringEventInfo::kFromUntrustedEvent && + frame_->GetSecurityContext()->IsSandboxed( + network::mojom::blink::WebSandboxFlags::kPopups)) { + return; + } + if (request.GetNavigationPolicy() == kNavigationPolicyCurrentTab && (!origin_window || origin_window->GetSecurityOrigin()->CanAccess( frame_->DomWindow()->GetSecurityOrigin()))) { diff --git a/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist b/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist index d20416c3..03dd574b0 100644 --- a/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist +++ b/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist @@ -433,6 +433,7 @@ testing/data/rtl-iframe-inner.html testing/data/rtl-iframe.html testing/data/rtl-overview-mode.html +testing/data/sandboxed-srcdoc-ctrl-click.html testing/data/scale_oscillate.html testing/data/script/nested-script.js testing/data/script/not-parser-blocked-async-nested-script.html diff --git a/third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html b/third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html new file mode 100644 index 0000000..b325b44 --- /dev/null +++ b/third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html @@ -0,0 +1,31 @@ +<!doctype html> +<html lang="en"> +<body> +<iframe + sandbox="allow-scripts" + srcdoc=' + <div id="container"></div> + <script> + let clicked = false; + const btn = document.createElement("a"); + btn.id = "btn"; + btn.href = "about:blank"; + document.body.appendChild(btn); + + btn.addEventListener("click", (event) => { + if (clicked) return; + clicked = true; + event.preventDefault(); + const clickEvent = new MouseEvent("click", { + ctrlKey: true, + metaKey: true, + bubbles: true, + cancelable: true, + }); + btn.dispatchEvent(clickEvent); + }); + </script> +' +> +</iframe> +</html>
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/frame/web_frame_test.cc b/third_party/blink/renderer/core/frame/web_frame_test.cc
index c274456..b12511d 100644
--- a/third_party/blink/renderer/core/frame/web_frame_test.cc
+++ b/third_party/blink/renderer/core/frame/web_frame_test.cc
@@ -7653,8 +7653,8 @@
// frame_test_helpers::TestWebFrameClient:
void BeginNavigation(std::unique_ptr<WebNavigationInfo> info) override {
+ begin_navigation_call_count_++;
if (ignore_navigations_) {
- begin_navigation_call_count_++;
return;
}
TestWebFrameClient::BeginNavigation(std::move(info));
@@ -14845,5 +14845,56 @@
EXPECT_EQ(old_parent->ConnectedSubframeCount(), 0u);
EXPECT_EQ(new_parent->ConnectedSubframeCount(), 1u);
}
+class IframeBeginNavivationCountTestWebFrameClient
+ : public frame_test_helpers::TestWebFrameClient {
+ public:
+ IframeBeginNavivationCountTestWebFrameClient() = default;
+ ~IframeBeginNavivationCountTestWebFrameClient() override = default;
+
+ // WebLocalFrameClient:
+ WebLocalFrame* CreateChildFrame(
+ mojom::blink::TreeScopeType scope,
+ const WebString& name,
+ const WebString& fallback_name,
+ const FramePolicy& frame_policy,
+ const WebFrameOwnerProperties&,
+ FrameOwnerElementType,
+ WebPolicyContainerBindParams policy_container_bind_params,
+ ukm::SourceId document_ukm_source_id,
+ FinishChildFrameCreationFn finish_creation) override {
+ auto client = std::make_unique<TestNewWindowWebFrameClient>();
+ client_ = client.get();
+ client_->set_sandbox_flags(frame_policy.sandbox_flags);
+ return CreateLocalChild(*Frame(), scope, std::move(client),
+ std::move(policy_container_bind_params),
+ finish_creation);
+ }
+
+ TestNewWindowWebFrameClient* iframe_client() const { return client_; }
+
+ private:
+ TestNewWindowWebFrameClient* client_ = nullptr;
+};
+
+TEST_F(WebFrameTest, SandboxedIframePopupCtrlClick) {
+ RegisterMockedHttpURLLoad("sandboxed-srcdoc-ctrl-click.html");
+ IframeBeginNavivationCountTestWebFrameClient web_frame_client;
+ frame_test_helpers::WebViewHelper web_view_helper;
+ web_view_helper.InitializeAndLoad(
+ base_url_ + "sandboxed-srcdoc-ctrl-click.html", &web_frame_client);
+
+ ASSERT_EQ(web_frame_client.iframe_client()->BeginNavigationCallCount(), 1);
+
+ LocalFrame* child = To<LocalFrame>(
+ web_view_helper.GetWebView()->GetPage()->MainFrame()->FirstChild());
+ Element* element =
+ child->GetDocument()->body()->getElementById(AtomicString("btn"));
+ To<HTMLElement>(element)->click();
+
+ // Clicking the button will attempt a synthetic Ctrl+Click from an iframe
+ // sandboxed without `allow-popups`. This should be blocked before reaching
+ // BeginNavigation().
+ EXPECT_EQ(web_frame_client.iframe_client()->BeginNavigationCallCount(), 1);
+}
} // namespace blink
diff --git a/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist b/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
index d20416c3..03dd574b0 100644
--- a/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
+++ b/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
@@ -433,6 +433,7 @@
testing/data/rtl-iframe-inner.html
testing/data/rtl-iframe.html
testing/data/rtl-overview-mode.html
+testing/data/sandboxed-srcdoc-ctrl-click.html
testing/data/scale_oscillate.html
testing/data/script/nested-script.js
testing/data/script/not-parser-blocked-async-nested-script.html
diff --git a/third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html b/third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html
new file mode 100644
index 0000000..b325b44
--- /dev/null
+++ b/third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html
@@ -0,0 +1,31 @@
+<!doctype html>
+<html lang="en">
+<body>
+<iframe
+ sandbox="allow-scripts"
+ srcdoc='
+ <div id="container"></div>
+ <script>
+ let clicked = false;
+ const btn = document.createElement("a");
+ btn.id = "btn";
+ btn.href = "about:blank";
+ document.body.appendChild(btn);
+
+ btn.addEventListener("click", (event) => {
+ if (clicked) return;
+ clicked = true;
+ event.preventDefault();
+ const clickEvent = new MouseEvent("click", {
+ ctrlKey: true,
+ metaKey: true,
+ bubbles: true,
+ cancelable: true,
+ });
+ btn.dispatchEvent(clickEvent);
+ });
+ </script>
+'
+>
+</iframe>
+</html>
Original Bug Report
HTML5 Sandbox Security Model Violation with auxiliary browsing contexts being created despite the lack of "allow-popups" keyword within iframes
Report description
HTML5 Sandbox Security Model Violation with auxiliary browsing contexts being created despite the lack of “allow-popups” keyword within iframes
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
The problem
Please describe the technical details of the vulnerability
The vulnerability exists within the Blink rendering engine’s handling of navigation policies derived from mouse events. Specifically, the function NavigationPolicyFromEventInternal in third_party/blink/renderer/core/loader/navigation_policy.cc fails to validate the isTrusted property of a MouseEvent before honoring modifier keys (such as ctrlKey or metaKey).
In a standard HTML5 sandbox environment, an iframe without the allow-popups flag is prohibited from opening new auxiliary browsing contexts (windows or tabs). However, this restriction relies on the renderer correctly calculating the Navigation Policy for a given action.
When a user performs a Ctrl+Click (or Cmd+Click on macOS) on a link, Blink translates this into a navigation policy of kNavigationPolicyNewBackgroundTab or kNavigationPolicyNewForegroundTab. The vulnerability arises because JavaScript within a sandboxed iframe can programmatically construct a synthetic MouseEvent with these modifier keys set to true and dispatch it against an anchor element.
Because NavigationPolicyFromEventInternal does not check if the event is trusted (event.isTrusted()), it blindly processes the synthetic modifiers. Consequently, the renderer calculates a “New Tab” navigation policy and requests the browser to open a new window. This effectively bypasses the allow-popups restriction, as the browser treats the request as a legitimate link navigation with a specific disposition, rather than a blocked window.open call.
Impact analysis
This vulnerability is a security bypass of the HTML5 sandbox allow-popups restriction. While typically classified as a sandbox escape in standard browsers (allowing untrusted content to annoy users or facilitate phishing via popups), the impact is significantly amplified in certain Electron-based applications, such as the Discord Desktop Client, where it can serve as a primitive for Remote Code Execution (RCE).
Core Impact: Sandbox Escape & Security Control Bypass
The vulnerability allows sandboxed content (explicitly restricted from creating new windows) to force the creation of an auxiliary browsing context. This directly violates the integrity of the sandbox attribute, rendering the allow-popups flag ineffective against a malicious actor capable of executing JavaScript.
High-Severity Exploitation in Electron (Discord RCE Chain)
In the context of the Discord Desktop Client, this sandbox escape bridges the gap between a restricted renderer process and privileged system operations.
- Mechanism: Electron applications often intercept new window creation events (such as via
webContents.on('new-window')orsetWindowOpenHandler) to delegate external link handling to the operating system usingshell.openExternal(). - The Bypass: Discord implements checks to prevent sandboxed iframes from triggering this flow. However, because the vulnerability forces the Blink engine to classify the synthetic event as a legitimate, user-initiated “New Tab” navigation (via the
kNavigationPolicyNewForegroundTabpolicy), it bypasses renderer-side checks that rely on standardwindow.openrestrictions. - RCE Vector: By successfully triggering
shell.openExternal()with a malicious payload, we demonstrated the ability to execute arbitrary code on the host machine.- Violation: This directly violates the Electron security best practice: " Do not use shell.openExternal with untrusted content". Electron Checklist #15
- Chain:
Sandboxed Iframe->Synthetic Ctrl+Click->Sandbox Bypass (New Window Request)->Electron Interception->shell.openExternal(malicious_URI)->RCE.
This finding confirms that the “One Permitted Navigator” principle and standard event validation are insufficient in their current state to protect embedding contexts from synthetic input attacks.
The cause
What version of Chrome have you found the security issue in?
[145.0.7632.46]
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Sandbox Escape
How would you like to be publicly acknowledged for your report?
Credit to my GitHub @Ciarands
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/loader/navigation_policy.cc
- https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-shellopenexternal-with-untrusted-content