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 <tov@chromium.org>
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 <msramek@chromium.org>
Commit-Queue: Tom Van Goethem <tov@chromium.org>
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.