Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Popup Blocker
DescriptionInsufficient validation of untrusted input in Popup Blocker
ComponentPopup Blocker
Bug ClassLogic Error
Tracker489023922
Fix commit3b4c638edf82 (chromium/src) +296/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
if
chrome/browser/chrome_content_browser_client.cc
modified
TEST_F
chrome/browser/chrome_content_browser_client_unittest.cc
modified

Files Changed

  • chrome/browser/chrome_content_browser_client.cc
  • chrome/browser/chrome_content_browser_client.h
  • chrome/browser/chrome_content_browser_client_unittest.cc
From 3b4c638edf82be4ab208b92263b8c052cd93bdd2 Mon Sep 17 00:00:00 2001
From: Eva Su <evasu@chromium.org>
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 <ellyjones@chromium.org>
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Commit-Queue: Eva Su <evasu@chromium.org>
Reviewed-by: Yifan Luo <lyf@chromium.org>
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 =
Loading diff…

Regression Test / PoC

shipped with the fix
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.
 };
Loading diff…

Original Bug Report

reported by sz...@chromium.org

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:

https://issues.chromium.org/40091540

View on issue tracker