CVE-2026-7934
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/chrome_content_browser_client.cc |
modified | |
TEST_Fchrome/browser/chrome_content_browser_client_unittest.cc |
modified |
Files Changed
chrome/browser/chrome_content_browser_client.ccchrome/browser/chrome_content_browser_client.hchrome/browser/chrome_content_browser_client_unittest.cc
Patch
From 3b4c638edf82be4ab208b92263b8c052cd93bdd2 Mon Sep 17 00:00:00 2001 From: Eva Su <[email protected]> Date: Tue, 24 Mar 2026 22:21:13 -0700 Subject: [PATCH] [Extensions] Add browser-side validation to prevent popup blocker bypass Previously, RenderFrameHostImpl::CreateNewWindow() directly trusted the renderer-supplied allow_popup boolean field within the IPC CreateNewWindowParams structure to bypass the popup blocker. A compromised renderer could synthesize IPCs with allow_popup set to true to circumvent this limitation. To prevent this, this CL duplicates the relevant extension checks from the renderer to the browser process. It ensures that the popup bypass is exclusively permitted when the originating process genuinely belongs to an extension, a hosted app, or has had an extension content script injected into it. Specifically, this CL: - Adds ContentBrowserClient::IsPopupBypassAllowed() to allow browser-side verification of the popup bypass capability. - Updates RenderFrameHostImpl::CreateNewWindow() to check this new method alongside the allow_popup IPC flag. - Implements ChromeContentBrowserClient::IsPopupBypassAllowed() to validate that the process either hosts an extension context, a privileged web page (e.g., hosted apps) in an outermost main frame, or has previously run an injected content script. - Strengthens the existing renderer-side checks in ExtensionsRendererClient::AllowPopup() by verifying that the context's extension object corresponds to an actual extension or hosted app for kPrivilegedExtension and kPrivilegedWebPage, respectively. - Adds these histograms to log metrics: - `Extensions.PopupBypassAllowedType`: Logs which extension-related context (Content Script, Extension Process, or Privileged Web Page) successfully bypassed the blocker. This data will help determine if these privileges can be further restricted or removed in the future. - `Extensions.PopupBypassDeniedByBrowser`: Records whenever a renderer-requested bypass (via the `allow_popup` IPC flag) is rejected by the browser-side verification logic. Bug: 489023922 Change-Id: I7cc40c30417ca197c86a46c04bdfed831b173b57 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7669581 Reviewed-by: Elly FJ <[email protected]> Reviewed-by: Alex Moshchuk <[email protected]> Reviewed-by: Devlin Cronin <[email protected]> Commit-Queue: Eva Su <[email protected]> Reviewed-by: Yifan Luo <[email protected]> Cr-Commit-Position: refs/heads/main@{#1604600} --- diff --git a/chrome/browser/chrome_content_browser_client.cc b/chrome/browser/chrome_content_browser_client.cc index cc29df8..797648f 100644 --- a/chrome/browser/chrome_content_browser_client.cc +++ b/chrome/browser/chrome_content_browser_client.cc @@ -632,6 +632,7 @@ #include "extensions/common/extension.h" #include "extensions/common/extension_set.h" #include "extensions/common/manifest_handlers/background_info.h" +#include "extensions/common/mojom/context_type.mojom.h" #include "extensions/common/permissions/permissions_data.h" #include "extensions/common/switches.h" @@ -4434,6 +4435,59 @@ ->GetFeatureObserverClient(); } +// These values are persisted to logs and used for histograms. +enum class PopupBypassType { + kContentScript = 0, + kExtensionProcess = 1, + kPrivilegedWebPage = 2, + kMaxValue = kPrivilegedWebPage, +}; + +bool ChromeContentBrowserClient::IsPopupBypassAllowed( + content::RenderFrameHost* render_frame_host) { +#if BUILDFLAG(ENABLE_EXTENSIONS_CORE) + content::RenderProcessHost* process = render_frame_host->GetProcess(); + content::BrowserContext* browser_context = process->GetBrowserContext(); + extensions::ProcessMap* process_map = + extensions::ProcessMap::Get(browser_context); + if (!process_map) { + return false; + } + + // Allow if it is an authorized extension process. + const extensions::Extension* extension = + process_map->GetEnabledExtensionByProcessID(process->GetID().value()); + if (process_map->CanProcessHostContextType( + extension, *process, + extensions::mojom::ContextType::kPrivilegedExtension)) { + base::UmaHistogramEnumeration("Security.PopupBypassAllowedType", + PopupBypassType::kExtensionProcess); + return true; + } + + // Allow if it is a privileged web page (e.g., hosted app) in an outermost + // main frame. + if (!render_frame_host->GetParentOrOuterDocument() && + process_map->CanProcessHostContextType( + extension, *process, + extensions::mojom::ContextType::kPrivilegedWebPage)) { + base::UmaHistogramEnumeration("Security.PopupBypassAllowedType", + PopupBypassType::kPrivilegedWebPage); + return true; + } + + // Allow if an extension ran a content script in this process. + if (!extensions::ScriptInjectionTracker:: + GetExtensionsThatRanContentScriptsInProcess(*process) + .empty()) { + base::UmaHistogramEnumeration("Security.PopupBypassAllowedType", + PopupBypassType::kContentScript); + return true; + } +#endif + return false; +} + bool ChromeContentBrowserClient::CanCreateWindow( RenderFrameHost* opener, const GURL& opener_url, diff --git a/chrome/browser/chrome_content_browser_client.h b/chrome/browser/chrome_content_browser_client.h index ebd5dfd..a910642 100644 --- a/chrome/browser/chrome_content_browser_client.h +++ b/chrome/browser/chrome_content_browser_client.h @@ -512,6 +512,8 @@ std::unique_ptr<content::ClientCertificateDelegate> delegate) override; content::MediaObserver* GetMediaObserver() override; content::FeatureObserverClient* GetFeatureObserverClient() override; + bool IsPopupBypassAllowed( + content::RenderFrameHost* render_frame_host) override; bool CanCreateWindow(content::RenderFrameHost* opener, const GURL& opener_url, const GURL& opener_top_level_frame_url, diff --git a/chrome/browser/chrome_content_browser_client_unittest.cc b/chrome/browser/chrome_content_browser_client_unittest.cc index d2849ea..272a9390e 100644 --- a/chrome/browser/chrome_content_browser_client_unittest.cc +++ b/chrome/browser/chrome_content_browser_client_unittest.cc @@ -173,6 +173,15 @@ #include "third_party/blink/public/common/features.h" #endif // BUILDFLAG(ENABLE_EXTENSIONS) +#if BUILDFLAG(ENABLE_EXTENSIONS_CORE) +#include "chrome/test/base/chrome_render_view_host_test_harness.h" +#include "extensions/browser/extension_registry.h" +#include "extensions/browser/process_map.h" +#include "extensions/browser/script_injection_tracker.h" +#include "extensions/common/extension_builder.h" +#include "extensions/common/mojom/context_type.mojom.h" +#endif // BUILDFLAG(ENABLE_EXTENSIONS_CORE) + #if BUILDFLAG(ENABLE_PDF) #include "content/public/test/mock_navigation_handle.h" #include "content/public/test/test_renderer_host.h" @@ -222,6 +231,78 @@ TestingProfile profile_; }; +#if BUILDFLAG(ENABLE_EXTENSIONS_CORE) +using ChromeContentBrowserClientIsPopupBypassAllowedTest = + ChromeRenderViewHostTestHarness; + +// Tests that an extension process is allowed to bypass the popup blocker. +TEST_F(ChromeContentBrowserClientIsPopupBypassAllowedTest, ExtensionProcess) { + ChromeContentBrowserClient client; + NavigateAndCommit(GURL("https://example.com")); + EXPECT_FALSE(client.IsPopupBypassAllowed(main_rfh())); + + auto* process_map = extensions::ProcessMap::Get(profile()); + ASSERT_TRUE(process_map); + + scoped_refptr<const extensions::Extension> extension = + extensions::ExtensionBuilder("Test").Build(); + process_map->Insert(extension->id(), + main_rfh()->GetProcess()->GetID().value()); + extensions::ExtensionRegistry::Get(profile())->AddEnabled(extension); + + EXPECT_TRUE(client.IsPopupBypassAllowed(main_rfh())); +} + +// Tests that a privileged hosted app is allowed to bypass the popup blocker. +TEST_F(ChromeContentBrowserClientIsPopupBypassAllowedTest, PrivilegedWebPage) { + ChromeContentBrowserClient client; + NavigateAndCommit(GURL("https://example.com")); + EXPECT_FALSE(client.IsPopupBypassAllowed(main_rfh())); + + scoped_refptr<const extensions::Extension> hosted_app = + extensions::ExtensionBuilder("Hosted App") + .SetManifestKey( + "app", base::DictValue().Set("urls", base::ListValue().Append( + "http://example.com/"))) + .Build(); + + extensions::ExtensionRegistry::Get(profile())->AddEnabled(hosted_app); + auto* process_map = extensions::ProcessMap::Get(profile()); + process_map->Insert(hosted_app->id(), + main_rfh()->GetProcess()->GetID().value()); + + EXPECT_TRUE(client.IsPopupBypassAllowed(main_rfh())); +} + +// Tests that a process where an extension ran a content script is allowed to +// bypass the popup blocker. +TEST_F(ChromeContentBrowserClientIsPopupBypassAllowedTest, ContentScript) { + ChromeContentBrowserClient client; + NavigateAndCommit(GURL("https://example.com")); + EXPECT_FALSE(client.IsPopupBypassAllowed(main_rfh())); + + auto* process_map = extensions::ProcessMap::Get(profile()); + ASSERT_TRUE(process_map); + + scoped_refptr<const extensions::Extension> extension =
Regression Test / PoC
diff --git a/chrome/browser/chrome_content_browser_client_unittest.cc b/chrome/browser/chrome_content_browser_client_unittest.cc
index d2849ea..272a9390e 100644
--- a/chrome/browser/chrome_content_browser_client_unittest.cc
+++ b/chrome/browser/chrome_content_browser_client_unittest.cc
@@ -173,6 +173,15 @@
#include "third_party/blink/public/common/features.h"
#endif // BUILDFLAG(ENABLE_EXTENSIONS)
+#if BUILDFLAG(ENABLE_EXTENSIONS_CORE)
+#include "chrome/test/base/chrome_render_view_host_test_harness.h"
+#include "extensions/browser/extension_registry.h"
+#include "extensions/browser/process_map.h"
+#include "extensions/browser/script_injection_tracker.h"
+#include "extensions/common/extension_builder.h"
+#include "extensions/common/mojom/context_type.mojom.h"
+#endif // BUILDFLAG(ENABLE_EXTENSIONS_CORE)
+
#if BUILDFLAG(ENABLE_PDF)
#include "content/public/test/mock_navigation_handle.h"
#include "content/public/test/test_renderer_host.h"
@@ -222,6 +231,78 @@
TestingProfile profile_;
};
+#if BUILDFLAG(ENABLE_EXTENSIONS_CORE)
+using ChromeContentBrowserClientIsPopupBypassAllowedTest =
+ ChromeRenderViewHostTestHarness;
+
+// Tests that an extension process is allowed to bypass the popup blocker.
+TEST_F(ChromeContentBrowserClientIsPopupBypassAllowedTest, ExtensionProcess) {
+ ChromeContentBrowserClient client;
+ NavigateAndCommit(GURL("https://example.com"));
+ EXPECT_FALSE(client.IsPopupBypassAllowed(main_rfh()));
+
+ auto* process_map = extensions::ProcessMap::Get(profile());
+ ASSERT_TRUE(process_map);
+
+ scoped_refptr<const extensions::Extension> extension =
+ extensions::ExtensionBuilder("Test").Build();
+ process_map->Insert(extension->id(),
+ main_rfh()->GetProcess()->GetID().value());
+ extensions::ExtensionRegistry::Get(profile())->AddEnabled(extension);
+
+ EXPECT_TRUE(client.IsPopupBypassAllowed(main_rfh()));
+}
+
+// Tests that a privileged hosted app is allowed to bypass the popup blocker.
+TEST_F(ChromeContentBrowserClientIsPopupBypassAllowedTest, PrivilegedWebPage) {
+ ChromeContentBrowserClient client;
+ NavigateAndCommit(GURL("https://example.com"));
+ EXPECT_FALSE(client.IsPopupBypassAllowed(main_rfh()));
+
+ scoped_refptr<const extensions::Extension> hosted_app =
+ extensions::ExtensionBuilder("Hosted App")
+ .SetManifestKey(
+ "app", base::DictValue().Set("urls", base::ListValue().Append(
+ "http://example.com/")))
+ .Build();
+
+ extensions::ExtensionRegistry::Get(profile())->AddEnabled(hosted_app);
+ auto* process_map = extensions::ProcessMap::Get(profile());
+ process_map->Insert(hosted_app->id(),
+ main_rfh()->GetProcess()->GetID().value());
+
+ EXPECT_TRUE(client.IsPopupBypassAllowed(main_rfh()));
+}
+
+// Tests that a process where an extension ran a content script is allowed to
+// bypass the popup blocker.
+TEST_F(ChromeContentBrowserClientIsPopupBypassAllowedTest, ContentScript) {
+ ChromeContentBrowserClient client;
+ NavigateAndCommit(GURL("https://example.com"));
+ EXPECT_FALSE(client.IsPopupBypassAllowed(main_rfh()));
+
+ auto* process_map = extensions::ProcessMap::Get(profile());
+ ASSERT_TRUE(process_map);
+
+ scoped_refptr<const extensions::Extension> extension =
+ extensions::ExtensionBuilder("Test").Build();
+
+ extensions::ScriptInjectionTracker::
+ AddExtensionThatRanContentScriptsInProcessForTesting(
+ *main_rfh()->GetProcess(), extension->id());
+
+ EXPECT_TRUE(client.IsPopupBypassAllowed(main_rfh()));
+}
+
+// Tests that a normal web page is not allowed to bypass the popup blocker.
+TEST_F(ChromeContentBrowserClientIsPopupBypassAllowedTest, NormalWebPage) {
+ ChromeContentBrowserClient client;
+ NavigateAndCommit(GURL("https://example.com"));
+ EXPECT_FALSE(client.IsPopupBypassAllowed(main_rfh()));
+}
+
+#endif // BUILDFLAG(ENABLE_EXTENSIONS_CORE)
+
// Check that chrome-native: URLs do not assign a site for their
// SiteInstances. This works because `kChromeNativeScheme` is registered as an
// empty document scheme in ChromeContentClient.
diff --git a/chrome/browser/extensions/window_open_apitest.cc b/chrome/browser/extensions/window_open_apitest.cc
index 1a99f7b..279c057b 100644
--- a/chrome/browser/extensions/window_open_apitest.cc
+++ b/chrome/browser/extensions/window_open_apitest.cc
@@ -223,17 +223,42 @@
// Tests that an extension page can call window.open to an extension URL and
// the new window has extension privileges.
IN_PROC_BROWSER_TEST_F(ExtensionBrowserTest, WindowOpenExtension) {
- ASSERT_TRUE(LoadExtension(
- test_data_dir_.AppendASCII("uitest").AppendASCII("window_open")));
+ const extensions::Extension* extension = LoadExtension(
+ test_data_dir_.AppendASCII("uitest").AppendASCII("window_open"));
+ ASSERT_TRUE(extension);
- GURL start_url(std::string(extensions::kExtensionScheme) +
- url::kStandardSchemeSeparator +
- last_loaded_extension_id() + "/test.html");
+ GURL start_url = extension->GetResourceURL("test.html");
auto* web_contents = GetActiveWebContents();
ASSERT_TRUE(NavigateToURL(web_contents, start_url));
WebContents* newtab = nullptr;
- ASSERT_NO_FATAL_FAILURE(OpenWindow(
- web_contents, start_url.Resolve("newtab.html"), true, true, &newtab));
+ ASSERT_NO_FATAL_FAILURE(OpenWindow(web_contents,
+ extension->GetResourceURL("newtab.html"),
+ true, true, &newtab));
+
+ EXPECT_EQ(true, content::EvalJs(newtab, "testExtensionApi()"));
+}
+
+// Tests that an extension page can call window.open without a user gesture
+// and the new window is opened (bypassing the popup blocker) with extension
+// privileges.
+IN_PROC_BROWSER_TEST_F(ExtensionBrowserTest,
+ WindowOpenExtensionWithoutUserGesture) {
+ const extensions::Extension* extension = LoadExtension(
+ test_data_dir_.AppendASCII("uitest").AppendASCII("window_open"));
+ ASSERT_TRUE(extension);
+
+ GURL start_url = extension->GetResourceURL("test.html");
+ auto* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(web_contents, start_url));
+
+ content::WebContentsAddedObserver tab_added_observer;
+ ASSERT_TRUE(content::ExecJs(
+ web_contents,
+ "window.open('" + extension->GetResourceURL("newtab.html").spec() + "');",
+ content::EXECUTE_SCRIPT_NO_USER_GESTURE));
+ content::WebContents* newtab = tab_added_observer.GetWebContents();
+ ASSERT_TRUE(newtab);
+ EXPECT_TRUE(content::WaitForLoadStop(newtab));
EXPECT_EQ(true, content::EvalJs(newtab, "testExtensionApi()"));
}
diff --git a/chrome/browser/ui/blocked_content/popup_blocker_browsertest.cc b/chrome/browser/ui/blocked_content/popup_blocker_browsertest.cc
index 0a51be4..175da29 100644
--- a/chrome/browser/ui/blocked_content/popup_blocker_browsertest.cc
+++ b/chrome/browser/ui/blocked_content/popup_blocker_browsertest.cc
@@ -442,6 +442,27 @@
EXPECT_EQ(base::ASCIIToUTF16(kSearchString), match.contents);
}
+// Verify that the browser process prevents a non-extension process from
+// bypassing the popup blocker. This acts as a browser-side validation against a
+// compromised renderer.
+IN_PROC_BROWSER_TEST_F(PopupBlockerBrowserTest,
+ PopupBypassFromNonExtensionProcessIsBlocked) {
+ GURL url(
+ embedded_test_server()->GetURL("/popup_blocker/popup-window-open.html"));
+ EXPECT_TRUE(ui_test_utils::NavigateToURL(browser(), url));
+
+ WebContents* tab = browser()->tab_strip_model()->GetActiveWebContents();
+ content::RenderFrameHost* rfh = tab->GetPrimaryMainFrame();
+
+ // Simulate a compromised renderer trying to bypass the popup blocker.
+ // The popup should be blocked because the renderer is not an extension.
+ EXPECT_TRUE(content::PwnMessageHelper::OpenPopup(rfh, url));
+
+ // The popup should be blocked because the renderer is not an extension.
+ EXPECT_EQ(1u, chrome::GetBrowserCount(browser()->profile()));
+ EXPECT_EQ(1, browser()->tab_strip_model()->count());
+}
+
// This test fails on linux AURA with this change
// https://codereview.chromium.org/23903056
// BUG=https://code.google.com/p/chromium/issues/detail?id=295299
diff --git a/content/public/test/browser_test_utils.cc b/content/public/test/browser_test_utils.cc
index 7979638..e921a8e 100644
--- a/content/public/test/browser_test_utils.cc
+++ b/content/public/test/browser_test_utils.cc
@@ -4184,6 +4184,31 @@
waiter.WaitForOperationToFinish();
}
+bool PwnMessageHelper::OpenPopup(RenderFrameHost* render_frame_host,
+ const GURL& url) {
+ mojom::CreateNewWindowParamsPtr params = mojom::CreateNewWindowParams::New();
+ params->target_url = url;
+ params->allow_popup =
+ true; // The compromised renderer lies and sets this to true
+ params->window_container_type = mojom::WindowContainerType::NORMAL;
+ params->disposition = WindowOpenDisposition::NEW_POPUP;
+ params->features = blink::mojom::WindowFeatures::New();
+ params->referrer = blink::mojom::Referrer::New();
+
+ bool is_blocked = false;
+ base::RunLoop run_loop;
+ static_cast<mojom::FrameHost*>(
+ static_cast<RenderFrameHostImpl*>(render_frame_host))
+ ->CreateNewWindow(
+ std::move(params),
+ base::BindLambdaForTesting([&](mojom::CreateNewWindowStatus status,
+ mojom::CreateNewWindowReplyPtr reply) {
+ is_blocked = (status == mojom::CreateNewWindowStatus::kBlocked);
+ run_loop.Quit();
+ }));
+ run_loop.Run();
+ return is_blocked;
+}
void PwnMessageHelper::OpenURL(RenderFrameHost* render_frame_host,
const GURL& url) {
auto params = blink::mojom::OpenURLParams::New();
diff --git a/content/public/test/browser_test_utils.h b/content/public/test/browser_test_utils.h
index 389aba85..b752e17 100644
--- a/content/public/test/browser_test_utils.h
+++ b/content/public/test/browser_test_utils.h
@@ -2279,6 +2279,8 @@
// Calls OpenURL method in FrameHost Mojo interface.
static void OpenURL(RenderFrameHost* render_frame_host, const GURL& url);
+ static bool OpenPopup(RenderFrameHost* render_frame_host, const GURL& url);
+
private:
PwnMessageHelper(); // Not instantiable.
};
Original Bug Report
A compromised renderer process can circumvent the popup blocker
When the renderer calls the synchronous content.mojom.FrameHost.CreateNewWindow IPC, it fills a CreateNewWindowParams structure that includes a boolean allow_popup field. The browser process reads this field in RenderFrameHostImpl::CreateNewWindow() and uses it to decide whether to proceed with window creation or reject it as a blocked popup.
The critical point is that the browser directly trusts this renderer-supplied value without performing any independent verification of whether a real user gesture actually occurred. If the renderer process has been compromised, it can synthesize IPC’s with allow_popup set to true.
This was split out from:
https://issues.chromium.org/487338366
Note that the general issue around user activation state belonging in the browser process was raised previously in: