CVE-2026-79041
Overview
Background
- Direct launch scheme
- On macOS (stable channel, per the test’s
GetDirectLaunchUrlScheme()early-return), Chrome registersgoogle-chrome://(orchromium://) so an external caller can ask Chrome to open the URL embedded after the scheme;startup::StripGoogleChromeSchemeremoves the prefix in-[AppController application:openURLs:]. - `StartupTab` / `is_untrusted_launch`
- A startup tab carries a URL plus an
is_untrusted_launchbit; per the commit message, when it is trueStartupBrowserCreatorImplsets an opaqueinitiator_originon the resulting navigation instead of leaving itnullopt. - `ExtensionNavigationThrottle` / Web Accessible Resources (WAR)
- The throttle blocks navigations from web origins to
chrome-extension://resources the extension has not declared web-accessible, but treats a navigation with no initiator as a trusted browser-internal request and skips the check.
Root Cause Analysis
Before the patch, -[AppController application:openURLs:] stripped the google-chrome:// prefix, validated the inner URL with startup::ValidateLaunchUrlWebSafe, and then pushed it into a plain std::vector<GURL> exactly like any other URL Launch Services handed over. That threw away the fact that the URL came from an untrusted external launch: openUrlsReplacingNTP:, the _startupUrls queue, OpenUrlsInBrowser and OpenUrlsInBrowserWithProfile all passed bare GURLs, and OpenUrlsInBrowserWithProfile called StartupBrowserCreatorImpl::OpenURLsInBrowser, where (per the commit message) is_untrusted_launch defaulted to false. The navigation therefore committed with initiator_origin == nullopt, and ExtensionNavigationThrottle treated it as a trusted internal navigation, skipping the WAR check for chrome-extension:// targets. The invariant violated is that provenance of an externally supplied URL must survive all the way to navigation creation so the browser can authorize it as untrusted.
The fix replaces the GURL plumbing with StartupTabs end-to-end, tags direct-launch URLs with is_untrusted_launch=true at the point they are parsed, keeps that bit when tabs are queued before startup completes, and calls StartupBrowserCreatorImpl::OpenTabsInBrowser so an opaque initiator is attached; the new browser test OpenSchemeUrlSetsOpaqueInitiator checks that the committed origin is opaque. This commit is a reland of an earlier fix that was reverted for an apparently unrelated Linux build failure, so the code changes match the original fix.
google-chrome:// path checked the URL once and then turned it into a plain GURL, so by the time a tab opened, an attacker-supplied external URL looked like a browser-internal navigation with no initiator. The fix carries an is_untrusted_launch flag in a StartupTab through every hop. This produces an opaque initiator, so the extension WAR authorization check is no longer skipped.Attack Path
- Craft a direct-launch URL
The attacker builds a
google-chrome://<target>URL whose inner target (per the commit message) points at an extension page, and gets it passed to Chrome via Launch Services, e.g. from a link in another application or a web page that hands off the external protocol. Whether a confirmation prompt appears is not shown in the diff (inferred: some user interaction is likely needed). - Prefix stripped and validated
-[AppController application:openURLs:]strips the scheme and accepts the inner URL ifstartup::ValidateLaunchUrlWebSafepasses. Pre-patch it was stored as a plainGURLwith no untrusted marking. - Provenance lost on the way to a tab
The URL is either queued in
_startupUrls(if startup has not finished) or sent throughOpenUrlsInBrowser→OpenUrlsInBrowserWithProfile→StartupBrowserCreatorImpl::OpenURLsInBrowser, whereis_untrusted_launchdefaulted to false and no initiator origin was set. - WAR check skipped
With
initiator_originunset,ExtensionNavigationThrottletreats the navigation as trusted and lets it load extension resources that are not declared web-accessible, which a web origin should not be able to open.
Impact Assessment
google-chrome:// URL can make Chrome load a top-level extension page that the extension never made web-accessible. This defeats the browser-process authorization meant to isolate an extension’s internal pages. The bug needs macOS, a channel where the direct-launch scheme is registered (the test returns early on Beta/Dev/Canary), an installed target extension, and some way to get Launch Services to deliver the URL. Turning this into abuse of a particular extension page (for example, driving privileged extension UI with attacker-chosen URL parameters) is (inferred). The advisory gives only “Missing authorization in Browser” and does not report in-the-wild exploitation.Changed Functions
| Function | Change | Notes |
|---|---|---|
BookmarkMenuBridgechrome/browser/app_controller_mac.h |
modified | |
forchrome/browser/app_controller_mac.mm |
modified | |
ifchrome/browser/app_controller_mac.mm |
modified |
Files Changed
chrome/browser/app_controller_mac.mmchrome/browser/app_controller_mac_google_chrome_scheme_browsertest.mmchrome/browser/app_controller_mac.hchrome/browser/BUILD.gnchrome/browser/ui/startup/startup_browser_creator_impl.h
Audit Directions
- Provenance-dropping type conversionsLook for places where an externally sourced URL (Launch Services, command line, Handoff, shortcut files, OS share targets, protocol handlers) is converted to a bare
GURLbefore navigation. Any such hop can silently turn untrusted input into a “browser-initiated” navigation withinitiator_origin == nullopt. - Null-initiator trust in throttlesCheck
ExtensionNavigationThrottleand similar checks that treat a missinginitiator_originas trusted. Enumerate every browser-side caller that can create such navigations from attacker-influenced data on each platform, not just macOS. - Startup queuing pathsPre-startup queues such as
_startupTabsoften use different code paths from post-startup handling. Verify that security attributes (untrusted flag, profile, incognito) survive the queue/drain cycle and the.crweblocshortcut branch.
Patch
From 831a446cd4ccd3f2738e0f622093d7f7eed7b4f7 Mon Sep 17 00:00:00 2001 From: Deepak Ravichandran <[email protected]> Date: Thu, 30 Jul 2026 04:39:12 -0700 Subject: [PATCH] Reland "[macOS] Preserve untrusted launch status for direct launch URLs" This reverts commit bfd17025198217b42c660ee15ca8383dc69eea66. Reason for revert: I am not sure if the original revert was done on the correct CL. The error messages do not make sense. Original change's description: > Revert "[macOS] Preserve untrusted launch status for direct launch URLs" > > This reverts commit e964c0794fca66bff95a3e78973b32d9e1adf4d0. > > Reason for revert: Build failure on Linux > > Failure Link: https://ci.chromium.org/ui/p/chromium/builders/ci/linux-arm64-dbg/8808/overview > > Original change's description: > > [macOS] Preserve untrusted launch status for direct launch URLs > > > > On macOS, external URLs using the google-chrome:// (or chromium://) > > scheme are processed in AppController application:openURLs:. Previously, > > stripped URLs were stored as standard GURLs in std::vector<GURL>, > > dropping the provenance that they originated from an external launch. > > > > As a result, when StartupBrowserCreatorImpl opened these URLs into tabs, > > is_untrusted_launch defaulted to false. This left > > params.initiator_origin as nullopt, causing ExtensionNavigationThrottle > > to treat the navigation as a trusted internal request and bypassing Web > > Accessible Resources (WAR) checks for extension pages. > > > > This CL fixes the issue by: > > > > 1. Updating AppController to maintain and propagate StartupTabs instead > > of std::vector<GURL>, explicitly marking direct launch URLs with > > is_untrusted_launch = true. > > > > 2. Preserving StartupTabs (and their untrusted launch status) when > > queuing urls before startup is complete. > > > > 3. Passing StartupTabs through OpenStartupTabsInBrowserWithProfile so > > StartupBrowserCreatorImpl sets an opaque initiator_origin on launch. > > > > 4. Enabling and adding browser test coverage in > > app_controller_mac_google_chrome_scheme_browsertest.mm to verify an > > opaque initiator origin is assigned. > > > > Bug: 518249083,446672134 > > Change-Id: Ib9d319d8c58fb3c5fcd45850aa6d9591f524b1e6 > > Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8101603 > > Commit-Queue: Deepak Ravichandran <[email protected]> > > Reviewed-by: Avi Drissman <[email protected]> > > Cr-Commit-Position: refs/heads/main@{#1670621} > > Bug: 518249083,446672134 > No-Presubmit: true > No-Tree-Checks: true > No-Try: true > Change-Id: I026f2f2b1fb58ecb2aea2ede390a7151aa91687d > Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8169720 > Auto-Submit: Andrea Gomez <[email protected]> > Reviewed-by: Andrea Gomez <[email protected]> > Bot-Commit: [email protected] <[email protected]> > Owners-Override: Andrea Gomez <[email protected]> > Commit-Queue: Andrea Gomez <[email protected]> > Cr-Commit-Position: refs/heads/main@{#1670627} Bug: 518249083,446672134 Change-Id: Iabe22218cc306f5a1d7014a0821da03b763b7164 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8166529 Bot-Commit: [email protected] <[email protected]> Commit-Queue: Deepak Ravichandran <[email protected]> Reviewed-by: Andrea Gomez <[email protected]> Owners-Override: Andrea Gomez <[email protected]> Cr-Commit-Position: refs/heads/main@{#1670975} --- diff --git a/chrome/browser/app_controller_mac.h b/chrome/browser/app_controller_mac.h index 64d79d71..801fbd1 100644 --- a/chrome/browser/app_controller_mac.h +++ b/chrome/browser/app_controller_mac.h @@ -22,6 +22,7 @@ #include "base/files/file_path.h" #include "chrome/browser/profiles/keep_alive/scoped_profile_keep_alive.h" +#include "chrome/browser/ui/startup/startup_tab.h" #include "components/prefs/pref_change_registrar.h" class BookmarkMenuBridge; @@ -131,8 +132,6 @@ // Delegate method to return the dock menu. - (NSMenu*)applicationDockMenu:(NSApplication*)sender; -// Get the URLs that Launch Services expects the browser to open at startup. -- (const std::vector<GURL>&)startupUrls; - (BookmarkMenuBridge*)bookmarkMenuBridge; - (HistoryMenuBridge*)historyMenuBridge; diff --git a/chrome/browser/app_controller_mac.mm b/chrome/browser/app_controller_mac.mm index c0e3326..9c306fbd 100644 --- a/chrome/browser/app_controller_mac.mm +++ b/chrome/browser/app_controller_mac.mm @@ -165,10 +165,6 @@ return g_pending_requests; } -// Open the urls in the last used browser from a regular profile. -void OpenUrlsInBrowserWithProfile(const std::vector<GURL>& urls, - Profile* profile); - // Returns true if the profile requires signin before being used. bool IsProfileSignedOut(const base::FilePath& profile_path); @@ -433,19 +429,20 @@ return profile_path_info.path; } -// Open the urls in the last used browser. Loads the profile asynchronously if -// needed. -void OpenUrlsInBrowser(std::vector<GURL> urls) { - std::vector<GURL> regular_urls; +void OpenStartupTabsInBrowserWithProfile(const StartupTabs& tabs, + Profile* profile); + +void OpenStartupTabsInBrowser(StartupTabs tabs) { + StartupTabs regular_tabs; std::vector<base::FilePath> shortcuts; - for (auto& url : urls) { + for (auto& tab : tabs) { base::FilePath path; - if (net::FileURLToFilePath(url, &path) && + if (net::FileURLToFilePath(tab.url, &path) && path.Extension() == shortcuts::ChromeWeblocFile::kFileExtension) { shortcuts.push_back(path); } else { - regular_urls.push_back(std::move(url)); + regular_tabs.push_back(std::move(tab)); } } @@ -458,7 +455,7 @@ base::TaskShutdownBehavior::BLOCK_SHUTDOWN}, base::BindOnce( [](const std::vector<base::FilePath>& shortcuts) { - base::flat_map<base::FilePath, std::vector<GURL>> profile_url_map; + base::flat_map<base::FilePath, StartupTabs> profile_tab_map; for (const auto& path : shortcuts) { auto shortcut = shortcuts::ChromeWeblocFile::LoadFromFile(path); // TODO: Consider opening the original file URL? @@ -467,8 +464,6 @@ } bool is_shortcut_url_valid = startup::ValidateLaunchUrlWebUnsafe(shortcut->target_url()); - // Do not allow chrome sensitive urls to be launched from a .crwebloc - // file. #if BUILDFLAG(ENABLE_EXTENSIONS) is_shortcut_url_valid = is_shortcut_url_valid || shortcut->target_url().SchemeIs( @@ -479,45 +474,44 @@ << shortcut->target_url(); continue; } - profile_url_map[shortcut->profile_path_name().path()].push_back( - shortcut->target_url()); + profile_tab_map[shortcut->profile_path_name().path()] + .emplace_back(shortcut->target_url(), + /*is_untrusted_launch=*/false); } - return profile_url_map; + return profile_tab_map; }, std::move(shortcuts)), - base::BindOnce( - [](const base::flat_map<base::FilePath, std::vector<GURL>> - profile_url_map) { - const base::FilePath& user_data_dir = - g_browser_process->profile_manager()->user_data_dir(); - ProfileAttributesStorage& profile_attributes_storage = - g_browser_process->profile_manager() - ->GetProfileAttributesStorage(); - for (const auto& [profile, urls_for_profile] : profile_url_map) { - const base::FilePath profile_path = - user_data_dir.Append(profile); - if (profile_attributes_storage.GetProfileAttributesWithPath( - profile_path)) { - RunInProfileSafely( - profile_path, - base::BindOnce(&OpenUrlsInBrowserWithProfile, - urls_for_profile), - app_controller_mac::kShowProfilePickerOnFailure); - } else { - // If the target profile doesn't exist, fall back to the - // last profile. - RunInLastProfileSafely( - base::BindOnce(&OpenUrlsInBrowserWithProfile, - urls_for_profile), - app_controller_mac::kShowProfilePickerOnFailure); - } - } - })); + base::BindOnce([](const base::flat_map<base::FilePath, StartupTabs> + profile_tab_map) { + const base::FilePath& user_data_dir = + g_browser_process->profile_manager()->user_data_dir(); + ProfileAttributesStorage& profile_attributes_storage = + g_browser_process->profile_manager() + ->GetProfileAttributesStorage(); + for (const auto& [profile, tabs_for_profile] : profile_tab_map) { + const base::FilePath profile_path = user_data_dir.Append(profile); + if (profile_attributes_storage.GetProfileAttributesWithPath( + profile_path)) { + app_controller_mac::RunInProfileSafely( + profile_path, + base::BindOnce(&OpenStartupTabsInBrowserWithProfile, + tabs_for_profile), + app_controller_mac::kShowProfilePickerOnFailure); + } else { + // If the target profile doesn't exist, fall back to the + // last profile. + app_controller_mac::RunInLastProfileSafely( + base::BindOnce(&OpenStartupTabsInBrowserWithProfile, + tabs_for_profile), + app_controller_mac::kShowProfilePickerOnFailure); + } + } + })); } - if (!regular_urls.empty()) { + if (!regular_tabs.empty()) { app_controller_mac::RunInLastProfileSafely( - base::BindOnce(&OpenUrlsInBrowserWithProfile, regular_urls), + base::BindOnce(&OpenStartupTabsInBrowserWithProfile, regular_tabs), app_controller_mac::kShowProfilePickerOnFailure); } } @@ -551,16 +545,16 @@ - (void)profileWasRemoved:(const base::FilePath&)profilePath forIncognito:(bool)isIncognito; -// This class cannot open urls until startup has finished. The urls that cannot -// be opened are cached in |startupUrls_|. This method must be called exactly -// once after startup has completed. It opens the urls in |startupUrls_|, and -// clears |startupUrls_|. +// This class cannot open tabs until startup has finished. The tabs that cannot +// be opened are cached in |_startupTabs|. This method must be called exactly +// once after startup has completed. It opens the tabs in |_startupTabs|, and +// clears |_startupTabs|. - (void)openStartupUrls; -// Opens a tab for each GURL in |urls|. If there is exactly one tab open before -// this method is called, and that tab is the NTP, then this method closes the -// NTP after all the |urls| have been opened. -- (void)openUrlsReplacingNTP:(std::vector<GURL>)urls; +// Opens a tab for each StartupTab in |tabs|. If there is exactly one tab open +// before this method is called, and that tab is the NTP, then this method +// closes the NTP after all the |tabs| have been opened. +- (void)openStartupTabsReplacingNTP:(StartupTabs)tabs; // Returns |YES| if |webContents| can be sent to another device via Handoff. - (BOOL)isHandoffEligible:(content::WebContents*)webContents; @@ -715,8 +709,8 @@ // If we're told to open URLs (in particular, via |-application:openURLs:| by // Launch Services) before we've launched the browser, we queue them up in - // |startupUrls_| so that they can go in the first browser window/tab. - std::vector<GURL> _startupUrls; + // |_startupTabs| so that they can go in the first browser window/tab. + StartupTabs _startupTabs; BOOL _startupComplete; // Outlets for testing close tab/window menu items. @@ -1255,30 +1249,24 @@ } - (void)openStartupUrls { - DCHECK(_startupComplete); - [self openUrlsReplacingNTP:std::move(_startupUrls)]; - _startupUrls.clear(); + CHECK(_startupComplete); + [self openStartupTabsReplacingNTP:std::move(_startupTabs)]; + _startupTabs.clear(); } -- (void)openUrlsReplacingNTP:(std::vector<GURL>)urls { - if (urls.empty()) - return; - - // On Mac, the URLs are passed in via Cocoa, not command line. The Chrome - // NSApplication is created in MainMessageLoop, and then the shortcut urls - // are passed in via Apple events. At this point, the first browser is - // already loaded in PreMainMessageLoop. If we initialize NSApplication - // before PreMainMessageLoop to capture shortcut URL events, it may cause - // more problems because it relies on things created in PreMainMessageLoop - // and may break existing message loop design. - - // If the browser hasn't started yet, just queue up the URLs. - if (!_startupComplete) { - _startupUrls.insert(_startupUrls.end(), urls.begin(), urls.end()); +- (void)openStartupTabsReplacingNTP:(StartupTabs)tabs { + if (tabs.empty()) { return; } - OpenUrlsInBrowser(std::move(urls)); + if (!_startupComplete) { + for (auto& tab : tabs) { + _startupTabs.push_back(std::move(tab)); + } + return; + } + + OpenStartupTabsInBrowser(std::move(tabs)); } - (void)resetKeepAliveWhileHidden { @@ -2066,7 +2054,7 @@ } - (void)application:(NSApplication*)sender openURLs:(NSArray<NSURL*>*)urls { - std::vector<GURL> gurlVector; + StartupTabs startupTabs; for (NSURL* url in urls) { // Handle the google-chrome:// scheme (and chromium://). // We convert every URL to string here to reuse the shared @@ -2078,15 +2066,17 @@ if (startup::StripGoogleChromeScheme(urlStringView)) { GURL gurl(urlStringView); if (startup::ValidateLaunchUrlWebSafe(gurl)) { - gurlVector.push_back(gurl); + startupTabs.emplace_back(gurl, /*is_untrusted_launch=*/true); } } else { - gurlVector.push_back(net::GURLWithNSURL(url)); + startupTabs.emplace_back(net::GURLWithNSURL(url), + /*is_untrusted_launch=*/false); } } - if (!gurlVector.empty()) - [self openUrlsReplacingNTP:std::move(gurlVector)]; + if (!startupTabs.empty()) { + [self openStartupTabsReplacingNTP:std::move(startupTabs)]; + } } // Show the preferences window, or bring it to the front if it's already @@ -2191,9 +2181,6 @@ return dockMenu; } -- (const std::vector<GURL>&)startupUrls { - return _startupUrls; -} - (BookmarkMenuBridge*)bookmarkMenuBridge { return _bookmarkMenuBridge; @@ -2450,10 +2437,10 @@ return NO; GURL gurl(base::SysNSStringToUTF8([url absoluteString])); - std::vector<GURL> gurlVector; - gurlVector.push_back(gurl); + StartupTabs tabs; + tabs.emplace_back(gurl, /*is_untrusted_launch=*/true); - [self openUrlsReplacingNTP:std::move(gurlVector)]; + [self openStartupTabsReplacingNTP:std::move(tabs)]; return YES; } @@ -2593,8 +2580,8 @@ [AppController.sharedController setLastProfile:profile]; } -void OpenUrlsInBrowserWithProfile(const std::vector<GURL>& urls, - Profile* profile) { +void OpenStartupTabsInBrowserWithProfile(const StartupTabs& tabs, + Profile* profile) { if (!profile) return; // No suitable profile to open the URLs, do nothing. // Prefer a regular (non-incognito) profile @@ -2629,8 +2616,9 @@ first_run::IsChromeFirstRun() ? chrome::startup::IsFirstRun::kYes : chrome::startup::IsFirstRun::kNo; StartupBrowserCreatorImpl launch(base::FilePath(), dummy, first_run); - launch.OpenURLsInBrowser(browser->GetBrowserForMigrationOnly(), - chrome::startup::IsProcessStartup::kNo, urls); + launch.OpenTabsInBrowser(browser->GetBrowserForMigrationOnly(), + chrome::startup::IsProcessStartup::kNo, tabs, + StartupBrowserCreatorImpl::TabOverWrite::kNo); // This NTP check should be replaced once https://crbug.com/41261582 is fixed. if (startupIndex != TabStripModel::kNoTab && @@ -2702,8 +2690,9 @@ [AppController.sharedController applicationDidFinishLaunching:notify]; } ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/app_controller_mac_google_chrome_scheme_browsertest.mm b/chrome/browser/app_controller_mac_google_chrome_scheme_browsertest.mm
index a5927720..458a919d 100644
--- a/chrome/browser/app_controller_mac_google_chrome_scheme_browsertest.mm
+++ b/chrome/browser/app_controller_mac_google_chrome_scheme_browsertest.mm
@@ -12,6 +12,9 @@
#include "chrome/browser/ui/tabs/tab_strip_model.h"
#include "chrome/test/base/in_process_browser_test.h"
#include "chrome/test/base/ui_test_utils.h"
+#include "content/public/browser/navigation_controller.h"
+#include "content/public/browser/navigation_entry.h"
+#include "content/public/browser/web_contents.h"
#include "content/public/test/browser_test.h"
#include "net/base/apple/url_conversions.h"
@@ -31,9 +34,8 @@
using AppControllerGoogleChromeSchemeBrowserTest = InProcessBrowserTest;
-// TODO(crbuig.com/446672134): Fix and re-enable.
IN_PROC_BROWSER_TEST_F(AppControllerGoogleChromeSchemeBrowserTest,
- DISABLED_OpenSchemeUrl) {
+ OpenSchemeUrl) {
std::string scheme = shell_integration::GetDirectLaunchUrlScheme();
if (scheme.empty()) {
// Scheme not supported for this channel (e.g. Beta/Dev/Canary).
@@ -55,6 +57,31 @@
}
IN_PROC_BROWSER_TEST_F(AppControllerGoogleChromeSchemeBrowserTest,
+ OpenSchemeUrlSetsOpaqueInitiator) {
+ std::string scheme = shell_integration::GetDirectLaunchUrlScheme();
+ if (scheme.empty()) {
+ // Scheme not supported for this channel (e.g. Beta/Dev/Canary).
+ return;
+ }
+
+ GURL target_url("http://example.com/");
+ std::string scheme_url_str = scheme + "://" + target_url.spec();
+ NSURL* scheme_url =
+ [NSURL URLWithString:base::SysUTF8ToNSString(scheme_url_str)];
+
+ ui_test_utils::AllBrowserTabAddedWaiter waiter;
+
+ AppController* app_controller = AppController.sharedController;
+ [app_controller application:NSApp openURLs:@[ scheme_url ]];
+
+ content::WebContents* new_tab = waiter.Wait();
+ EXPECT_EQ(new_tab->GetVisibleURL(), target_url);
+
+ EXPECT_TRUE(
+ new_tab->GetPrimaryMainFrame()->GetLastCommittedOrigin().opaque());
+}
+
+IN_PROC_BROWSER_TEST_F(AppControllerGoogleChromeSchemeBrowserTest,
OpenSchemeUrlInvalid) {
std::string scheme = shell_integration::GetDirectLaunchUrlScheme();
if (scheme.empty()) {