CVE-2026-5899
Overview
Background
- JavaScript dialog title
- The “<origin> says” (or “This page says”) banner Chrome prepends to
alert()/confirm()/prompt()dialogs so the user can tell which site is speaking. - Precursor origin
- For an opaque origin (such as a
data:URL document), the origin of the context that created it, which Chrome tracks so it can attribute behavior back to the initiator. - `data:` URL main frame
- A top-level document whose URL is a
data:URI; it has an opaque origin but may carry a precursor origin inherited from the page that opened it. - `util::DialogTitle`
- The helper in
dialog_util.ccthat computes the dialog banner text from the main-frame and alerting-frame origins.
Root Cause Analysis
When a top-level data: URL document (for example one opened via a link with target="_blank") raised a JavaScript dialog, util::DialogTitle derived the banner from the main frame’s origin only. Because a data: document carries an opaque origin with a precursor, the code could resolve and display that precursor origin’s host in the banner, making the dialog appear to come from the HTTP site that spawned the tab rather than from the untrusted data: content. util::DialogTitle had no access to the actual committed URL, so it could not tell that the main frame was a non-standard data: document that should be shown generically.
The fix threads the main frame’s GetLastCommittedURL() down through AppModalDialogManager::GetSiteFrameTitle into util::DialogTitle, so that when the top-level document is a data: URL the function suppresses the precursor origin and falls back to the generic “This page says” text. The regression test TitleForNonHTTPOriginInMainFrame asserts exactly this: a data: URL new tab yields “This page says” while an HTTP tab yields “a.com:<port> says”.
data: main frame could surface an inherited precursor host and misattribute the dialog to a trusted site; the fix supplies the committed URL so the code can recognize the data: scheme and show a neutral title instead.Attack Path
- Open an untrusted top-level `data:` document
An attacker-controlled HTTP page opens a new tab/window pointing at a
data:URL, so the new top-level document has an opaque origin whose precursor is the attacker’s (or any) spawning origin. - Raise a JavaScript dialog
Script in the
data:document callsalert()/confirm()/prompt(), invokingAppModalDialogManagerto build the dialog title. - Misleading banner is shown
Pre-patch,
util::DialogTitleresolved the precursor origin and displayed it, so the dialog banner named a host the user might trust rather than disclosing that the content is an opaquedata:page (spoofed security UI). - Suppression after fix
With the committed URL now passed in, a
data:main frame produces the generic “This page says” banner, removing the misattribution.
Impact Assessment
data: top-level document could be labeled with a precursor origin, leading the user to attribute attacker-chosen dialog text to a different, possibly trusted, site. This is reachable by ordinary web content in a stock build (opening a data: URL tab and calling alert()), requires no special flag or extension, and the vendor advisory rates it low severity; no memory-safety or escalation beyond UI spoofing is shown by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
IN_PROC_BROWSER_TEST_Pchrome/browser/ui/javascript_dialogs/javascript_dialog_browsertest.cc |
modified | |
GURLcomponents/javascript_dialogs/app_modal_dialog_manager.cc |
modified | |
GURLcomponents/javascript_dialogs/app_modal_dialog_manager.h |
modified | |
Origincomponents/javascript_dialogs/app_modal_dialog_manager.h |
modified |
Files Changed
chrome/browser/ui/javascript_dialogs/chrome_app_modal_dialog_manager_delegate.ccchrome/browser/ui/javascript_dialogs/javascript_dialog_browsertest.cccomponents/javascript_dialogs/app_modal_dialog_manager.cccomponents/javascript_dialogs/app_modal_dialog_manager.hcomponents/javascript_dialogs/app_modal_dialog_manager_unittest.cccomponents/javascript_dialogs/core/dialog_util.cc
Audit Directions
- Opaque-origin UI attributionAudit any security-relevant UI (dialogs, permission prompts, download/origin chips) that formats an origin for display; for opaque origins, check whether a precursor/initiator origin can leak into user-facing text and misattribute content.
- `data:`/`blob:`/`filesystem:` top-level documentsReview code paths that assume the main frame has a standard, displayable origin; non-standard schemes with inherited precursors are a recurring spoofing surface.
- Origin-only vs. URL-aware decisionsFlag helpers that make display or trust decisions from
url::Originalone where the committedGURL(scheme) is needed to distinguish genuinely standard origins from opaque ones.
Patch
From a8e095a03482aaa9554da7835c8ccbb0f146bca4 Mon Sep 17 00:00:00 2001 From: Antonio Sartori <[email protected]> Date: Wed, 18 Feb 2026 02:11:40 -0800 Subject: [PATCH] Don't display precursor origin on javascript alerts with data: url This CL tweaks the beheaviour of the util function computing the message to be displayed on javascript alert. If the alerting top-level document has a data: url, we don't display the precursor origin anymore (even if we had one, which only applies in some situations). Bug: 474817168 Change-Id: I0ee8d60d0c0bcd2ddd1bd158ff0aad344870b595 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7462512 Reviewed-by: Avi Drissman <[email protected]> Reviewed-by: Gauthier Ambard <[email protected]> Commit-Queue: Antonio Sartori <[email protected]> Cr-Commit-Position: refs/heads/main@{#1586300} --- diff --git a/chrome/browser/ui/javascript_dialogs/chrome_app_modal_dialog_manager_delegate.cc b/chrome/browser/ui/javascript_dialogs/chrome_app_modal_dialog_manager_delegate.cc index 16e3c449e..19e7abf8 100644 --- a/chrome/browser/ui/javascript_dialogs/chrome_app_modal_dialog_manager_delegate.cc +++ b/chrome/browser/ui/javascript_dialogs/chrome_app_modal_dialog_manager_delegate.cc @@ -45,5 +45,6 @@ } return javascript_dialogs::AppModalDialogManager::GetSiteFrameTitle( + web_contents->GetPrimaryMainFrame()->GetLastCommittedURL(), web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin(), origin); } diff --git a/chrome/browser/ui/javascript_dialogs/javascript_dialog_browsertest.cc b/chrome/browser/ui/javascript_dialogs/javascript_dialog_browsertest.cc index bfdeab1..67bdb13 100644 --- a/chrome/browser/ui/javascript_dialogs/javascript_dialog_browsertest.cc +++ b/chrome/browser/ui/javascript_dialogs/javascript_dialog_browsertest.cc @@ -453,7 +453,8 @@ // Tests that the title for a dialog generated from a page with a non-HTTP URL // that was spawned by an HTTP URL has that HTTP URL used for the title. -IN_PROC_BROWSER_TEST_P(JavaScriptDialogOriginTest, TitleForNonHTTPOrigin) { +IN_PROC_BROWSER_TEST_P(JavaScriptDialogOriginTest, + TitleForNonHTTPOriginInSubframe) { GURL url = embedded_test_server()->GetURL("a.com", "/title1.html"); ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url)); content::WebContents* tab = @@ -486,6 +487,49 @@ dialog_manager->GetTitle(tab, subframe->GetLastCommittedOrigin())); } +IN_PROC_BROWSER_TEST_P(JavaScriptDialogOriginTest, + TitleForNonHTTPOriginInMainFrame) { + GURL url = embedded_test_server()->GetURL("a.com", "/title1.html"); + ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url)); + content::WebContents* tab = + browser()->tab_strip_model()->GetActiveWebContents(); + + // Create a popup / new tab. + content::TestNavigationObserver opened_tab_observer(nullptr); + opened_tab_observer.StartWatchingNewWebContents(); + GURL test_url(GetParam()); + std::string script = content::JsReplace(R"( + let a = document.createElement("a"); + a.href = $1; + a.target = "_blank"; + a.id = "link"; + a.textContent = "Open a new tab"; + document.body.appendChild(a);)", + test_url); + ASSERT_TRUE(content::ExecJs(tab, script)); + content::SimulateMouseClickOrTapElementWithId(tab, "link"); + opened_tab_observer.Wait(); + ASSERT_EQ(2, browser()->tab_strip_model()->count()); + + content::WebContents* opened_tab = + browser()->tab_strip_model()->GetActiveWebContents(); + + // Wait until newly opened tab is fully loaded. + ASSERT_TRUE(WaitForLoadStop(opened_tab)); + + // Verify the title that would be used for a dialog spawned by the new tab. + javascript_dialogs::AppModalDialogManager* dialog_manager = + javascript_dialogs::AppModalDialogManager::GetInstance(); + EXPECT_EQ(base::UTF8ToUTF16( + test_url.SchemeIs("data") + ? "This page says" + : base::StringPrintf("a.com:%d says", + embedded_test_server()->port())), + dialog_manager->GetTitle( + opened_tab, + opened_tab->GetPrimaryMainFrame()->GetLastCommittedOrigin())); +} + IN_PROC_BROWSER_TEST_F(JavaScriptDialogTest, HandlesSwappingTabWithDialogIntoSplitView) { // Create three tabs with the first two in a split view. diff --git a/components/javascript_dialogs/app_modal_dialog_manager.cc b/components/javascript_dialogs/app_modal_dialog_manager.cc index 9462609..f956412 100644 --- a/components/javascript_dialogs/app_modal_dialog_manager.cc +++ b/components/javascript_dialogs/app_modal_dialog_manager.cc @@ -22,6 +22,8 @@ #include "ui/base/l10n/l10n_util.h" #include "ui/gfx/font_list.h" +class GURL; + namespace javascript_dialogs { namespace { @@ -82,15 +84,18 @@ // Otherwise, return the formatted URL. return GetSiteFrameTitle( + web_contents->GetPrimaryMainFrame()->GetLastCommittedURL(), web_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin(), alerting_frame_origin); } // static std::u16string AppModalDialogManager::GetSiteFrameTitle( + const GURL& main_frame_url, const url::Origin& main_frame_origin, const url::Origin& alerting_frame_origin) { - return util::DialogTitle(main_frame_origin, alerting_frame_origin); + return util::DialogTitle(main_frame_url, main_frame_origin, + alerting_frame_origin); } void AppModalDialogManager::RunJavaScriptDialog( diff --git a/components/javascript_dialogs/app_modal_dialog_manager.h b/components/javascript_dialogs/app_modal_dialog_manager.h index d9e3e97..bf5520d 100644 --- a/components/javascript_dialogs/app_modal_dialog_manager.h +++ b/components/javascript_dialogs/app_modal_dialog_manager.h @@ -14,6 +14,8 @@ #include "components/javascript_dialogs/app_modal_dialog_manager_delegate.h" #include "content/public/browser/javascript_dialog_manager.h" +class GURL; + namespace url { class Origin; } @@ -81,6 +83,7 @@ bool reset_state) override; static std::u16string GetSiteFrameTitle( + const GURL& main_frame_url, const url::Origin& main_frame_origin, const url::Origin& alerting_frame_origin); diff --git a/components/javascript_dialogs/app_modal_dialog_manager_unittest.cc b/components/javascript_dialogs/app_modal_dialog_manager_unittest.cc index d758c52..723343e9 100644 --- a/components/javascript_dialogs/app_modal_dialog_manager_unittest.cc +++ b/components/javascript_dialogs/app_modal_dialog_manager_unittest.cc @@ -47,8 +47,7 @@ "An embedded page on this page says"}, // data: - // /!\ NOTE that this is for data URLs entered directly in the omnibox. - // For pages that generate frames with data URLs, see the browsertest. + // See also the javascript_dialog_browsertest. // - main frame: {"data main frame", "data:blahblah", true, "", "This page says"}, // - subframe: @@ -104,7 +103,8 @@ ? main_frame_origin : url::Origin::Create(GURL(test_case.alerting_frame_url)); std::u16string result = AppModalDialogManager::GetSiteFrameTitle( - main_frame_origin, alerting_frame_origin); + GURL(test_case.main_frame_url), main_frame_origin, + alerting_frame_origin); EXPECT_EQ(test_case.expected, base::UTF16ToUTF8(result)); } } diff --git a/components/javascript_dialogs/core/dialog_util.cc b/components/javascript_dialogs/core/dialog_util.cc index a4d69330..23136f92 100644 --- a/components/javascript_dialogs/core/dialog_util.cc +++ b/components/javascript_dialogs/core/dialog_util.cc @@ -11,6 +11,7 @@ #include "url/gurl.h" #include "url/origin.h" #include "url/scheme_host_port.h" +#include "url/url_constants.h" namespace javascript_dialogs::util { @@ -33,7 +34,8 @@ precursor.scheme(), precursor.host(), precursor.port()); } -std::u16string DialogTitle(const url::Origin& main_frame_origin, +std::u16string DialogTitle(const GURL& main_frame_url, + const url::Origin& main_frame_origin, const url::Origin& alerting_frame_origin) { // Note that `Origin::Create()` handles unwrapping of `blob:` and // `filesystem:` schemed URLs, so no special handling is needed for that. @@ -47,7 +49,12 @@ bool is_same_origin_as_main_frame = unwrapped_alerting_frame_origin.IsSameOriginWith( unwrapped_main_frame_origin); - if (unwrapped_alerting_frame_origin.GetURL().IsStandard() && +
Regression Test / PoC
diff --git a/chrome/browser/ui/javascript_dialogs/javascript_dialog_browsertest.cc b/chrome/browser/ui/javascript_dialogs/javascript_dialog_browsertest.cc
index bfdeab1..67bdb13 100644
--- a/chrome/browser/ui/javascript_dialogs/javascript_dialog_browsertest.cc
+++ b/chrome/browser/ui/javascript_dialogs/javascript_dialog_browsertest.cc
@@ -453,7 +453,8 @@
// Tests that the title for a dialog generated from a page with a non-HTTP URL
// that was spawned by an HTTP URL has that HTTP URL used for the title.
-IN_PROC_BROWSER_TEST_P(JavaScriptDialogOriginTest, TitleForNonHTTPOrigin) {
+IN_PROC_BROWSER_TEST_P(JavaScriptDialogOriginTest,
+ TitleForNonHTTPOriginInSubframe) {
GURL url = embedded_test_server()->GetURL("a.com", "/title1.html");
ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url));
content::WebContents* tab =
@@ -486,6 +487,49 @@
dialog_manager->GetTitle(tab, subframe->GetLastCommittedOrigin()));
}
+IN_PROC_BROWSER_TEST_P(JavaScriptDialogOriginTest,
+ TitleForNonHTTPOriginInMainFrame) {
+ GURL url = embedded_test_server()->GetURL("a.com", "/title1.html");
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), url));
+ content::WebContents* tab =
+ browser()->tab_strip_model()->GetActiveWebContents();
+
+ // Create a popup / new tab.
+ content::TestNavigationObserver opened_tab_observer(nullptr);
+ opened_tab_observer.StartWatchingNewWebContents();
+ GURL test_url(GetParam());
+ std::string script = content::JsReplace(R"(
+ let a = document.createElement("a");
+ a.href = $1;
+ a.target = "_blank";
+ a.id = "link";
+ a.textContent = "Open a new tab";
+ document.body.appendChild(a);)",
+ test_url);
+ ASSERT_TRUE(content::ExecJs(tab, script));
+ content::SimulateMouseClickOrTapElementWithId(tab, "link");
+ opened_tab_observer.Wait();
+ ASSERT_EQ(2, browser()->tab_strip_model()->count());
+
+ content::WebContents* opened_tab =
+ browser()->tab_strip_model()->GetActiveWebContents();
+
+ // Wait until newly opened tab is fully loaded.
+ ASSERT_TRUE(WaitForLoadStop(opened_tab));
+
+ // Verify the title that would be used for a dialog spawned by the new tab.
+ javascript_dialogs::AppModalDialogManager* dialog_manager =
+ javascript_dialogs::AppModalDialogManager::GetInstance();
+ EXPECT_EQ(base::UTF8ToUTF16(
+ test_url.SchemeIs("data")
+ ? "This page says"
+ : base::StringPrintf("a.com:%d says",
+ embedded_test_server()->port())),
+ dialog_manager->GetTitle(
+ opened_tab,
+ opened_tab->GetPrimaryMainFrame()->GetLastCommittedOrigin()));
+}
+
IN_PROC_BROWSER_TEST_F(JavaScriptDialogTest,
HandlesSwappingTabWithDialogIntoSplitView) {
// Create three tabs with the first two in a split view.
diff --git a/components/javascript_dialogs/app_modal_dialog_manager_unittest.cc b/components/javascript_dialogs/app_modal_dialog_manager_unittest.cc
index d758c52..723343e9 100644
--- a/components/javascript_dialogs/app_modal_dialog_manager_unittest.cc
+++ b/components/javascript_dialogs/app_modal_dialog_manager_unittest.cc
@@ -47,8 +47,7 @@
"An embedded page on this page says"},
// data:
- // /!\ NOTE that this is for data URLs entered directly in the omnibox.
- // For pages that generate frames with data URLs, see the browsertest.
+ // See also the javascript_dialog_browsertest.
// - main frame:
{"data main frame", "data:blahblah", true, "", "This page says"},
// - subframe:
@@ -104,7 +103,8 @@
? main_frame_origin
: url::Origin::Create(GURL(test_case.alerting_frame_url));
std::u16string result = AppModalDialogManager::GetSiteFrameTitle(
- main_frame_origin, alerting_frame_origin);
+ GURL(test_case.main_frame_url), main_frame_origin,
+ alerting_frame_origin);
EXPECT_EQ(test_case.expected, base::UTF16ToUTF8(result));
}
}
Original Bug Report
Unviersal CSP Bypass/XSS & Privileged chrome:// page XSS via Browser History Sidebar navigation
Steps to reproduce the problem
Data URIs containing JavaScript payloads bypass Content Security Policy when reopened from Chrome’s history sidebar, leading to XSS on the original origin. Additionally, this can be escalated to execute JavaScript in the privileged chrome://history/ context.
Problem Description
Affected
Chrome/Chromium, Edge, and Chromium-based browsers.
Vulnerability Details
Bug #1: CSP Bypass leading to XSS
Any page with a strict CSP that allows data: in href or img-src is vulnerable to XSS:
- User right-clicks data URI link/image → Open in new tab
- CSP blocks script execution (expected)
- User closes the data URI tab
- User opens history sidebar (three dots → History)
- User clicks data URI entry from sidebar
- Script executes in original origin context, CSP bypassed, XSS achieved
Bug #2: Privileged Context XSS
- Open
chrome://history/(Ctrl+H) - Right-click data URI entry → Open in new tab
- CSP blocks (expected), close tab
- Open history sidebar, click same data URI
- Script executes under
chrome://history/context
Payloads
<a href=“data:text/html,<script>alert(‘XSS’)</script>” target="_blank">Click</a>
<img src=“data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20onload%3D%27alert(1)%27%2F%3E”>
Root Cause
Data URIs reopened from history sidebar lose their CSP association, allowing script execution without policy enforcement while retaining the original origin context.
Impact
- XSS on any origin allowing data URIs in href/src
- Complete CSP bypass regardless of policy strictness
- Privileged code execution in chrome:// context
- Potential access to sensitive browser APIs
Note
Even though data URIs are somewhat more restricted from accessing origin context, this bypass demonstrates a serious gap in CSP enforcement through the history sidebar. The ability to execute arbitrary JavaScript in both the original origin and privileged chrome:// contexts undermines the security guarantees that CSP is designed to provide.
Severity
High/Critical
Summary
Unviersal CSP Bypass/XSS & Privileged chrome:// page XSS via Browser History Sidebar navigation
Custom Questions
Reporter credit:
Islam Rzayev
Additional Data
Category: Security
Chrome Channel: Stable
Regression: N/A \