Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Extensions
DescriptionInsufficient policy enforcement in Extensions
ComponentExtensions
Bug ClassLogic Error
Tracker514503077
Fix commitc0e115725023 (chromium/src) +75/-21
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
TEST_F
chrome/common/extensions/permissions/permissions_data_unittest.cc
modified
if
extensions/common/permissions/permissions_data.cc
modified

Files Changed

  • chrome/common/extensions/permissions/permissions_data_unittest.cc
  • extensions/common/permissions/permissions_data.cc
  • extensions/common/permissions/permissions_data.h
From c0e115725023ce4352650e22ed2c25baab0d1cfc Mon Sep 17 00:00:00 2001
From: James Cook <[email protected]>
Date: Tue, 26 May 2026 16:04:58 -0700
Subject: [PATCH] extensions: Fix bypass of user site restrictions in pageCapture API

PermissionData::CanCaptureVisiblePage(), when called from the
pageCapture API with CaptureRequirement::kPageCapture was not checking
if the user had blocked extension access to the page to capture.

Refactor out an IsUrlBlockedByUser() method and call it to check if
the URL should be allowed. We can't use the existing PermissionData
methods like GetPageAccess() or CanRunOnPage() because those would
require the extension to declare host_permissions, and that's not
part of the API contract for chrome.pageCapture.

Given that the risk of this defect is low (an extension could capture
an MHTML version of a page), I don't think it's worth changing the API
contract to require host_permissions, since that would likely break
existing extensions using pageCapture.

A follow-up CL will remove some unnecessary ENABLE_EXTENSIONS
buildflag checks, as desktop Android now supports pageCapture. I left
them in for easier merging, if we need it.

Bug: 514503077
Test: added to unit_tests
Change-Id: I2a4fe17de851adc996c4535e4e2c56e2e59086fd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7871735
Reviewed-by: Tim <[email protected]>
Commit-Queue: James Cook <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1636553}
---

diff --git a/chrome/common/extensions/permissions/permissions_data_unittest.cc b/chrome/common/extensions/permissions/permissions_data_unittest.cc
index 240a5ba..7a0a658d 100644
--- a/chrome/common/extensions/permissions/permissions_data_unittest.cc
+++ b/chrome/common/extensions/permissions/permissions_data_unittest.cc
@@ -1791,4 +1791,30 @@
   }
 }
 
+#if BUILDFLAG(ENABLE_EXTENSIONS)
+// TODO(crbug.com/427298257): Enable on desktop Android.
+TEST_F(CaptureVisiblePageTest, PageCapture_UserBlockedURLs) {
+  // Allow per-host user restrictions.
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(
+      extensions_features::kExtensionsMenuAccessControl);
+
+  // Apply a user host restriction.
+  URLPattern blocked_url(URLPattern::SCHEME_ALL, "https://blocked.com/*");
+  int context_id = 8;
+  URLPatternSet blocked_patterns({blocked_url});
+  PermissionsData::SetUserHostRestrictions(
+      context_id, std::move(blocked_patterns), URLPatternSet());
+  page_capture().permissions_data()->SetContextId(context_id);
+
+  // The user restricted URL can't be captured.
+  EXPECT_FALSE(CanCapture(page_capture(), GURL("https://blocked.com"),
+                          extensions::CaptureRequirement::kPageCapture));
+
+  // An arbitrary URL can be captured.
+  EXPECT_TRUE(CanCapture(page_capture(), GURL("https://allowed.com/"),
+                         extensions::CaptureRequirement::kPageCapture));
+}
+#endif  // BUILDFLAG(ENABLE_EXTENSIONS)
+
 }  // namespace extensions
diff --git a/extensions/common/permissions/permissions_data.cc b/extensions/common/permissions/permissions_data.cc
index a7fe00a7..8f4206e 100644
--- a/extensions/common/permissions/permissions_data.cc
+++ b/extensions/common/permissions/permissions_data.cc
@@ -34,6 +34,8 @@
 
 namespace {
 
+constexpr char kErrorBlocked[] = "Blocked";
+
 PermissionsData::PolicyDelegate* g_policy_delegate = nullptr;
 
 struct URLPatternAccessSet {
@@ -529,6 +531,17 @@
       return false;
     }
 
+    // We can't use GetPageAccess() here because that would require developers
+    // using the pageCapture API to declare host_permissions in their manifest,
+    // and that's not part of the API spec. In lieu of that, check for URLs the
+    // user has specifically disallowed. https://crbug.com/514503077
+    if (IsUrlBlockedByUser(origin_url)) {
+      if (error) {
+        *error = kErrorBlocked;
+      }
+      return false;
+    }
+
     // If the URL is a typical web URL, the pageCapture permission is
     // sufficient.
     if ((origin_url.SchemeIs(url::kHttpScheme) ||
@@ -611,6 +624,30 @@
          !policy_allowed_hosts_unsafe_.MatchesURL(url);
 }
 
+bool PermissionsData::IsUrlBlockedByUser(const GURL& document_url) const {
+  // Only applies when per-URL extension blocking is enabled.
+  if (!base::FeatureList::IsEnabled(
+          extensions_features::kExtensionsMenuAccessControl)) {
+    return false;
+  }
+
+  if (!context_id_ || location_ == mojom::ManifestLocation::kComponent ||
+      Manifest::IsPolicyLocation(location_)) {
+    return false;
+  }
+
+  base::AutoLock lock(GetContextPermissionsLock());
+  auto& context_permissions = GetContextPermissions(*context_id_);
+  // Check if the host is restricted by the user. `allowed_hosts` takes
+  // precedent over `blocked_hosts`. Note that, today, PermissionsManager
+  // ensures there's no overlap, but this will change if/when
+  // PermissionsManager uses URLPatterns instead of origins.
+  return context_permissions.user_restrictions.blocked_hosts.MatchesURL(
+             document_url) &&
+         !context_permissions.user_restrictions.allowed_hosts.MatchesURL(
+             document_url);
+}
+
 PermissionsData::PageAccess PermissionsData::CanRunOnPage(
     const GURL& document_url,
     const URLPatternSet& permitted_url_patterns,
@@ -628,28 +665,14 @@
   if (IsRestrictedUrl(document_url, error))
     return PageAccess::kDenied;
 
-  if (base::FeatureList::IsEnabled(
-          extensions_features::kExtensionsMenuAccessControl) &&
-      context_id_ && location_ != mojom::ManifestLocation::kComponent &&
-      !Manifest::IsPolicyLocation(location_)) {
-    base::AutoLock lock(GetContextPermissionsLock());
-    auto& context_permissions = GetContextPermissions(*context_id_);
-    // Check if the host is restricted by the user. `allowed_hosts` takes
-    // precedent over `blocked_hosts`. Note that, today, PermissionsManager
-    // ensures there's no overlap, but this will change if/when
-    // PermissionsManager uses URLPatterns instead of origins.
-    if (context_permissions.user_restrictions.blocked_hosts.MatchesURL(
-            document_url) &&
-        !context_permissions.user_restrictions.allowed_hosts.MatchesURL(
-            document_url)) {
-      if (error) {
-        // TODO(crbug.com/40803363): What level of information should
-        // we specify here? Policy host restrictions pass a descriptive error
-        // back to the extension; is there any harm in doing so?
-        *error = "Blocked";
-      }
-      return PageAccess::kDenied;
+  if (IsUrlBlockedByUser(document_url)) {
+    if (error) {
+      // TODO(crbug.com/40803363): What level of information should
+      // we specify here? Policy host restrictions pass a descriptive error
+      // back to the extension; is there any harm in doing so?
+      *error = kErrorBlocked;
     }
+    return PageAccess::kDenied;
   }
 
   if (tab_url_patterns && tab_url_patterns->MatchesURL(document_url))
diff --git a/extensions/common/permissions/permissions_data.h b/extensions/common/permissions/permissions_data.h
index 49f77a9..cd222fe 100644
--- a/extensions/common/permissions/permissions_data.h
+++ b/extensions/common/permissions/permissions_data.h
@@ -208,6 +208,11 @@
                            int tab_id,
                            std::string* error) const;
 
+  // Returns true if there's a user host restriction that blocks `document_url`,
+  // unless there is one that explicitly allows it. Returns false if feature
+  // kExtensionsMenuAccessControl is not enabled.
+  bool IsUrlBlockedByUser(const GURL& document_url) const;
+
   // Returns true if the associated extension has permission to inject a
   // content script on the page.
   // If this returns false and `error` is non-NULL, `error` will be popualted
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/common/extensions/permissions/permissions_data_unittest.cc b/chrome/common/extensions/permissions/permissions_data_unittest.cc
index 240a5ba..7a0a658d 100644
--- a/chrome/common/extensions/permissions/permissions_data_unittest.cc
+++ b/chrome/common/extensions/permissions/permissions_data_unittest.cc
@@ -1791,4 +1791,30 @@
   }
 }
 
+#if BUILDFLAG(ENABLE_EXTENSIONS)
+// TODO(crbug.com/427298257): Enable on desktop Android.
+TEST_F(CaptureVisiblePageTest, PageCapture_UserBlockedURLs) {
+  // Allow per-host user restrictions.
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeature(
+      extensions_features::kExtensionsMenuAccessControl);
+
+  // Apply a user host restriction.
+  URLPattern blocked_url(URLPattern::SCHEME_ALL, "https://blocked.com/*");
+  int context_id = 8;
+  URLPatternSet blocked_patterns({blocked_url});
+  PermissionsData::SetUserHostRestrictions(
+      context_id, std::move(blocked_patterns), URLPatternSet());
+  page_capture().permissions_data()->SetContextId(context_id);
+
+  // The user restricted URL can't be captured.
+  EXPECT_FALSE(CanCapture(page_capture(), GURL("https://blocked.com"),
+                          extensions::CaptureRequirement::kPageCapture));
+
+  // An arbitrary URL can be captured.
+  EXPECT_TRUE(CanCapture(page_capture(), GURL("https://allowed.com/"),
+                         extensions::CaptureRequirement::kPageCapture));
+}
+#endif  // BUILDFLAG(ENABLE_EXTENSIONS)
+
 }  // namespace extensions
Loading diff…

Original Bug Report

reported by [email protected]

Bypass of user site restrictions in chrome.pageCapture API

Flapjack, 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The chrome.pageCapture API can potentially bypass user-defined host restrictions, such as the ‘Block all extensions on this site’ setting. A logic flaw in PermissionsData::CanCaptureVisiblePage allows extensions with the pageCapture permission to capture the full DOM of restricted HTTP/HTTPS pages. This allows extensions to extract sensitive data from sites the user explicitly blocked.

Affected files:

  • extensions/common/permissions/permissions_data.cc

Estimated timestamp from git blame: 2022-06-02

Description

The chrome.pageCapture.saveAsMHTML API allows an extension to capture the contents of a tab as an MHTML archive. While this API requires the pageCapture permission, it should respect user-specified site access restrictions (e.g., when a user chooses “Block all extensions on this site” via the Extensions menu).

However, a logic flaw in PermissionsData::CanCaptureVisiblePage causes it to bypass the user-level host restriction checks for the pageCapture API.

When chrome.pageCapture.saveAsMHTML is called, PageCaptureSaveAsMHTMLFunction::CanCaptureCurrentPage invokes PermissionsData::CanCaptureVisiblePage with CaptureRequirement::kPageCapture.

// extensions/common/permissions/permissions_data.cc

  } else {
    DCHECK_EQ(CaptureRequirement::kPageCapture, capture_requirement);
    if (!has_page_capture) {
      if (error)
        *error = manifest_errors::kPageCaptureNeeded;
      return false;
    }

    // If the URL is a typical web URL, the pageCapture permission is
    // sufficient.
    if ((origin_url.SchemeIs(url::kHttpScheme) ||
         origin_url.SchemeIs(url::kHttpsScheme)) &&
        !extension_urls::IsWebstoreOrigin(origin)) {
      return true;
    }
  }

If the target is a standard HTTP or HTTPS URL, the function immediately returns true.

By returning early here, it bypasses the call to GetPageAccess() (which only happens in the kActiveTabOrAllUrls branch). Since GetPageAccess() is the method that invokes CanRunOnPage()—where the user_restrictions.blocked_hosts check is enforced—the pageCapture API completely ignores user-defined site blocks.

As a result, a malicious extension with the pageCapture permission can query for a tab’s ID (e.g., using chrome.tabs.query) and successfully capture the MHTML of a page that the user has explicitly restricted the extension from accessing, breaking the expected privacy boundary.

Potential Steps to Reproduce

Note: These steps are suggested based on static code analysis; our tooling has not yet verified this with a live proof of concept.

  1. Install an extension that has the pageCapture and tabs permissions.
  2. Navigate to an HTTP/HTTPS website with sensitive information.
  3. Open the Extensions menu and explicitly revoke the extension’s site access for this site (e.g., “Block all extensions on this site”).
  4. The extension background script calls chrome.tabs.query to find the tabId of the blocked site.
  5. The extension calls chrome.pageCapture.saveAsMHTML({tabId: target_tab_id}).
  6. Observe that the API call succeeds and returns the MHTML blob containing the restricted site’s DOM, despite the user’s explicit block.

Suggested Fix

Update PermissionsData::CanCaptureVisiblePage so that the kPageCapture branch also evaluates user host restrictions. This could be achieved by calling GetPageAccess() or explicitly checking CanRunOnPage() before returning true for HTTP/HTTPS schemes.

Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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.

View on issue tracker