CVE-2026-87544
Overview
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 bySandboxedPageInfo::IsSandboxedPage. - `Extension::ResourceMatches`
- helper in
extensions/common/extension.ccthat resolves a relative resource path against the extension base URL and asks aURLPatternSetwhether any pattern matches it. - Case-insensitive filesystems
- filesystems (Windows, most macOS setups) where
Sandboxed.htmlandsandboxed.htmlresolve 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.
Attack Path
- Declare a sandboxed page
A (potentially malicious or compromised) extension ships a manifest with
"sandbox": { "pages": ["sandboxed.html"] }and the filesandboxed.htmlon disk. - 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 ofsandboxed.html, but the pre-patch case-sensitiveURLPattern::MatchesPathfails to match the sandbox pattern. - Frame loads unsandboxed
Because
IsSandboxedPagereturned 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.runtimeis"undefined", process not in the extensionProcessMap), demonstrating that the pre-fix path lacked these protections. - 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
(inferred) and not proven by the patch, which only shows the sandbox-vs-not-sandbox decision being corrected.Changed Functions
| Function | Change | Notes |
|---|---|---|
forextensions/common/manifest_handlers/web_accessible_resources_info.cc |
modified | |
forextensions/common/manifest_handlers/webview_info.cc |
modified | |
ifextensions/common/url_pattern.cc |
modified | |
forextensions/common/url_pattern_set.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/browser/extensions/sandboxed_pages_apitest.cc |
modified |
Files Changed
extensions/common/url_pattern.ccextensions/common/extension_unittest.ccextensions/common/manifest_handlers/web_accessible_resources_info.ccextensions/common/BUILD.gnextensions/common/url_pattern_set.ccextensions/common/manifest_handlers/csp_info_unittest.ccchrome/browser/extensions/web_accessible_resources_browsertest.ccextensions/common/url_pattern_unittest.ccextensions/common/url_pattern_set_unittest.ccextensions/common/extension.ccextensions/common/url_pattern.hextensions/common/manifest_handlers/webview_info.ccextensions/common/extension.hextensions/common/url_pattern_set.hchrome/browser/extensions/sandboxed_pages_apitest.ccextensions/common/manifest_handlers/sandboxed_page_info.cc
Audit Directions
- Case-sensitivity of security decisions on case-insensitive filesystemsAudit 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 callerWhere one matching helper (
ResourceMatches/MatchesURL) now takes acase_sensitiveflag, 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 matchersReview path matchers that compare escaped vs unescaped or ASCII vs Unicode forms (the TODO for
crbug.com/545512660about percent-encoded non-ASCII paths in case-sensitive mode) for inconsistent unescaping/case-folding that lets equivalent paths bypass or wrongly satisfy a match.
Patch
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)
Regression Test / PoC
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)
Original Bug Report
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, revision1672557, 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
-
Open
chrome://extensions, enable Developer mode, and load the attachedextensiondirectory. -
Copy its generated extension ID.
-
Open
https://example.com/#PRIVATE_TAB_SECRET_91B74Cin another tab. -
Open the canonical URL:
chrome-extension://<ID>/sandboxed.html -
Observe that the file loads but reports:
{
"runtimeType": "undefined",
"runtimeId": null,
"tabsApiType": "undefined",
"tabsApiSucceeded": false,
"privateTabSeen": false
}
-
Open the case-only alias:
chrome-extension://<ID>/SANDBOXED.HTML -
The same physical file now reports a privileged extension context:
{
"runtimeType": "object",
"runtimeId": "<ID>",
"tabsApiType": "object",
"tabsApiSucceeded": true,
"privateTabSeen": true
}
-
For the complete sandbox-to-privileged transition, open:
chrome-extension://<ID>/sandboxed.html?escape=caseThe initially sandboxed page navigates itself to
/SANDBOXED.HTML?escaped=case; the final page reads the marker tab throughchrome.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.ExtensionURLLoaderconverts the request path to aFilePathand opens it from disk.- APFS/Windows resolve
SANDBOXED.HTMLto the existingsandboxed.htmlfile. - 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.runtimeand successfulchrome.tabs.query()access. - The canonical lowercase resource remained sandboxed.
- Lowercase percent-encoded aliases such as
%73andboxed.htmlandsandboxed%2Ehtmlremained 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:
- Reject resource URLs whose path does not exactly match the canonical packaged entry on case-insensitive platforms.
- Resolve the requested resource against the installed extension’s canonical file list, then perform all policy checks against that canonical relative path.
- 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
- Chrome’s documented sandbox guarantee: https://developer.chrome.com/docs/extensions/how-to/security/sandboxing-eval
- Percent-encoding fix: https://chromium.googlesource.com/chromium/src/+/a6c88df1ea51133a9f9f9dea1691f692c4519065
- Current
Extension::ResourceMatches()implementation: https://chromium.googlesource.com/chromium/src/+/main/extensions/common/extension.cc - Installed-extension wrong-case resource test: https://chromium.googlesource.com/chromium/src/+/main/chrome/browser/extensions/content_verifier_browsertest.cc
- Fixed issue referenced by that commit: 538969297
- Canary tested revision:
main@1672557
Attachments
extension/run-matrix.mjspackage.jsonMANUAL-STEPS.mdevidence/canary-153/summary.jsonevidence/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
Is the security issue related to a crash?
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
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://chromium.googlesource.com/chromium/src/+/a6c88df1ea51133a9f9f9dea1691f692c4519065
- https://chromium.googlesource.com/chromium/src/+/main/chrome/browser/extensions/content_verifier_browsertest.cc
- https://chromium.googlesource.com/chromium/src/+/main/extensions/common/extension.cc
- https://developer.chrome.com/docs/extensions/how-to/security/sandboxing-eval
- https://issuetracker.google.com/issues/538969297