CVE-2026-113127
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
IN_PROC_BROWSER_TEST_Fchrome/browser/ui/views/hats/hats_browsertest.cc |
modified |
Files Changed
chrome/test/data/hats/hats_next_mock.htmlchrome/browser/ui/views/hats/hats_browsertest.ccchrome/browser/ui/views/hats/hats_next_web_dialog.ccchrome/browser/ui/views/hats/BUILD.gn
Patch
From efabd3f9c90d743074e57f243e4db5cf55819d51 Mon Sep 17 00:00:00 2001 From: Tom Van Goethem <[email protected]> Date: Sat, 19 Sep 2026 06:56:22 -0700 Subject: [PATCH] [HaTS] Mark navigations opened from HaTS dialog as renderer-initiated When the HaTS WebDialog opens a link in the regular browser via HatsWebView::CreateCustomWebContents, it previously used OpenURLParams::CreateBrowserInitiated(). This caused cross-profile navigations originating from the survey renderer to be treated as browser-initiated with no initiator origin, bypassing renderer navigation checks such as BlockedSchemeNavigationThrottle and extension web_accessible_resources restrictions. Update CreateCustomWebContents to mark the navigation as renderer-initiated with a precursorless opaque initiator origin and preserve the user gesture flag. TAG=agy CONV=a5445b86-6b49-4062-b8f6-4d4220909b87 Bug: 501730091 Change-Id: Id1daab4aaa75c8eb3fc4076ffcd2bf640b11bbb5 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8425481 Reviewed-by: Martin Šrámek <[email protected]> Commit-Queue: Tom Van Goethem <[email protected]> Cr-Commit-Position: refs/heads/main@{#1701391} --- diff --git a/chrome/browser/ui/views/hats/hats_next_web_dialog.cc b/chrome/browser/ui/views/hats/hats_next_web_dialog.cc index a3d3252..5b98f81c 100644 --- a/chrome/browser/ui/views/hats/hats_next_web_dialog.cc +++ b/chrome/browser/ui/views/hats/hats_next_web_dialog.cc @@ -52,6 +52,7 @@ #include "ui/views/layout/fill_layout.h" #include "ui/views/view_class_properties.h" #include "ui/web_dialogs/web_dialog_delegate.h" +#include "url/origin.h" constexpr gfx::Size HatsNextWebDialog::kMinSize; constexpr gfx::Size HatsNextWebDialog::kMaxSize; @@ -128,10 +129,12 @@ // The HaTS Next WebDialog runs with a non-primary OTR profile. This profile // cannot open new browser windows, so they are instead opened in the // regular browser that initiated the HaTS survey. - content::OpenURLParams params = - content::OpenURLParams::CreateBrowserInitiated( - target_url, WindowOpenDisposition::NEW_FOREGROUND_TAB, - ui::PAGE_TRANSITION_LINK); + content::OpenURLParams params(target_url, content::Referrer(), + WindowOpenDisposition::NEW_FOREGROUND_TAB, + ui::PAGE_TRANSITION_LINK, + /*is_renderer_initiated=*/true); + params.initiator_origin = url::Origin(); + params.user_gesture = true; // For the case where we are showing a survey in an undocked DevTools // window, we open the URL in the browser of the inspected page. diff --git a/chrome/browser/ui/views/hats/hats_browsertest.cc b/chrome/browser/ui/views/hats/hats_browsertest.cc index c808c1a..2c168ab 100644 --- a/chrome/browser/ui/views/hats/hats_browsertest.cc +++ b/chrome/browser/ui/views/hats/hats_browsertest.cc @@ -33,6 +33,7 @@ #include "components/ukm/test_ukm_recorder.h" #include "components/version_info/version_info.h" #include "content/public/test/browser_test.h" +#include "content/public/test/test_navigation_observer.h" #include "testing/gmock/include/gmock/gmock.h" #include "third_party/blink/public/common/page/page_zoom.h" #include "ui/views/test/views_test_utils.h" @@ -355,6 +356,10 @@ IN_PROC_BROWSER_TEST_F(HatsNextWebDialogBrowserTest, NewWebContents) { ASSERT_TRUE(embedded_test_server()->Start()); + content::TestNavigationObserver navigation_observer(nullptr); + navigation_observer.set_expected_initial_url(GURL("http://foo.com")); + navigation_observer.StartWatchingNewWebContents(); + auto* dialog = new MockHatsNextWebDialog( browser(), "open_new_web_contents_for_testing", std::nullopt, std::nullopt, embedded_test_server()->GetURL("/hats/hats_next_mock.html"), @@ -366,10 +371,15 @@ dialog->WaitForClose(); // Check that a tab with http://foo.com (defined in hats_next_mock.html) has - // been opened in the regular browser and is active. + // been opened in the regular browser and is active, with an opaque initiator + // origin. EXPECT_EQ( GURL("http://foo.com"), browser()->GetTabStripModel()->GetActiveWebContents()->GetVisibleURL()); + + navigation_observer.WaitForNavigationFinished(); + ASSERT_TRUE(navigation_observer.last_initiator_origin().has_value()); + EXPECT_TRUE(navigation_observer.last_initiator_origin()->opaque()); } // The devtools browser for undocked devtools has no tab strip and can't open @@ -380,6 +390,10 @@ BrowserWindowInterface* devtools_browser = OpenUndockedDevToolsWindow(); + content::TestNavigationObserver navigation_observer(nullptr); + navigation_observer.set_expected_initial_url(GURL("http://foo.com")); + navigation_observer.StartWatchingNewWebContents(); + auto* dialog = new MockHatsNextWebDialog( devtools_browser, "open_new_web_contents_for_testing", std::nullopt, std::nullopt, embedded_test_server()->GetURL("/hats/hats_next_mock.html"), @@ -391,10 +405,39 @@ dialog->WaitForClose(); // Check that a tab with http://foo.com (defined in hats_next_mock.html) has - // been opened in the regular browser and is active. + // been opened in the regular browser and is active, with an opaque initiator + // origin. EXPECT_EQ( GURL("http://foo.com"), browser()->GetTabStripModel()->GetActiveWebContents()->GetVisibleURL()); + + navigation_observer.WaitForNavigationFinished(); + ASSERT_TRUE(navigation_observer.last_initiator_origin().has_value()); + EXPECT_TRUE(navigation_observer.last_initiator_origin()->opaque()); +} + +IN_PROC_BROWSER_TEST_F(HatsNextWebDialogBrowserTest, + NewWebContentsBlocksDataUrl) { + ASSERT_TRUE(embedded_test_server()->Start()); + + content::TestNavigationObserver navigation_observer( + GURL("data:text/html,<h1>blocked</h1>"), /*quit_mode=*/{}, + /*ignore_uncommitted_navigations=*/false); + navigation_observer.StartWatchingNewWebContents(); + + auto* dialog = new MockHatsNextWebDialog( + browser(), "open_data_url_for_testing", std::nullopt, std::nullopt, + embedded_test_server()->GetURL("/hats/hats_next_mock.html"), + base::Seconds(100), base::DoNothing(), base::DoNothing(), {}, {}); + + EXPECT_CALL(*hats_service(), HatsNextDialogClosed); + dialog->WaitForClose(); + navigation_observer.WaitForNavigationFinished(); + + // Top-level data: URL navigations initiated by a renderer are blocked by + // BlockedSchemeNavigationThrottle. + EXPECT_FALSE(navigation_observer.last_navigation_succeeded()); + EXPECT_EQ(net::ERR_ABORTED, navigation_observer.last_net_error_code()); } IN_PROC_BROWSER_TEST_F(HatsNextWebDialogBrowserTest, DialogResize) { diff --git a/chrome/test/data/hats/hats_next_mock.html b/chrome/test/data/hats/hats_next_mock.html index 505f2fd..fc1db5e 100644 --- a/chrome/test/data/hats/hats_next_mock.html +++ b/chrome/test/data/hats/hats_next_mock.html @@ -44,6 +44,11 @@ history.pushState('', '', '#close'); } + if (params.get('trigger_id') == "open_data_url_for_testing") { + window.open('data:text/html,<h1>blocked</h1>', '_blank'); + history.pushState('', '', '#close'); + } + if (params.get('trigger_id') == "resize_for_testing") { // Size wrapping div element to a known size, with one dimension smaller // than the by-default renderer-enforced minimum. diff --git a/chrome/browser/ui/views/hats/BUILD.gn b/chrome/browser/ui/views/hats/BUILD.gn index 7fc7aa6..03cb3ea 100644 --- a/chrome/browser/ui/views/hats/BUILD.gn +++ b/chrome/browser/ui/views/hats/BUILD.gn @@ -48,5 +48,6 @@ "//ui/base/metadata", "//ui/views", "//ui/web_dialogs", + "//url", ] }
Regression Test / PoC
diff --git a/chrome/browser/ui/views/hats/hats_browsertest.cc b/chrome/browser/ui/views/hats/hats_browsertest.cc
index c808c1a..2c168ab 100644
--- a/chrome/browser/ui/views/hats/hats_browsertest.cc
+++ b/chrome/browser/ui/views/hats/hats_browsertest.cc
@@ -33,6 +33,7 @@
#include "components/ukm/test_ukm_recorder.h"
#include "components/version_info/version_info.h"
#include "content/public/test/browser_test.h"
+#include "content/public/test/test_navigation_observer.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "third_party/blink/public/common/page/page_zoom.h"
#include "ui/views/test/views_test_utils.h"
@@ -355,6 +356,10 @@
IN_PROC_BROWSER_TEST_F(HatsNextWebDialogBrowserTest, NewWebContents) {
ASSERT_TRUE(embedded_test_server()->Start());
+ content::TestNavigationObserver navigation_observer(nullptr);
+ navigation_observer.set_expected_initial_url(GURL("http://foo.com"));
+ navigation_observer.StartWatchingNewWebContents();
+
auto* dialog = new MockHatsNextWebDialog(
browser(), "open_new_web_contents_for_testing", std::nullopt,
std::nullopt, embedded_test_server()->GetURL("/hats/hats_next_mock.html"),
@@ -366,10 +371,15 @@
dialog->WaitForClose();
// Check that a tab with http://foo.com (defined in hats_next_mock.html) has
- // been opened in the regular browser and is active.
+ // been opened in the regular browser and is active, with an opaque initiator
+ // origin.
EXPECT_EQ(
GURL("http://foo.com"),
browser()->GetTabStripModel()->GetActiveWebContents()->GetVisibleURL());
+
+ navigation_observer.WaitForNavigationFinished();
+ ASSERT_TRUE(navigation_observer.last_initiator_origin().has_value());
+ EXPECT_TRUE(navigation_observer.last_initiator_origin()->opaque());
}
// The devtools browser for undocked devtools has no tab strip and can't open
@@ -380,6 +390,10 @@
BrowserWindowInterface* devtools_browser = OpenUndockedDevToolsWindow();
+ content::TestNavigationObserver navigation_observer(nullptr);
+ navigation_observer.set_expected_initial_url(GURL("http://foo.com"));
+ navigation_observer.StartWatchingNewWebContents();
+
auto* dialog = new MockHatsNextWebDialog(
devtools_browser, "open_new_web_contents_for_testing", std::nullopt,
std::nullopt, embedded_test_server()->GetURL("/hats/hats_next_mock.html"),
@@ -391,10 +405,39 @@
dialog->WaitForClose();
// Check that a tab with http://foo.com (defined in hats_next_mock.html) has
- // been opened in the regular browser and is active.
+ // been opened in the regular browser and is active, with an opaque initiator
+ // origin.
EXPECT_EQ(
GURL("http://foo.com"),
browser()->GetTabStripModel()->GetActiveWebContents()->GetVisibleURL());
+
+ navigation_observer.WaitForNavigationFinished();
+ ASSERT_TRUE(navigation_observer.last_initiator_origin().has_value());
+ EXPECT_TRUE(navigation_observer.last_initiator_origin()->opaque());
+}
+
+IN_PROC_BROWSER_TEST_F(HatsNextWebDialogBrowserTest,
+ NewWebContentsBlocksDataUrl) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ content::TestNavigationObserver navigation_observer(
+ GURL("data:text/html,<h1>blocked</h1>"), /*quit_mode=*/{},
+ /*ignore_uncommitted_navigations=*/false);
+ navigation_observer.StartWatchingNewWebContents();
+
+ auto* dialog = new MockHatsNextWebDialog(
+ browser(), "open_data_url_for_testing", std::nullopt, std::nullopt,
+ embedded_test_server()->GetURL("/hats/hats_next_mock.html"),
+ base::Seconds(100), base::DoNothing(), base::DoNothing(), {}, {});
+
+ EXPECT_CALL(*hats_service(), HatsNextDialogClosed);
+ dialog->WaitForClose();
+ navigation_observer.WaitForNavigationFinished();
+
+ // Top-level data: URL navigations initiated by a renderer are blocked by
+ // BlockedSchemeNavigationThrottle.
+ EXPECT_FALSE(navigation_observer.last_navigation_succeeded());
+ EXPECT_EQ(net::ERR_ABORTED, navigation_observer.last_net_error_code());
}
IN_PROC_BROWSER_TEST_F(HatsNextWebDialogBrowserTest, DialogResize) {
diff --git a/chrome/test/data/hats/hats_next_mock.html b/chrome/test/data/hats/hats_next_mock.html
index 505f2fd..fc1db5e 100644
--- a/chrome/test/data/hats/hats_next_mock.html
+++ b/chrome/test/data/hats/hats_next_mock.html
@@ -44,6 +44,11 @@
history.pushState('', '', '#close');
}
+ if (params.get('trigger_id') == "open_data_url_for_testing") {
+ window.open('data:text/html,<h1>blocked</h1>', '_blank');
+ history.pushState('', '', '#close');
+ }
+
if (params.get('trigger_id') == "resize_for_testing") {
// Size wrapping div element to a known size, with one dimension smaller
// than the by-default renderer-enforced minimum.
Original Bug Report
Bypass of extension WAR via HaTS dialog window.open() laundering
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.
Overview: The HaTS dialog incorrectly forwards window.open() requests from its survey renderer to the main browser profile as browser-initiated navigations. This strips the initiator_origin from the navigation parameters. A compromised HaTS renderer can potentially leverage this to bypass web_accessible_resources checks and navigate to internal, privileged extension pages.
Affected files:
chrome/browser/ui/views/hats/hats_next_web_dialog.cc
Estimated timestamp from git blame: 2020-10-30
Summary
There is a potential trust-laundering vulnerability in the Happiness Tracking Survey (HaTS) dialog. When the survey renderer (hosted at https://www.google.com) calls window.open(), the request is intercepted by HatsWebView::CreateCustomWebContents. This implementation forwards the request to the user’s regular browser profile using improperly initialized OpenURLParams, causing the navigation to appear browser-initiated. This strips the initiator origin, which can bypass extension web_accessible_resources (WAR) policies.
Technical Details
In chrome/browser/ui/views/hats/hats_next_web_dialog.cc, HatsWebView::CreateCustomWebContents handles renderer-initiated window creation to redirect links from the restricted Off-The-Record (OTR) HaTS profile into the regular browser.
When it constructs content::OpenURLParams, it explicitly sets is_renderer_initiated to false and leaves initiator_origin uninitialized (std::nullopt). Because is_renderer_initiated is false, the constructor also implicitly sets user_gesture to true.
When this laundered navigation reaches ExtensionNavigationThrottle::WillStartOrRedirectRequest, it bypasses security checks. The throttle automatically trusts navigations lacking an initiator origin (assuming they are legitimate omnibox or bookmark navigations) via the following check:
if (!navigation_handle()->GetInitiatorOrigin().has_value()) {
return content::NavigationThrottle::PROCEED;
}
Because the origin was lost during the HaTS laundering, the navigation proceeds, skipping the web_accessible_resources check. Furthermore, because the HaTS WebContents does not have a PopupBlockerTabHelper attached, the renderer can trigger window.open automatically via JavaScript without any user interaction.
Impact
If an attacker gains code execution in the HaTS renderer (e.g., via XSS on the survey page), they can potentially trigger navigations to privileged, non-web-accessible extension pages in the user’s regular profile. By appending malicious parameters to the URL, this could be leveraged to achieve Universal XSS (UXSS) in the context of an installed extension.
Potential Steps to Reproduce
(Note: These are suggested steps; our tooling agent does not have the ability to run code or execute a live proof of concept).
- An attacker discovers an XSS vulnerability on the Google-hosted HaTS survey page.
- The attacker triggers the HaTS dialog for a user.
- The injected XSS payload executes
window.open('chrome-extension://<target_extension_id>/internal_page.html?xss_payload=...', '_blank');immediately upon load, bypassing popup blockers. - The internal extension page opens in a new tab in the user’s main profile, successfully bypassing WAR restrictions.
Suggested Fix
When constructing OpenURLParams in HatsWebView::CreateCustomWebContents (chrome/browser/ui/views/hats/hats_next_web_dialog.cc), accurately reflect the renderer’s involvement:
- Set
is_renderer_initiatedtotrue. - Set
initiator_origintoopener->GetLastCommittedOrigin(). - Pass the
source_site_instanceto the navigation parameters.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
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.