Chrome · Browser
CVE-2026-87560
Logic Error in Browser
Overview
Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/background/BUILD.gn |
modified | |
ifchrome/browser/background/background_contents_navigation_throttle.cc |
modified | |
WillStartRequestchrome/browser/background/background_contents_navigation_throttle.cc |
modified | |
WillRedirectRequestchrome/browser/background/background_contents_navigation_throttle.cc |
modified | |
WillStartOrRedirectRequestchrome/browser/background/background_contents_navigation_throttle.cc |
modified |
Files Changed
chrome/browser/BUILD.gnchrome/browser/background/BUILD.gnchrome/browser/background/background_contents.ccchrome/browser/background/background_contents_navigation_throttle.cc
Patch
From 2c850898c887b93e039cd8557ced79fd3f1f3c39 Mon Sep 17 00:00:00 2001
From: Jan Keitel <jkeitel@google.com>
Date: Fri, 14 Aug 2026 01:28:50 -0700
Subject: [PATCH] Enforce web extent on hosted app BackgroundContents
Previously, a hosted app with the background permission could open
a background contents window to an off-extent target URL via
window.open(url, 'name', 'background'). The target URL would be
saved in kRegisteredBackgroundContents preferences and reloaded
without extent validation on browser startup.
This CL implements a 4-layer defense-in-depth fix:
1. In Browser::CreateBackgroundContents, reject target URLs
that are outside the hosted app's manifest web_extent().
2. In BackgroundContentsNavigationThrottle, intercept and block
main-frame navigations outside web_extent().
3. In OnBackgroundContentsNavigated, verify extent before
registering in preferences and unregister if off-extent.
4. In LoadBackgroundContentsFromDictionary, check extent before
restoring saved preference URLs on startup.
TAG=agy
CONV=dda136c7-b0bc-4856-a8dc-3e0b48d7c407
Bug: 511824746
Change-Id: I88bfc6018376f02a5138dfb96e707c014d46620e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8175450
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Commit-Queue: Jan Keitel <jkeitel@google.com>
Cr-Commit-Position: refs/heads/main@{#1679423}
---
diff --git a/chrome/browser/BUILD.gn b/chrome/browser/BUILD.gn
index 1e27b46..54fd32db 100644
--- a/chrome/browser/BUILD.gn
+++ b/chrome/browser/BUILD.gn
@@ -932,6 +932,7 @@
"//chrome/browser/ai",
"//chrome/browser/app_mode",
"//chrome/browser/autocomplete:aim_eligibility_service",
+ "//chrome/browser/background",
"//chrome/browser/battery",
"//chrome/browser/bluetooth",
"//chrome/browser/browsing_data",
diff --git a/chrome/browser/background/BUILD.gn b/chrome/browser/background/BUILD.gn
index e3c8221..5c73229 100644
--- a/chrome/browser/background/BUILD.gn
+++ b/chrome/browser/background/BUILD.gn
@@ -14,6 +14,7 @@
if (enable_background_contents) {
sources += [
+ "background_contents_navigation_throttle.h",
"background_contents_service.h",
"background_contents_service_factory.h",
]
@@ -49,6 +50,7 @@
if (enable_background_contents) {
sources += [
+ "background_contents_navigation_throttle.cc",
"background_contents_service.cc",
"background_contents_service_factory.cc",
]
diff --git a/chrome/browser/background/background_contents.cc b/chrome/browser/background/background_contents.cc
index abe93700..12c5cbc 100644
--- a/chrome/browser/background/background_contents.cc
+++ b/chrome/browser/background/background_contents.cc
@@ -98,13 +98,15 @@
}
void BackgroundContents::PrimaryPageChanged(content::Page& page) {
- // Note: because BackgroundContents are only available to extension apps,
+ // Note: Because `BackgroundContents` are only available to extension apps,
// navigation is limited to urls within the app's extent. This is enforced in
- // RenderView::decidePolicyForNavigation. If BackgroundContents become
- // available as a part of the web platform, it probably makes sense to have
- // some way to scope navigation of a background page to its opener's security
- // origin. Note: if the first navigation is to a URL outside the app's
- // extent a background page will be opened but will remain at about:blank.
+ // `Browser::CreateBackgroundContents`,
+ // `BackgroundContentsNavigationThrottle`, and `BackgroundContentsService`. If
+ // `BackgroundContents` become available as a part of the web platform, it
+ // probably makes sense to have some way to scope navigation of a background
+ // page to its opener's security origin. Note: if the first navigation is to a
+ // URL outside the app's extent a background page will be opened but will
+ // remain at about:blank.
delegate_->OnBackgroundContentsNavigated(this);
}
diff --git a/chrome/browser/background/background_contents_navigation_throttle.cc b/chrome/browser/background/background_contents_navigation_throttle.cc
new file mode 100644
index 0000000..99d287d
--- /dev/null
+++ b/chrome/browser/background/background_contents_navigation_throttle.cc
@@ -0,0 +1,103 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "chrome/browser/background/background_contents_navigation_throttle.h"
+
+#include <memory>
+
+#include "base/feature_list.h"
+#include "chrome/browser/background/background_contents_service.h"
+#include "chrome/browser/background/background_contents_service_factory.h"
+#include "chrome/browser/profiles/profile.h"
+#include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/web_contents.h"
+#include "content/public/common/url_constants.h"
+#include "extensions/browser/extension_registry.h"
+#include "extensions/common/extension.h"
+#include "extensions/common/extension_features.h"
+#include "url/gurl.h"
+
+// static
+void BackgroundContentsNavigationThrottle::MaybeCreateAndAdd(
+ content::NavigationThrottleRegistry& registry) {
+ if (!base::FeatureList::IsEnabled(
+ extensions_features::kBlockBackgroundContentsOffExtentNavigation)) {
+ return;
+ }
+
+ content::NavigationHandle& handle = registry.GetNavigationHandle();
+ if (!handle.IsInMainFrame()) {
+ return;
+ }
+
+ content::WebContents* const web_contents = handle.GetWebContents();
+ if (!web_contents) {
+ return;
+ }
+
+ BackgroundContentsService* const service =
+ BackgroundContentsServiceFactory::GetForProfile(
+ Profile::FromBrowserContext(web_contents->GetBrowserContext()));
+ if (!service || !service->IsTracked(web_contents)) {
+ return;
+ }
+
+ registry.AddThrottle(
+ std::make_unique<BackgroundContentsNavigationThrottle>(registry));
+}
+
+BackgroundContentsNavigationThrottle::BackgroundContentsNavigationThrottle(
+ content::NavigationThrottleRegistry& registry)
+ : content::NavigationThrottle(registry) {}
+
+BackgroundContentsNavigationThrottle::~BackgroundContentsNavigationThrottle() =
+ default;
+
+content::NavigationThrottle::ThrottleCheckResult
+BackgroundContentsNavigationThrottle::WillStartRequest() {
+ return WillStartOrRedirectRequest();
+}
+
+content::NavigationThrottle::ThrottleCheckResult
+BackgroundContentsNavigationThrottle::WillRedirectRequest() {
+ return WillStartOrRedirectRequest();
+}
+
+const char* BackgroundContentsNavigationThrottle::GetNameForLogging() {
+ return "BackgroundContentsNavigationThrottle";
+}
+
+content::NavigationThrottle::ThrottleCheckResult
+BackgroundContentsNavigationThrottle::WillStartOrRedirectRequest() {
+ content::WebContents* const web_contents =
+ navigation_handle()->GetWebContents();
+ Profile* const profile =
+ Profile::FromBrowserContext(web_contents->GetBrowserContext());
+ BackgroundContentsService* const service =
+ BackgroundContentsServiceFactory::GetForProfile(profile);
+
+ const std::string& appid = service->GetParentApplicationId(web_contents);
+ if (appid.empty()) {
+ return content::NavigationThrottle::PROCEED;
+ }
+
+ extensions::ExtensionRegistry* const registry =
+ extensions::ExtensionRegistry::Get(profile);
+ const extensions::Extension* const extension =
+ registry->enabled_extensions().GetByID(appid);
+ if (!extension) {
+ return content::NavigationThrottle::BLOCK_REQUEST;
+ }
+
+ const GURL& url = navigation_handle()->GetURL();
+ if (url.is_empty() || url.SchemeIs(url::kAboutScheme)) {
+ return content::NavigationThrottle::PROCEED;
+ }
+
+ if (!extension->web_extent().MatchesURL(url)) {
+ return content::NavigationThrottle::BLOCK_REQUEST;
+ }
+
+ return content::NavigationThrottle::PROCEED;
+}
diff --git a/chrome/browser/background/background_contents_navigation_throttle.h b/chrome/browser/background/background_contents_navigation_throttle.h
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/chrome/browser/background/background_contents_service_unittest.cc b/chrome/browser/background/background_contents_service_unittest.cc
index ee7d3e5..d80b14a7 100644
--- a/chrome/browser/background/background_contents_service_unittest.cc
+++ b/chrome/browser/background/background_contents_service_unittest.cc
@@ -11,6 +11,7 @@
#include "base/functional/callback.h"
#include "base/memory/raw_ptr.h"
#include "base/run_loop.h"
+#include "base/strings/stringprintf.h"
#include "base/test/run_until.h"
#include "base/threading/thread_restrictions.h"
#include "base/values.h"
@@ -27,7 +28,9 @@
#include "components/prefs/pref_service.h"
#include "components/prefs/scoped_user_pref_update.h"
#include "content/public/test/browser_task_environment.h"
+#include "extensions/browser/extension_registry.h"
#include "extensions/common/extension.h"
+#include "extensions/common/extension_builder.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
@@ -121,6 +124,18 @@
return contents_ptr;
}
+ scoped_refptr<const extensions::Extension> CreateHostedApp(
+ const std::string& name,
+ const GURL& url) {
+ std::string json = base::StringPrintf(
+ R"("app": {"urls": ["%s"], "launch": {"web_url": "%s"}})",
+ url.spec().c_str(), url.spec().c_str());
+ scoped_refptr<const extensions::Extension> extension =
+ extensions::ExtensionBuilder(name).AddJSON(json).Build();
+ extensions::ExtensionRegistry::Get(profile_)->AddEnabled(extension);
+ return extension;
+ }
+
protected:
content::BrowserTaskEnvironment task_environment_{
content::BrowserTaskEnvironment::TimeSource::MOCK_TIME};
@@ -260,9 +275,10 @@
TEST_F(BackgroundContentsServiceTest, RestoreFromPrefs) {
BackgroundContentsService service(profile_);
- // Manually set up the preference.
- const std::string appid = "appid";
const GURL expected_url("http://www.google.com/test");
+ scoped_refptr<const extensions::Extension> extension =
+ CreateHostedApp("test_app", expected_url);
+ const std::string appid = extension->id();
{
ScopedDictPrefUpdate update(profile_->GetPrefs(),
@@ -280,3 +296,49 @@
ASSERT_TRUE(contents);
EXPECT_EQ(expected_url, contents->GetInitialURLForTesting());
}
+
+// Tests that background contents stored in prefs with an off-extent URL are
+// ignored on restore.
+TEST_F(BackgroundContentsServiceTest, RestoreFromPrefsIgnoresUrlOutsideExtent) {
+ BackgroundContentsService service(profile_);
+
+ const GURL in_extent_url("http://www.google.com/test");
+ const GURL out_of_extent_url("http://attacker.example.com/test");
+ scoped_refptr<const extensions::Extension> extension =
+ CreateHostedApp("test_app", in_extent_url);
+ const std::string appid = extension->id();
+
+ {
+ ScopedDictPrefUpdate update(profile_->GetPrefs(),
+ prefs::kRegisteredBackgroundContents);
+ base::DictValue dict;
+ dict.Set("url", out_of_extent_url.spec());
+ dict.Set("name", "test_frame");
+ update->Set(appid, std::move(dict));
+ }
+
+ service.LoadBackgroundContentsForExtension(appid);
+
+ BackgroundContents* contents = service.GetAppBackgroundContents(appid);
+ EXPECT_FALSE(contents);
+}
+
+// Tests that navigating background contents to an off-extent URL unregisters it
+// from prefs.
+TEST_F(BackgroundContentsServiceTest, NavigatedUrlOutsideExtentNotRegistered) {
+ BackgroundContentsService service(profile_);
+
+ const GURL in_extent_url("http://www.google.com/test");
+ const GURL out_of_extent_url("http://attacker.example.com/test");
+ scoped_refptr<const extensions::Extension> extension =
+ CreateHostedApp("test_app", in_extent_url);
+ const std::string appid = extension->id();
+
+ auto owned_contents =
+ std::make_unique<MockBackgroundContents>(&service, appid);
+ EXPECT_EQ(GetPrefs(profile_).size(), 0u);
+ auto* contents = AddToService(std::move(owned_contents));
+
+ contents->Navigate(out_of_extent_url);
+ EXPECT_EQ(GetPrefs(profile_).size(), 0u);
+}
diff --git a/chrome/browser/extensions/app_background_page_apitest.cc b/chrome/browser/extensions/app_background_page_apitest.cc
index 1cfa1ff6..8bb944d 100644
--- a/chrome/browser/extensions/app_background_page_apitest.cc
+++ b/chrome/browser/extensions/app_background_page_apitest.cc
@@ -30,15 +30,18 @@
#include "chrome/browser/ui/dialogs/browser_dialogs.h"
#include "chrome/browser/ui/extensions/application_launch.h"
#include "chrome/common/chrome_paths.h"
+#include "chrome/test/base/ui_test_utils.h"
#include "components/embedder_support/switches.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
+#include "content/public/test/test_navigation_observer.h"
#include "content/public/test/test_utils.h"
#include "extensions/browser/process_manager.h"
#include "extensions/common/extension.h"
#include "extensions/common/switches.h"
#include "extensions/test/extension_test_message_listener.h"
+#include "net/base/net_errors.h"
#include "net/dns/mock_host_resolver.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "testing/gmock/include/gmock/gmock.h"
@@ -601,3 +604,111 @@
content::RunAllPendingInMessageLoop();
ASSERT_TRUE(VerifyBackgroundMode(false));
}
+
+// Tests that opening a background contents window with an off-extent target
+// URL is blocked.
+IN_PROC_BROWSER_TEST_F(AppBackgroundPageApiTest,
+ NoJsBackgroundPageTargetOutsideExtent) {
+ const std::string app_manifest = base::StringPrintf(
+ R"({
+ "name": "App",
+ "version": "0.1",
+ "manifest_version": 2,
+ "app": {
+ "urls": [
+ "http://a.com/"
+ ],
+ "launch": {
+ "web_url": "http://a.com:%u/empty.html"
+ }
+ },
+ "permissions": ["background"],
+ "background": {
+ "allow_js_access": false
+ }
+ })",
+ embedded_test_server()->port());
+
+ base::FilePath app_dir;
+ ASSERT_TRUE(CreateApp(app_manifest, &app_dir));
+ ASSERT_TRUE(LoadExtension(app_dir));
+
+ const Extension* extension = GetSingleLoadedExtension();
+ ASSERT_TRUE(extension);
+
+ GURL launch_url = embedded_test_server()->GetURL("a.com", "/empty.html");
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), launch_url));
+
+ GURL out_of_extent_url =
+ embedded_test_server()->GetURL("b.com", "/empty.html");
+ std::string script =
+ base::StringPrintf("window.open('%s', 'bg', 'background') == null;",
+ out_of_extent_url.spec().c_str());
+ EXPECT_EQ(content::EvalJs(
+ browser()->tab_strip_model()->GetActiveWebContents(), script),
+ true);
+
+ EXPECT_FALSE(BackgroundContentsServiceFactory::GetForProfile(profile())
+ ->GetAppBackgroundContents(extension->id()));
+ UnloadExtension(extension->id());
+}
+
+// Tests that navigating an existing background contents window to an
+// off-extent URL is blocked.
+IN_PROC_BROWSER_TEST_F(AppBackgroundPageApiTest,
+ NoJsBackgroundPageNavigateOutsideExtent) {
+ const std::string app_manifest = base::StringPrintf(
+ R"({
+ "name": "App",
+ "version": "0.1",
+ "manifest_version": 2,
+ "app": {
+ "urls": [
+ "http://a.com/"
+ ],
+ "launch": {
+ "web_url": "http://a.com:%u/empty.html"
+ }
+ },
+ "permissions": ["background"],
+ "background": {
+ "allow_js_access": false
+ }
+ })",
+ embedded_test_server()->port());
+
+ base::FilePath app_dir;
+ ASSERT_TRUE(CreateApp(app_manifest, &app_dir));
+ ASSERT_TRUE(LoadExtension(app_dir));
+
+ const Extension* extension = GetSingleLoadedExtension();
+ ASSERT_TRUE(extension);
+
+ BackgroundContentsTestWaiter background_waiter(profile());
+ GURL launch_url = embedded_test_server()->GetURL("a.com", "/empty.html");
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), launch_url));
+
+ std::string open_script = base::StringPrintf(
+ "window.open('%s', 'bg', 'background');", launch_url.spec().c_str());
+ EXPECT_TRUE(content::ExecJs(
+ browser()->tab_strip_model()->GetActiveWebContents(), open_script));
+ background_waiter.WaitForBackgroundContents(extension->id());
+
+ BackgroundContents* background_contents =
+ BackgroundContentsServiceFactory::GetForProfile(profile())
+ ->GetAppBackgroundContents(extension->id());
+ ASSERT_TRUE(background_contents);
+
+ GURL out_of_extent_url =
+ embedded_test_server()->GetURL("b.com", "/empty.html");
+ std::string nav_script = base::StringPrintf("window.location.href = '%s';",
+ out_of_extent_url.spec().c_str());
+ content::TestNavigationObserver nav_observer(
+ background_contents->web_contents());
+ EXPECT_TRUE(content::ExecJs(background_contents->web_contents(), nav_script));
+ nav_observer.Wait();
+
+ EXPECT_FALSE(nav_observer.last_navigation_succeeded());
+ EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, nav_observer.last_net_error_code());
+ UnloadExtension(extension->id());
+}
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page