Overview

Low
Severity
9.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Extensions
DescriptionIncorrect authorization in Extensions
ComponentExtensions
Sub-componentExtensions
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.
Tracker542355360
Fix commit2a11198c47e9 (chromium/src) +391/-22
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedantoniosmr02
Reported2026-08-04 (per vendor advisory)
Fix landed2026-08-13 (public git)
Disclosed2026-09-08
Patch gap26 days — fix public in source before the advisory

Background

Manifest sandboxed pages
extension pages listed under the manifest "sandbox": { "pages": [...] } key that must load in a null/untrusted origin without extension API access, matched at load time by SandboxedPageInfo::IsSandboxedPage.
`Extension::ResourceMatches`
helper in extensions/common/extension.cc that resolves a relative resource path against the extension base URL and asks a URLPatternSet whether any pattern matches it.
Case-insensitive filesystems
filesystems (Windows, most macOS setups) where Sandboxed.html and sandboxed.html resolve to the same file, so a case-variant request still serves the declared resource’s bytes.
`base::i18n::FoldCase`
Unicode case-folding routine (added via base/i18n/case_conversion.h) used by the fix to compare paths case-insensitively for ASCII and non-ASCII characters alike.

Root Cause Analysis

Before the patch, SandboxedPageInfo::IsSandboxedPage called extension->ResourceMatches(GetPages(extension), relative_path), which flowed through URLPatternSet::MatchesURL → URLPattern::MatchesURL → URLPattern::MatchesPath and performed strictly case-sensitive matching via base::MatchPattern(test, path_escaped_). On a case-insensitive filesystem, a frame that requested Sandboxed.html (or CAFÉ.html) would be served the bytes of the declared sandboxed.html/café.html resource, yet the case-mismatched request path failed the sandbox pattern match, so the frame was treated as a normal (non-sandboxed) extension page. The violated invariant is that any resource declared as a sandboxed page must always load sandboxed regardless of the path casing used to request it.

The fix threads a new bool case_sensitive parameter through Extension::ResourceMatches, URLPatternSet::MatchesURL, and URLPattern::MatchesURL/MatchesPath, and IsSandboxedPage now passes /*case_sensitive=*/false, so a case-variant request to a sandboxed page is folded (via base::i18n::FoldCase after base::UnescapeURLComponent) and still matches, forcing the frame to be sandboxed. Web-accessible-resource callers were deliberately left at /*case_sensitive=*/true so that, when in doubt, those resources are not exposed.

Key insight
The single mistake was matching a requested resource path against the sandbox pattern set case-sensitively while the underlying (case-insensitive) filesystem served the same file for any casing, so a declared sandboxed page could be loaded unsandboxed by requesting a case variant of its path; the fix makes only the sandbox check case-insensitive (Unicode-folded) so every casing that maps to a sandboxed resource is recognized as sandboxed.

Attack Path

  1. Declare a sandboxed page A (potentially malicious or compromised) extension ships a manifest with "sandbox": { "pages": ["sandboxed.html"] } and the file sandboxed.html on disk.
  2. Request a case-variant path On a case-insensitive filesystem (Windows/macOS), a frame navigates to chrome-extension://<id>/Sandboxed.html; the filesystem serves the bytes of sandboxed.html, but the pre-patch case-sensitive URLPattern::MatchesPath fails to match the sandbox pattern.
  3. Frame loads unsandboxed Because IsSandboxedPage returned false for the case-variant path, the page loads as a normal extension page instead of in a null origin, so it is not stripped of extension privileges. The regression test confirms the intended post-fix state (origin "null", typeof chrome.runtime is "undefined", process not in the extension ProcessMap), demonstrating that the pre-fix path lacked these protections.
  4. Content intended to be sandboxed runs with extension privileges (inferred) Page content the developer expected to execute in an untrusted sandbox instead runs with access to extension APIs / a trusted extension process; any concrete escalation from that mis-privileging is not demonstrated by the diff.

Impact Assessment

An attacker who can cause a case-variant request to a declared sandboxed page on a case-insensitive filesystem gets that page to load without the sandbox restrictions the developer relied on, i.e. potentially with extension API access and in a trusted extension process rather than a null origin. The vendor advisory rates this low severity and does not report in-the-wild exploitation; NVD scores it CVSS 9.8. Any further escalation (e.g. running otherwise-sandboxed/untrusted content with real extension powers to attack the browser or user data) is (inferred) and not proven by the patch, which only shows the sandbox-vs-not-sandbox decision being corrected.

Changed Functions

FunctionChangeNotes
for
extensions/common/manifest_handlers/web_accessible_resources_info.cc
modified
for
extensions/common/manifest_handlers/webview_info.cc
modified
if
extensions/common/url_pattern.cc
modified
for
extensions/common/url_pattern_set.cc
modified
IN_PROC_BROWSER_TEST_F
chrome/browser/extensions/sandboxed_pages_apitest.cc
modified

Files Changed

  • extensions/common/url_pattern.cc
  • extensions/common/extension_unittest.cc
  • extensions/common/manifest_handlers/web_accessible_resources_info.cc
  • extensions/common/BUILD.gn
  • extensions/common/url_pattern_set.cc
  • extensions/common/manifest_handlers/csp_info_unittest.cc
  • chrome/browser/extensions/web_accessible_resources_browsertest.cc
  • extensions/common/url_pattern_unittest.cc
  • extensions/common/url_pattern_set_unittest.cc
  • extensions/common/extension.cc
  • extensions/common/url_pattern.h
  • extensions/common/manifest_handlers/webview_info.cc
  • extensions/common/extension.h
  • extensions/common/url_pattern_set.h
  • chrome/browser/extensions/sandboxed_pages_apitest.cc
  • extensions/common/manifest_handlers/sandboxed_page_info.cc

Audit Directions

  • Case-sensitivity of security decisions on case-insensitive filesystems
    Audit every security gate that matches a request path/URL against declared patterns (sandbox, CSP, permissions, resource access) for the assumption that request casing equals on-disk casing; a mismatch between filesystem serving and pattern matching can flip a security decision.
  • Fail-open vs fail-closed defaults per caller
    Where one matching helper (ResourceMatches/MatchesURL) now takes a case_sensitive flag, verify each caller chose the direction that fails safe (sandbox → case-insensitive to over-include; exposure of resources → case-sensitive to under-include), and flag any new caller that picks the insecure default.
  • Escaping/normalization gaps in matchers
    Review path matchers that compare escaped vs unescaped or ASCII vs Unicode forms (the TODO for crbug.com/545512660 about percent-encoded non-ASCII paths in case-sensitive mode) for inconsistent unescaping/case-folding that lets equivalent paths bypass or wrongly satisfy a match.
From 2a11198c47e96470dcca19b629a3a186d710c847 Mon Sep 17 00:00:00 2001
From: Devlin Cronin <[email protected]>
Date: Thu, 13 Aug 2026 11:33:10 -0700
Subject: [PATCH] [Extensions] Sandbox pages based on case-insensitive matching

There's a bug where if an extension sandboxes "sandboxed.html", but a
frame loads "Sandboxed.html", that frame won't be sandboxed (because it
doesn't match the pattern). On case-sensitive file systems (like linux),
this is fine: Sandboxed.html won't load. However, on case-insensitive
file systems (like Windows or most Mac systems), this is problematic:
Sandboxed.html will load sandboxed.html, but the frame won't be
sandboxed.

The *best* fix for this is likely to align our resource loading with the
canonical paths on the file system such that if there's a request for
Path.html, it will only succeed if there is a resource with the
(case-sensitive) Path.html path on disk. This would fix this issue and
would also ensure that developers don't accidentally have broken
extensions on case-sensitive file systems.

Unfortunately, we don't have a good way of knowing if that would break
existing extensions, since some extensions may be (improperly) relying
on this behavior, trying to load Path.html when the file is path.html.

In order to fix the sandboxing issue without risking breaking
extensions, make whether to sandbox a resource based on case-insensitive
matching: that is, if the extension specifies sandboxed.html, a frame
that loads Sandboxed.html will also be sandboxed. (Technically, this
also has a risk of breakage: an extension could have sandboxed.html AND
Sandboxed.html and only want to sandbox one of them. This is
significantly less likely than the other breaking flow, though.)

Do this by introducing a "case-sensitive" parameter to
Extension::ResourceMatches() and URLPattern[Set]::MatchesURL().

In addition to the case-sensitivity, we also introduce checks for
unicode escaping (and its own case-sensitivity).

Do *not* use this logic to apply to other uses of
Extension::ResourceMatches(), in particular, web-accessible resources.
This introduces an inconsistency; however, this allows us to default to
the more secure option in both cases: when in doubt, we sandbox the
frame, and when in doubt, we *don't* allow the frame in untrusted
contexts.

Longer term, we should measure and revisit simply changing our protocols
to only load the canonical path in the extension, at which point we
could remove this (since attempting to load a frame that doesn't match
the casing of the resource would fail).

Bug: 542355360, 545512660
Change-Id: Ib17f22c18e178c3fc6b701bd98c3be5f92718ef5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8242131
Reviewed-by: Andrea Orru <[email protected]>
Commit-Queue: Devlin Cronin <[email protected]>
Auto-Submit: Devlin Cronin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1678984}
---
diff --git a/extensions/common/extension.cc b/extensions/common/extension.cc
index 9413abe..28a3cac 100644
--- a/extensions/common/extension.cc
+++ b/extensions/common/extension.cc
@@ -311,7 +311,8 @@
 }
 
 bool Extension::ResourceMatches(const URLPatternSet& pattern_set,
-                                std::string_view resource) const {
+                                std::string_view resource,
+                                bool case_sensitive) const {
   // First, resolve `resource` relative to the extension's base URL.
   GURL resolved = extension_url_.Resolve(resource);
 
@@ -331,7 +332,7 @@
   // Re-resolve the URL using the unescaped relative path so URLPattern matches
   // against the canonical resource path served from disk.
   return pattern_set.MatchesURL(
-      extension_url_.Resolve(relative_path.AsUTF8Unsafe()));
+      extension_url_.Resolve(relative_path.AsUTF8Unsafe()), case_sensitive);
 }
 
 ExtensionResource Extension::GetResource(std::string_view relative_path) const {
diff --git a/extensions/common/extension.h b/extensions/common/extension.h
index 8ea15b24..bda81a2 100644
--- a/extensions/common/extension.h
+++ b/extensions/common/extension.h
@@ -194,9 +194,12 @@
     return GetResourceURL(url(), relative_url);
   }
 
-  // Returns true if the resource matches a pattern in the pattern_set.
+  // Returns true if the resource matches a pattern in the pattern_set. If
+  // `case_sensitive` is false, matching is performed case-insensitively using
+  // Unicode case folding.
   bool ResourceMatches(const URLPatternSet& pattern_set,
-                       std::string_view resource) const;
+                       std::string_view resource,
+                       bool case_sensitive) const;
 
   // Returns an extension resource object. `relative_path` should be UTF8
   // encoded.
diff --git a/extensions/common/manifest_handlers/sandboxed_page_info.cc b/extensions/common/manifest_handlers/sandboxed_page_info.cc
index bd8bc64..141de0c6d 100644
--- a/extensions/common/manifest_handlers/sandboxed_page_info.cc
+++ b/extensions/common/manifest_handlers/sandboxed_page_info.cc
@@ -45,8 +45,9 @@
 }
 
 bool SandboxedPageInfo::IsSandboxedPage(const Extension* extension,
-                                    const std::string& relative_path) {
-  return extension->ResourceMatches(GetPages(extension), relative_path);
+                                        const std::string& relative_path) {
+  return extension->ResourceMatches(GetPages(extension), relative_path,
+                                    /*case_sensitive=*/false);
 }
 
 SandboxedPageHandler::SandboxedPageHandler() = default;
diff --git a/extensions/common/manifest_handlers/web_accessible_resources_info.cc b/extensions/common/manifest_handlers/web_accessible_resources_info.cc
index 36098b3..140fb238 100644
--- a/extensions/common/manifest_handlers/web_accessible_resources_info.cc
+++ b/extensions/common/manifest_handlers/web_accessible_resources_info.cc
@@ -192,7 +192,8 @@
 
   // Look for the first match in the array of web accessible resources.
   for (const auto& entry : info->web_accessible_resources) {
-    if (extension.ResourceMatches(entry.resources, relative_path)) {
+    if (extension.ResourceMatches(entry.resources, relative_path,
+                                  /*case_sensitive=*/true)) {
       bool result = true;
 
       // Prior to MV3, web-accessible resources were accessible by any site.
@@ -298,7 +299,8 @@
     return false;
   }
   for (const auto& entry : info->web_accessible_resources) {
-    if (extension->ResourceMatches(entry.resources, path) &&
+    if (extension->ResourceMatches(entry.resources, path,
+                                   /*case_sensitive=*/true) &&
         entry.use_dynamic_url) {
       return true;
     }
diff --git a/extensions/common/manifest_handlers/webview_info.cc b/extensions/common/manifest_handlers/webview_info.cc
index 18387fb..754b717 100644
--- a/extensions/common/manifest_handlers/webview_info.cc
+++ b/extensions/common/manifest_handlers/webview_info.cc
@@ -79,8 +79,8 @@
 
   for (const auto& item : webview_info->partition_items_) {
     if (item->Matches(partition_id) &&
-        extension->ResourceMatches(item->accessible_resources(),
-                                   relative_path)) {
+        extension->ResourceMatches(item->accessible_resources(), relative_path,
+                                   /*case_sensitive=*/true)) {
       return true;
     }
   }
diff --git a/extensions/common/url_pattern.cc b/extensions/common/url_pattern.cc
index 1f733c42..a9a260c 100644
--- a/extensions/common/url_pattern.cc
+++ b/extensions/common/url_pattern.cc
@@ -10,11 +10,14 @@
 #include <ostream>
 #include <string_view>
 
+#include "base/i18n/case_conversion.h"
+#include "base/strings/escape.h"
 #include "base/strings/pattern.h"
 #include "base/strings/strcat.h"
 #include "base/strings/string_number_conversions.h"
 #include "base/strings/string_split.h"
 #include "base/strings/string_util.h"
+#include "base/strings/utf_string_conversions.h"
 #include "content/public/common/url_constants.h"
 #include "extensions/common/constants.h"
 #include "net/base/registry_controlled_domains/registry_controlled_domain.h"
@@ -419,6 +422,10 @@
 }
 
 bool URLPattern::MatchesURL(const GURL& test) const {
+  return MatchesURL(test, /*case_sensitive=*/true);
+}
+
+bool URLPattern::MatchesURL(const GURL& test, bool case_sensitive) const {
   // Invalid URLs can never match.
   if (!test.is_valid()) {
     return false;
@@ -456,7 +463,7 @@
   }
 
   return MatchesSecurityOriginHelper(*test_url) &&
-         MatchesPath(path_for_request);
+         MatchesPath(path_for_request, case_sensitive);
 }
 
 bool URLPattern::MatchesSecurityOrigin(const GURL& test) const {
@@ -575,16 +582,44 @@
 }
 
 bool URLPattern::MatchesPath(std::string_view test) const {
-  // Make the behaviour of OverlapsWith consistent with MatchesURL, which is
-  // need to match hosted apps on e.g. 'google.com' also run on 'google.com/'.
-  // The below if is a no-copy way of doing (test + "/*" == path_escaped_).
-  if (path_escaped_.length() == test.length() + 2 &&
-      base::StartsWith(path_escaped_.c_str(), test) &&
-      base::EndsWith(path_escaped_, "/*")) {
+  return MatchesPath(test, /*case_sensitive=*/true);
+}
+
+bool URLPattern::MatchesPath(std::string_view test, bool case_sensitive) const {
+  if (case_sensitive) {
+    // Make the behaviour of OverlapsWith consistent with MatchesURL, which
+    // is needed to ensure hosted apps on e.g. 'google.com' also run on
+    // 'google.com/'. The below if is a no-copy way of doing (test + "/*" ==
+    // path_escaped_).
+    if (path_escaped_.length() == test.length() + 2 &&
+        base::StartsWith(path_escaped_, test) &&
+        base::EndsWith(path_escaped_, "/*")) {
+      return true;
+    }
+
+    return base::MatchPattern(test, path_escaped_);
+  }
+
+  std::string unescaped_test =
+      base::UnescapeURLComponent(test, base::UnescapeRule::NORMAL);
+  std::string unescaped_pattern =
+      base::UnescapeURLComponent(path_escaped_, base::UnescapeRule::NORMAL);
+
+  // We can't just use base::ToLowerASCII() (because this might not be ASCII).
+  // Instead, we use i18n::FoldCase(), which will fold these into a canonical
+  // case for each character.
+  std::u16string test_u16 =
+      base::i18n::FoldCase(base::UTF8ToUTF16(unescaped_test));
+  std::u16string pattern_u16 =
+      base::i18n::FoldCase(base::UTF8ToUTF16(unescaped_pattern));
+
+  if (pattern_u16.length() == test_u16.length() + 2 &&
+      base::StartsWith(pattern_u16, test_u16) &&
+      base::EndsWith(pattern_u16, u"/*")) {
     return true;
   }
 
-  return base::MatchPattern(test, path_escaped_);
+  return base::MatchPattern(test_u16, pattern_u16);
 }
 
 const std::string& URLPattern::GetAsString() const {
diff --git a/extensions/common/url_pattern.h b/extensions/common/url_pattern.h
index 4d09251..8a71102f 100644
--- a/extensions/common/url_pattern.h
+++ b/extensions/common/url_pattern.h
@@ -159,8 +159,10 @@
   bool IsValidScheme(std::string_view scheme) const;
 
   // Returns true if this instance matches the specified URL. Always returns
-  // false for invalid URLs.
+  // false for invalid URLs. If `case_sensitive` is false, path matching is done
+  // case-insensitively using Unicode case folding.
   bool MatchesURL(const GURL& test) const;
+  bool MatchesURL(const GURL& test, bool case_sensitive) const;
 
   // Returns true if this instance matches the specified security origin.
   bool MatchesSecurityOrigin(const GURL& test) const;
@@ -175,8 +177,10 @@
   bool MatchesHost(std::string_view test) const;
   bool MatchesHost(const GURL& test) const;
 
-  // Returns true if `test` matches our path.
+  // Returns true if `test` matches our path. If `case_sensitive` is false, path
+  // matching is done case-insensitively using Unicode case folding.
   bool MatchesPath(std::string_view test) const;
+  bool MatchesPath(std::string_view test, bool case_sensitive) const;
 
   // Returns true if the pattern matches all patterns in an (e)TLD. This
   // includes patterns like *://*.com/*, *://*.co.uk/*, etc. A pattern that
diff --git a/extensions/common/url_pattern_set.cc b/extensions/common/url_pattern_set.cc
index bdf25b8..48ea889 100644
--- a/extensions/common/url_pattern_set.cc
+++ b/extensions/common/url_pattern_set.cc
@@ -222,8 +222,12 @@
 }
 
 bool URLPatternSet::MatchesURL(const GURL& url) const {
+  return MatchesURL(url, /*case_sensitive=*/true);
+}
+
+bool URLPatternSet::MatchesURL(const GURL& url, bool case_sensitive) const {
   for (const auto& pattern : patterns_) {
-    if (pattern.MatchesURL(url)) {
+    if (pattern.MatchesURL(url, case_sensitive)) {
       return true;
     }
   }
diff --git a/extensions/common/url_pattern_set.h b/extensions/common/url_pattern_set.h
index c39d72d..ebcd5a0c 100644
--- a/extensions/common/url_pattern_set.h
+++ b/extensions/common/url_pattern_set.h
@@ -124,8 +124,10 @@
   // Returns true if any pattern in this set encompasses `pattern`.
   bool ContainsPattern(const URLPattern& pattern) const;
 
-  // Test if the extent contains a URL.
+  // Test if the extent contains a URL. If `case_sensitive` is false, path
+  // matching is done case-insensitively using Unicode case folding.
   bool MatchesURL(const GURL& url) const;
+  bool MatchesURL(const GURL& url, bool case_sensitive) const;
 
   // Test if the extent matches all URLs (for example, <all_urls>).
   bool MatchesAllURLs() const;
diff --git a/chrome/browser/extensions/sandboxed_pages_apitest.cc b/chrome/browser/extensions/sandboxed_pages_apitest.cc
index e5beb22..731679e4 100644
--- a/chrome/browser/extensions/sandboxed_pages_apitest.cc
+++ b/chrome/browser/extensions/sandboxed_pages_apitest.cc
@@ -556,6 +556,83 @@
       extension->id(), frame_host->GetProcess()->GetID()));
 }
 
+// Verifies that requesting a sandboxed page using case-variant path (e.g.
+// "Sandboxed.html") is still recognized as a sandboxed page.
+// Regression test for https://crbug.com/542355360.
+IN_PROC_BROWSER_TEST_F(SandboxedPagesTest, CaseInsensitiveSandboxedPagePath) {
+  static constexpr char kManifest[] =
+      R"({
+           "name": "Case-insensitive sandboxed page test",
+           "version": "0.1",
+           "manifest_version": 3,
+           "sandbox": { "pages": ["sandboxed.html", "café.html"] }
+         })";
+  static constexpr char kSandboxedHtml[] =
+      R"(<html><body>Sandboxed Page</body></html>)";
+  static constexpr char kCafeHtml[] =
+      R"(<html>
+           <head><meta charset="utf-8"></head><body>Café Page</body>
+         </html>)";
+
+  TestExtensionDir test_dir;
+  test_dir.WriteManifest(kManifest);
+  // Write the case-variant files first so that on case-sensitive filesystems
+  // (like Linux) the files exist to be loaded, but on case-insensitive
+  // filesystems (macOS, Windows) the canonical lowercase files are written
+  // second.
+  test_dir.WriteFile(FILE_PATH_LITERAL("Sandboxed.html"), kSandboxedHtml);
+  test_dir.WriteFile(FILE_PATH_LITERAL("sandboxed.html"), kSandboxedHtml);
+  test_dir.WriteFile(FILE_PATH_LITERAL("CAFÉ.html"), kCafeHtml);
+  test_dir.WriteFile(FILE_PATH_LITERAL("café.html"), kCafeHtml);
+
+  const Extension* extension = LoadExtension(test_dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  content::WebContents* web_contents = GetActiveWebContents();
+
+  // Test ASCII case mismatch.
+  {
+    // Note: Deliberately don't use Extension::GetResourceURL(), which goes
+    // through additional sanitization checks.
+    GURL uppercase_url = extension->url().Resolve("Sandboxed.html");
+    ASSERT_TRUE(NavigateToURL(web_contents, uppercase_url));
+
+    // The page should load, but should be properly sandboxed: it shouldn't
+    // have extension APIs or be hosted in a trusted process.
+    content::RenderFrameHost* frame_host = web_contents->GetPrimaryMainFrame();
+    ASSERT_TRUE(frame_host);
+    EXPECT_FALSE(frame_host->IsErrorDocument());
+    EXPECT_EQ("Sandboxed Page",
+              content::EvalJs(web_contents, "document.body.innerText"));
+    EXPECT_EQ("null", frame_host->GetLastCommittedOrigin().Serialize());
+    EXPECT_EQ("undefined",
+              content::EvalJs(web_contents, "typeof chrome.runtime"));
+    EXPECT_FALSE(ProcessMap::Get(profile())->Contains(
+        extension->id(), frame_host->GetProcess()->GetID()));
+  }
+
+  // Test non-ASCII / Unicode UTF-8 case mismatch.
+  {
+    // Note: Deliberately don't use Extension::GetResourceURL(), which goes
+    // through additional sanitization checks.
+    GURL uppercase_url = extension->url().Resolve("CAFÉ.html");
+    ASSERT_TRUE(NavigateToURL(web_contents, uppercase_url));
+
+    // The page should load, but should be properly sandboxed: it shouldn't
+    // have extension APIs or be hosted in a trusted process.
+    content::RenderFrameHost* frame_host = web_contents->GetPrimaryMainFrame();
+    ASSERT_TRUE(frame_host);
+    EXPECT_FALSE(frame_host->IsErrorDocument());
+    EXPECT_EQ("Café Page",
+              content::EvalJs(web_contents, "document.body.innerText"));
+    EXPECT_EQ("null", frame_host->GetLastCommittedOrigin().Serialize());
+    EXPECT_EQ("undefined",
+              content::EvalJs(web_contents, "typeof chrome.runtime"));
+    EXPECT_FALSE(ProcessMap::Get(profile())->Contains(
+        extension->id(), frame_host->GetProcess()->GetID()));
+  }
+}
+
 // Pages that are sandboxed with the HTML5 `sandbox` attribute are treated
 // differently from pages specified in the "sandbox" attribute in the manifest.
 // These pages *do* get extension APIs.
diff --git a/chrome/browser/extensions/web_accessible_resources_browsertest.cc b/chrome/browser/extensions/web_accessible_resources_browsertest.cc
index f6861b432..c0df203 100644
--- a/chrome/browser/extensions/web_accessible_resources_browsertest.cc
+++ b/chrome/browser/extensions/web_accessible_resources_browsertest.cc
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/sandboxed_pages_apitest.cc b/chrome/browser/extensions/sandboxed_pages_apitest.cc
index e5beb22..731679e4 100644
--- a/chrome/browser/extensions/sandboxed_pages_apitest.cc
+++ b/chrome/browser/extensions/sandboxed_pages_apitest.cc
@@ -556,6 +556,83 @@
       extension->id(), frame_host->GetProcess()->GetID()));
 }
 
+// Verifies that requesting a sandboxed page using case-variant path (e.g.
+// "Sandboxed.html") is still recognized as a sandboxed page.
+// Regression test for https://crbug.com/542355360.
+IN_PROC_BROWSER_TEST_F(SandboxedPagesTest, CaseInsensitiveSandboxedPagePath) {
+  static constexpr char kManifest[] =
+      R"({
+           "name": "Case-insensitive sandboxed page test",
+           "version": "0.1",
+           "manifest_version": 3,
+           "sandbox": { "pages": ["sandboxed.html", "café.html"] }
+         })";
+  static constexpr char kSandboxedHtml[] =
+      R"(<html><body>Sandboxed Page</body></html>)";
+  static constexpr char kCafeHtml[] =
+      R"(<html>
+           <head><meta charset="utf-8"></head><body>Café Page</body>
+         </html>)";
+
+  TestExtensionDir test_dir;
+  test_dir.WriteManifest(kManifest);
+  // Write the case-variant files first so that on case-sensitive filesystems
+  // (like Linux) the files exist to be loaded, but on case-insensitive
+  // filesystems (macOS, Windows) the canonical lowercase files are written
+  // second.
+  test_dir.WriteFile(FILE_PATH_LITERAL("Sandboxed.html"), kSandboxedHtml);
+  test_dir.WriteFile(FILE_PATH_LITERAL("sandboxed.html"), kSandboxedHtml);
+  test_dir.WriteFile(FILE_PATH_LITERAL("CAFÉ.html"), kCafeHtml);
+  test_dir.WriteFile(FILE_PATH_LITERAL("café.html"), kCafeHtml);
+
+  const Extension* extension = LoadExtension(test_dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  content::WebContents* web_contents = GetActiveWebContents();
+
+  // Test ASCII case mismatch.
+  {
+    // Note: Deliberately don't use Extension::GetResourceURL(), which goes
+    // through additional sanitization checks.
+    GURL uppercase_url = extension->url().Resolve("Sandboxed.html");
+    ASSERT_TRUE(NavigateToURL(web_contents, uppercase_url));
+
+    // The page should load, but should be properly sandboxed: it shouldn't
+    // have extension APIs or be hosted in a trusted process.
+    content::RenderFrameHost* frame_host = web_contents->GetPrimaryMainFrame();
+    ASSERT_TRUE(frame_host);
+    EXPECT_FALSE(frame_host->IsErrorDocument());
+    EXPECT_EQ("Sandboxed Page",
+              content::EvalJs(web_contents, "document.body.innerText"));
+    EXPECT_EQ("null", frame_host->GetLastCommittedOrigin().Serialize());
+    EXPECT_EQ("undefined",
+              content::EvalJs(web_contents, "typeof chrome.runtime"));
+    EXPECT_FALSE(ProcessMap::Get(profile())->Contains(
+        extension->id(), frame_host->GetProcess()->GetID()));
+  }
+
+  // Test non-ASCII / Unicode UTF-8 case mismatch.
+  {
+    // Note: Deliberately don't use Extension::GetResourceURL(), which goes
+    // through additional sanitization checks.
+    GURL uppercase_url = extension->url().Resolve("CAFÉ.html");
+    ASSERT_TRUE(NavigateToURL(web_contents, uppercase_url));
+
+    // The page should load, but should be properly sandboxed: it shouldn't
+    // have extension APIs or be hosted in a trusted process.
+    content::RenderFrameHost* frame_host = web_contents->GetPrimaryMainFrame();
+    ASSERT_TRUE(frame_host);
+    EXPECT_FALSE(frame_host->IsErrorDocument());
+    EXPECT_EQ("Café Page",
+              content::EvalJs(web_contents, "document.body.innerText"));
+    EXPECT_EQ("null", frame_host->GetLastCommittedOrigin().Serialize());
+    EXPECT_EQ("undefined",
+              content::EvalJs(web_contents, "typeof chrome.runtime"));
+    EXPECT_FALSE(ProcessMap::Get(profile())->Contains(
+        extension->id(), frame_host->GetProcess()->GetID()));
+  }
+}
+
 // Pages that are sandboxed with the HTML5 `sandbox` attribute are treated
 // differently from pages specified in the "sandbox" attribute in the manifest.
 // These pages *do* get extension APIs.
diff --git a/chrome/browser/extensions/web_accessible_resources_browsertest.cc b/chrome/browser/extensions/web_accessible_resources_browsertest.cc
index f6861b432..c0df203 100644
--- a/chrome/browser/extensions/web_accessible_resources_browsertest.cc
+++ b/chrome/browser/extensions/web_accessible_resources_browsertest.cc
@@ -129,6 +129,121 @@
   ASSERT_TRUE(content::EvalJs(web_contents, script).ExtractBool());
 }
 
+// Verifies that web accessible resource matching is case-sensitive, so
+// requests using case-variant paths to allowed resources are blocked, and
+// unlisted resources remain inaccessible.
+IN_PROC_BROWSER_TEST_F(WebAccessibleResourcesBrowserTest,
+                       WebAccessibleResourcesAreCaseSensitive) {
+  static constexpr char kManifest[] = R"({
+    "name": "Case Sensitive WAR Test",
+    "version": "0.1",
+    "manifest_version": 3,
+    "web_accessible_resources": [
+      {
+        "resources": [ "accessible.html", "café.html" ],
+        "matches": [ "<all_urls>" ]
+      }
+    ]
+  })";
+
+  TestExtensionDir extension_dir;
+  extension_dir.WriteManifest(kManifest);
+  // Write the case-variant files first so that on case-sensitive filesystems
+  // (like Linux) the files exist to be loaded, but on case-preserving/case-
+  // insensitive filesystems (macOS, Windows) the canonical lowercase files
+  // are written second.
+  extension_dir.WriteFile(FILE_PATH_LITERAL("Accessible.html"),
+                          "accessible content");
+  extension_dir.WriteFile(FILE_PATH_LITERAL("accessible.html"),
+                          "accessible content");
+  extension_dir.WriteFile(FILE_PATH_LITERAL("CAFÉ.html"), "café content");
+  extension_dir.WriteFile(FILE_PATH_LITERAL("café.html"), "café content");
+  extension_dir.WriteFile(FILE_PATH_LITERAL("Private.html"), "private content");
+  extension_dir.WriteFile(FILE_PATH_LITERAL("private.html"), "private content");
+  const Extension* extension = LoadExtension(extension_dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  GURL web_page = embedded_test_server()->GetURL("example.com", "/simple.html");
+  auto* web_contents = GetActiveWebContents();
+  ASSERT_TRUE(content::NavigateToURL(web_contents, web_page));
+
+  static constexpr char kFetchScript[] = R"(
+    window.fetchResource = async function(url) {
+      try {
+        const response = await fetch(url);
+        return await response.text();
+      } catch (e) {
+        return 'FETCH_FAILED';
+      }
+    };
+  )";
+  ASSERT_TRUE(content::ExecJs(web_contents, kFetchScript));
+
+  // Note: in this test, we deliberately don't use Extension::GetResourceURL(),
+  // which goes through additional sanitization checks.
+
+  // 1. Exact-case declared resource can be fetched.
+  GURL accessible_url = extension->url().Resolve("accessible.html");
+  EXPECT_EQ("accessible content",
+            content::EvalJs(web_contents,
+                            content::JsReplace("window.fetchResource($1)",
+                                               accessible_url)));
+
+  // 2. Case-variant declared resource is blocked because web-accessible
+  // resources are case-sensitive.
+  GURL uppercase_accessible_url = extension->url().Resolve("Accessible.html");
+  EXPECT_EQ("FETCH_FAILED",
+            content::EvalJs(web_contents,
+                            content::JsReplace("window.fetchResource($1)",
+                                               uppercase_accessible_url)));
+
+  // 3. UTF-8 declared resource cannot be fetched because URLPattern matching
+  // does not currently unescape percent-encoded paths in case-sensitive mode
+  // ("caf%C3%A9.html" vs "café.html").
+  // TODO(crbug.com/545512660): Fix percent-encoded matching for non-ASCII paths
+  // in case-sensitive mode.
+  GURL cafe_url = extension->url().Resolve("café.html");
+  EXPECT_EQ(
+      "FETCH_FAILED",
+      content::EvalJs(web_contents, content::JsReplace(
+                                        "window.fetchResource($1)", cafe_url)));
+
+  // Same as above, but using a simple construction of the extension URL.
+  // GURL::Resolve() handles the unicode escaping directly; this more closely
+  // emulates a page just requesting café.html.
+  // As above, this fails because we internally *do* still escape unicode
+  // characters (so the handling is the same).
+  // TODO(crbug.com/545512660): Fix percent-encoded matching for non-ASCII paths
+  // in case-sensitive mode.
+  std::string cafe_url_simple =
+      base::StringPrintf("%scafé.html", extension->url().spec().c_str());
+  EXPECT_EQ("FETCH_FAILED",
+            content::EvalJs(web_contents,
+                            content::JsReplace("window.fetchResource($1)",
+                                               cafe_url_simple)));
+
+  // 4. UTF-8 case-variant declared resource is also blocked.
+  GURL uppercase_cafe_url = extension->url().Resolve("CAFÉ.html");
+  EXPECT_EQ("FETCH_FAILED",
+            content::EvalJs(web_contents,
+                            content::JsReplace("window.fetchResource($1)",
+                                               uppercase_cafe_url)));
+
+  // 5. Unlisted resource (private.html) is blocked.
+  GURL private_url = extension->url().Resolve("private.html");
+  EXPECT_EQ("FETCH_FAILED",
+            content::EvalJs(
+                web_contents,
+                content::JsReplace("window.fetchResource($1)", private_url)));
+
+  // 6. Case-variant of unlisted resource (Private.html) is also blocked.
+  GURL uppercase_private_url = extension->url().Resolve("Private.html");
+  EXPECT_EQ("FETCH_FAILED",
+            content::EvalJs(web_contents,
+                            content::JsReplace("window.fetchResource($1)",
+                                               uppercase_private_url)));
+}
+
 // Exercise these resources being used in iframes in a web page. The navigation
 // flow goes through a different path than resource fetching.
 IN_PROC_BROWSER_TEST_F(WebAccessibleResourcesBrowserTest,
diff --git a/extensions/common/extension_unittest.cc b/extensions/common/extension_unittest.cc
index 40e1dbc..c4578e6 100644
--- a/extensions/common/extension_unittest.cc
+++ b/extensions/common/extension_unittest.cc
@@ -15,9 +15,11 @@
 #include "base/test/scoped_command_line.h"
 #include "base/test/scoped_feature_list.h"
 #include "extensions/common/error_utils.h"
+#include "extensions/common/extension_builder.h"
 #include "extensions/common/extension_features.h"
 #include "extensions/common/manifest_constants.h"
 #include "extensions/common/switches.h"
+#include "extensions/common/url_pattern_set.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
 using extensions::mojom::ManifestLocation;
@@ -409,4 +411,28 @@
             extension->short_name());
 }
 
+TEST(ExtensionTest, ResourceMatchesCaseSensitivity) {
+  scoped_refptr<const Extension> extension = ExtensionBuilder("test").Build();
+  ASSERT_TRUE(extension);
+
+  URLPatternSet pattern_set;
+  URLPattern pattern(URLPattern::SCHEME_EXTENSION);
+  ASSERT_EQ(URLPattern::ParseResult::kSuccess,
+            pattern.Parse(extension->url().spec() + "path.html"));
+  pattern_set.AddPattern(pattern);
+
+  // Exact match succeeds for both case_sensitive = true and false.
+  EXPECT_TRUE(extension->ResourceMatches(pattern_set, "path.html",
+                                         /*case_sensitive=*/true));
+  EXPECT_TRUE(extension->ResourceMatches(pattern_set, "path.html",
+                                         /*case_sensitive=*/false));
+
+  // Case mismatch fails when case_sensitive = true, and succeeds when
+  // case_sensitive = false.
+  EXPECT_FALSE(extension->ResourceMatches(pattern_set, "Path.html",
+                                          /*case_sensitive=*/true));
+  EXPECT_TRUE(extension->ResourceMatches(pattern_set, "Path.html",
+                                         /*case_sensitive=*/false));
+}
+
 }  // namespace extensions
diff --git a/extensions/common/manifest_handlers/csp_info_unittest.cc b/extensions/common/manifest_handlers/csp_info_unittest.cc
index 4dedf3e..10221c65 100644
--- a/extensions/common/manifest_handlers/csp_info_unittest.cc
+++ b/extensions/common/manifest_handlers/csp_info_unittest.cc
@@ -77,6 +77,10 @@
   EXPECT_EQ(kDefaultSandboxedPageCSP, CSPInfo::GetResourceContentSecurityPolicy(
                                           extension1.get(), "/t%65st"));
   EXPECT_TRUE(SandboxedPageInfo::IsSandboxedPage(extension1.get(), "/t%65st"));
+  EXPECT_EQ(kDefaultSandboxedPageCSP, CSPInfo::GetResourceContentSecurityPolicy(
+                                          extension1.get(), "/Test"));
+  EXPECT_TRUE(SandboxedPageInfo::IsSandboxedPage(extension1.get(), "/Test"));
+  EXPECT_TRUE(SandboxedPageInfo::IsSandboxedPage(extension1.get(), "/tEsT"));
   EXPECT_EQ(
       kDefaultExtensionPagesCSP,
       CSPInfo::GetResourceContentSecurityPolicy(extension1.get(), "/none"));
@@ -97,6 +101,12 @@
                                                       "/path/test%2Eext"));
   EXPECT_TRUE(
       SandboxedPageInfo::IsSandboxedPage(extension5.get(), "/path/test%2Eext"));
+  EXPECT_EQ(kDefaultSandboxedPageCSP, CSPInfo::GetResourceContentSecurityPolicy(
+                                          extension5.get(), "/PATH/TEST.EXT"));
+  EXPECT_TRUE(
+      SandboxedPageInfo::IsSandboxedPage(extension5.get(), "/PATH/TEST.EXT"));
+  EXPECT_TRUE(
+      SandboxedPageInfo::IsSandboxedPage(extension5.get(), "/path/TEST.ext"));
   EXPECT_EQ(
       kDefaultExtensionPagesCSP,
       CSPInfo::GetResourceContentSecurityPolicy(extension5.get(), "/test"));
@@ -105,6 +115,30 @@
   EXPECT_EQ(kDefaultSandboxedPageCSP, CSPInfo::GetResourceContentSecurityPolicy(
                                           extension7.get(), "/test"));
 
+  static constexpr char kManifestUtf8Sandboxed[] =
+      R"({
+           "name": "UTF-8 Sandboxed Page Test",
+           "manifest_version": 3,
+           "version": "0.1",
+           "sandbox": {
+             "pages": ["café.html"]
+           }
+         })";
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

sandbox.pages bypass via case-insensitive extension resource aliases exposes privileged APIs


Report description

sandbox.pages bypass via case-insensitive extension resource aliases exposes privileged APIs


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules


The problem

Please describe the technical details of the vulnerability

Summary

Chrome decides whether an extension resource listed in sandbox.pages should receive a unique origin and no extension APIs by matching its URL path against a case-sensitive URLPattern. On macOS and Windows, the extension resource loader opens files through a case-insensitive filesystem.

As a result, sandboxed.html and SANDBOXED.HTML load the same physical file, but only the lowercase URL is recognized as sandboxed. The uppercase alias is hosted as a normal privileged extension page and receives the extension’s chrome.* APIs.

The attached PoC demonstrates that code starting on the canonical sandboxed resource can navigate to the case-only alias, regain chrome.runtime and chrome.tabs, and read the title and URL of an unrelated browser tab.

This still reproduces in Chrome for Testing Canary 153.0.7989.0, revision 1672557. That revision includes the recent percent-encoding fix at main@1671293 for issue 538969297, so this is a bypass of the current fix rather than the already-fixed %2E case.

Tested versions

  • Google Chrome Stable 150.0.7871.183 (Official Build), macOS arm64
  • Chrome for Testing Stable 150.0.7871.124, macOS arm64
  • Chrome for Testing Canary 153.0.7989.0, revision 1672557, macOS arm64
  • macOS default case-insensitive APFS filesystem

Chromium’s installed-extension content-verifier browser test also explicitly states that wrong-case extension resource paths load successfully on macOS and Windows, including an extension installed through the Web Store test path.

Steps to reproduce

  1. Open chrome://extensions, enable Developer mode, and load the attached extension directory.

  2. Copy its generated extension ID.

  3. Open https://example.com/#PRIVATE_TAB_SECRET_91B74C in another tab.

  4. Open the canonical URL:

    chrome-extension://<ID>/sandboxed.html

  5. Observe that the file loads but reports:

{
  "runtimeType": "undefined",
  "runtimeId": null,
  "tabsApiType": "undefined",
  "tabsApiSucceeded": false,
  "privateTabSeen": false
}
  1. Open the case-only alias:

    chrome-extension://<ID>/SANDBOXED.HTML

  2. The same physical file now reports a privileged extension context:

{
  "runtimeType": "object",
  "runtimeId": "<ID>",
  "tabsApiType": "object",
  "tabsApiSucceeded": true,
  "privateTabSeen": true
}
  1. For the complete sandbox-to-privileged transition, open:

    chrome-extension://<ID>/sandboxed.html?escape=case

    The initially sandboxed page navigates itself to /SANDBOXED.HTML?escaped=case; the final page reads the marker tab through chrome.tabs.query().

MANUAL-STEPS.md contains the same procedure. The automated matrix can be run after npm install with:

node run-matrix.mjs "/absolute/path/to/Google Chrome for Testing" artifacts

Root cause

The recent fix changed Extension::ResourceMatches() to percent-decode a URL into a relative FilePath before applying URLPatternSet::MatchesURL(). This aligns %2E and similar encodings, but it does not canonicalize aliases that the platform filesystem treats as the same path.

The relevant mismatch is:

  • URLPattern::MatchesPath() uses case-sensitive string pattern matching.
  • ExtensionURLLoader converts the request path to a FilePath and opens it from disk.
  • APFS/Windows resolve SANDBOXED.HTML to the existing sandboxed.html file.
  • The security policy therefore sees a non-sandboxed URL while the loader serves the manifest-declared sandbox resource.

A second direct-navigation variant uses a double leading slash. The policy path is resolved as a URL authority and fails to match, while the file conversion drops all leading slashes and opens sandboxed.html. The casing variant is the primary report because it also completes the self-navigation impact flow.

Mutation results

In Canary 153 after the percent-encoding fix:

  • 18/18 wrong-case aliases loaded the same resource with chrome.runtime and successful chrome.tabs.query() access.
  • The canonical lowercase resource remained sandboxed.
  • Lowercase percent-encoded aliases such as %73andboxed.html and sandboxed%2Ehtml remained sandboxed, confirming the existing fix works.
  • Dot-segment, query, and fragment controls remained sandboxed.
  • A double-leading-slash direct alias was also privileged.
  • The canonical-to-uppercase self-navigation completed and read the marker tab.

Stable 150 produced the same casing results and also remained vulnerable to the older percent-encoding variants, as expected.

Full results are attached in evidence/*/summary.json.

Suggested fix

Use one canonical extension-resource identity for sandbox, CSP, process/API, web-accessible-resource, content-verification, and file-loading decisions. Possible approaches are:

  1. Reject resource URLs whose path does not exactly match the canonical packaged entry on case-insensitive platforms.
  2. Resolve the requested resource against the installed extension’s canonical file list, then perform all policy checks against that canonical relative path.
  3. Avoid resolving a path beginning with // as a URL authority before policy matching, or reject such extension resource paths consistently in the loader.

Add installed-extension regression tests on macOS and Windows for wrong-case sandbox.pages URLs and tests for multiple leading slashes.

Supporting public changes

Attachments

  • extension/
  • run-matrix.mjs
  • package.json
  • MANUAL-STEPS.md
  • evidence/canary-153/summary.json
  • evidence/official-stable-150/summary.json
  • canonical and escaped screenshots for both builds

Impact analysis

Security impact

Chrome’s extension sandbox is intended for code that requires relaxed CSP, including inline script, eval(), and third-party content. Chrome documents two essential guarantees for pages listed in sandbox.pages: a unique origin and no access to extension APIs.

The alias bypass removes both protections. Any attacker-controlled code path that can select or navigate the sandbox resource alias can execute in the extension’s privileged context and use the permissions already granted to that extension. The PoC uses the tabs permission and proves disclosure of a separate tab’s title and URL; extensions with stronger permissions expose a correspondingly larger impact.

The PoC’s web_accessible_resources: ["*"] and custom sandbox CSP provide a standalone route from the sandbox document to the alias. They are valid extension configurations, not Chrome command-line switches or disabled security features. Without that route, privileged extension code can still load the wrong-case URL directly.


The cause

What version of Chrome have you found the security issue in?

oficial 150.0.7871.183

No, it is not related to a crash.

Choose the type of vulnerability

Permissions Bypass

How would you like to be publicly acknowledged for your report?

yes

View on issue tracker