Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Browser
DescriptionIncorrect authorization in Browser
ComponentBrowser
Bug ClassLogic Error
Tracker538969297
Fix commita6c88df1ea51 (chromium/src) +70/-1
CISA KEVNot listed
CreditedM. Fauzan Wijaya (Gh05t666nero)
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
IN_PROC_BROWSER_TEST_F
chrome/browser/extensions/sandboxed_pages_apitest.cc
modified

Files Changed

  • chrome/browser/extensions/sandboxed_pages_apitest.cc
  • extensions/common/extension.cc
  • extensions/common/manifest_handlers/csp_info_unittest.cc
From a6c88df1ea51133a9f9f9dea1691f692c4519065 Mon Sep 17 00:00:00 2001
From: Devlin Cronin <[email protected]>
Date: Thu, 30 Jul 2026 11:56:54 -0700
Subject: [PATCH] [Extensions] Canonicalize resource paths in Extension::ResourceMatches

Percent-encoded resource URLs (e.g. `frame%2Ehtml`) caused
`sandbox.pages` pattern checks in `Extension::ResourceMatches` to fail,
even though the ExtensionURLLoader served the underlying file from disk.
This allowed hosting these pages -- that should be sandboxed -- in
non-sandboxed contexts.

Updates `Extension::ResourceMatches` to unescape paths via
`ExtensionURLToRelativeFilePath` before matching against URL patterns.

Add a regression test for the same.

Bug: 538969297
Change-Id: I697233451dbf9b43ca5ff9d82956413a0ac9c755
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8171283
Reviewed-by: Andrea Orru <[email protected]>
Commit-Queue: Devlin Cronin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1671293}
---

diff --git a/chrome/browser/extensions/sandboxed_pages_apitest.cc b/chrome/browser/extensions/sandboxed_pages_apitest.cc
index efdddfc..e5beb22 100644
--- a/chrome/browser/extensions/sandboxed_pages_apitest.cc
+++ b/chrome/browser/extensions/sandboxed_pages_apitest.cc
@@ -514,6 +514,48 @@
       web_contents->GetPrimaryMainFrame()->GetProcess()->GetID()));
 }
 
+// Verifies that requesting a sandboxed page using percent-encoding in the path
+// (e.g. "sandboxed%2Ehtml") is still recognized as a sandboxed page.
+// Regression test for https://crbug.com/538969297.
+IN_PROC_BROWSER_TEST_F(SandboxedPagesTest, PercentEncodedSandboxedPagePath) {
+  static constexpr char kManifest[] =
+      R"({
+           "name": "Percent-encoded sandboxed page test",
+           "version": "0.1",
+           "manifest_version": 3,
+           "sandbox": { "pages": ["sandboxed.html"] }
+         })";
+  static constexpr char kSandboxedHtml[] =
+      R"(<html><body>Sandboxed Page</body></html>)";
+
+  TestExtensionDir test_dir;
+  test_dir.WriteManifest(kManifest);
+  test_dir.WriteFile(FILE_PATH_LITERAL("sandboxed.html"), kSandboxedHtml);
+
+  const Extension* extension = LoadExtension(test_dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  content::WebContents* web_contents = GetActiveWebContents();
+  GURL percent_encoded_url(extension->url().spec() + "sandboxed%2Ehtml");
+
+  ASSERT_TRUE(content::NavigateToURL(web_contents, percent_encoded_url));
+
+  content::RenderFrameHost* frame_host = web_contents->GetPrimaryMainFrame();
+  ASSERT_TRUE(frame_host);
+
+  // The frame should be sandboxed, so the origin should be "null".
+  EXPECT_EQ("null", frame_host->GetLastCommittedOrigin().Serialize());
+
+  // Extension APIs like `chrome.runtime` should be withheld.
+  EXPECT_EQ("undefined",
+            content::EvalJs(web_contents, "typeof chrome.runtime"));
+
+  // Sandboxed pages are hosted in a process that isn't tracked in the
+  // process map.
+  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/extensions/common/extension.cc b/extensions/common/extension.cc
index 5ff75561..9413abe 100644
--- a/extensions/common/extension.cc
+++ b/extensions/common/extension.cc
@@ -312,7 +312,26 @@
 
 bool Extension::ResourceMatches(const URLPatternSet& pattern_set,
                                 std::string_view resource) const {
-  return pattern_set.MatchesURL(extension_url_.Resolve(resource));
+  // First, resolve `resource` relative to the extension's base URL.
+  GURL resolved = extension_url_.Resolve(resource);
+
+  // Convert the URL to a relative file path inside the extension package, which
+  // unescapes percent-encoded path components (e.g. "%2E" -> "."). This aligns
+  // pattern matching with the URL loader's file resolution logic.
+  base::FilePath relative_path =
+      file_util::ExtensionURLToRelativeFilePath(resolved);
+
+  // If the path cannot be resolved to a valid relative path within the
+  // extension (e.g. due to encoded path separators or malformed URLs), it is
+  // not a valid extension resource and cannot match any resource pattern.
+  if (relative_path.empty()) {
+    return false;
+  }
+
+  // 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()));
 }
 
 ExtensionResource Extension::GetResource(std::string_view relative_path) const {
diff --git a/extensions/common/manifest_handlers/csp_info_unittest.cc b/extensions/common/manifest_handlers/csp_info_unittest.cc
index c24d8f0..4dedf3e 100644
--- a/extensions/common/manifest_handlers/csp_info_unittest.cc
+++ b/extensions/common/manifest_handlers/csp_info_unittest.cc
@@ -74,6 +74,9 @@
 
   EXPECT_EQ(kDefaultSandboxedPageCSP, CSPInfo::GetResourceContentSecurityPolicy(
                                           extension1.get(), "/test"));
+  EXPECT_EQ(kDefaultSandboxedPageCSP, CSPInfo::GetResourceContentSecurityPolicy(
+                                          extension1.get(), "/t%65st"));
+  EXPECT_TRUE(SandboxedPageInfo::IsSandboxedPage(extension1.get(), "/t%65st"));
   EXPECT_EQ(
       kDefaultExtensionPagesCSP,
       CSPInfo::GetResourceContentSecurityPolicy(extension1.get(), "/none"));
@@ -89,6 +92,11 @@
                                           extension4.get(), "/test"));
   EXPECT_EQ(kDefaultSandboxedPageCSP, CSPInfo::GetResourceContentSecurityPolicy(
                                           extension5.get(), "/path/test.ext"));
+  EXPECT_EQ(kDefaultSandboxedPageCSP,
+            CSPInfo::GetResourceContentSecurityPolicy(extension5.get(),
+                                                      "/path/test%2Eext"));
+  EXPECT_TRUE(
+      SandboxedPageInfo::IsSandboxedPage(extension5.get(), "/path/test%2Eext"));
   EXPECT_EQ(
       kDefaultExtensionPagesCSP,
       CSPInfo::GetResourceContentSecurityPolicy(extension5.get(), "/test"));
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 efdddfc..e5beb22 100644
--- a/chrome/browser/extensions/sandboxed_pages_apitest.cc
+++ b/chrome/browser/extensions/sandboxed_pages_apitest.cc
@@ -514,6 +514,48 @@
       web_contents->GetPrimaryMainFrame()->GetProcess()->GetID()));
 }
 
+// Verifies that requesting a sandboxed page using percent-encoding in the path
+// (e.g. "sandboxed%2Ehtml") is still recognized as a sandboxed page.
+// Regression test for https://crbug.com/538969297.
+IN_PROC_BROWSER_TEST_F(SandboxedPagesTest, PercentEncodedSandboxedPagePath) {
+  static constexpr char kManifest[] =
+      R"({
+           "name": "Percent-encoded sandboxed page test",
+           "version": "0.1",
+           "manifest_version": 3,
+           "sandbox": { "pages": ["sandboxed.html"] }
+         })";
+  static constexpr char kSandboxedHtml[] =
+      R"(<html><body>Sandboxed Page</body></html>)";
+
+  TestExtensionDir test_dir;
+  test_dir.WriteManifest(kManifest);
+  test_dir.WriteFile(FILE_PATH_LITERAL("sandboxed.html"), kSandboxedHtml);
+
+  const Extension* extension = LoadExtension(test_dir.UnpackedPath());
+  ASSERT_TRUE(extension);
+
+  content::WebContents* web_contents = GetActiveWebContents();
+  GURL percent_encoded_url(extension->url().spec() + "sandboxed%2Ehtml");
+
+  ASSERT_TRUE(content::NavigateToURL(web_contents, percent_encoded_url));
+
+  content::RenderFrameHost* frame_host = web_contents->GetPrimaryMainFrame();
+  ASSERT_TRUE(frame_host);
+
+  // The frame should be sandboxed, so the origin should be "null".
+  EXPECT_EQ("null", frame_host->GetLastCommittedOrigin().Serialize());
+
+  // Extension APIs like `chrome.runtime` should be withheld.
+  EXPECT_EQ("undefined",
+            content::EvalJs(web_contents, "typeof chrome.runtime"));
+
+  // Sandboxed pages are hosted in a process that isn't tracked in the
+  // process map.
+  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/extensions/common/manifest_handlers/csp_info_unittest.cc b/extensions/common/manifest_handlers/csp_info_unittest.cc
index c24d8f0..4dedf3e 100644
--- a/extensions/common/manifest_handlers/csp_info_unittest.cc
+++ b/extensions/common/manifest_handlers/csp_info_unittest.cc
@@ -74,6 +74,9 @@
 
   EXPECT_EQ(kDefaultSandboxedPageCSP, CSPInfo::GetResourceContentSecurityPolicy(
                                           extension1.get(), "/test"));
+  EXPECT_EQ(kDefaultSandboxedPageCSP, CSPInfo::GetResourceContentSecurityPolicy(
+                                          extension1.get(), "/t%65st"));
+  EXPECT_TRUE(SandboxedPageInfo::IsSandboxedPage(extension1.get(), "/t%65st"));
   EXPECT_EQ(
       kDefaultExtensionPagesCSP,
       CSPInfo::GetResourceContentSecurityPolicy(extension1.get(), "/none"));
@@ -89,6 +92,11 @@
                                           extension4.get(), "/test"));
   EXPECT_EQ(kDefaultSandboxedPageCSP, CSPInfo::GetResourceContentSecurityPolicy(
                                           extension5.get(), "/path/test.ext"));
+  EXPECT_EQ(kDefaultSandboxedPageCSP,
+            CSPInfo::GetResourceContentSecurityPolicy(extension5.get(),
+                                                      "/path/test%2Eext"));
+  EXPECT_TRUE(
+      SandboxedPageInfo::IsSandboxedPage(extension5.get(), "/path/test%2Eext"));
   EXPECT_EQ(
       kDefaultExtensionPagesCSP,
       CSPInfo::GetResourceContentSecurityPolicy(extension5.get(), "/test"));
Loading diff…

Original Bug Report

reported by [email protected]

sandbox.pages path matching bypass allows web content to run in the extension origin


Report description

sandbox.pages path matching bypass allows web content to run in the extension origin


Bug location

Where do you want to report your vulnerability?

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

Which URL (or repository) have you found the vulnerability in?

https://chromium.googlesource.com/chromium/src/


The problem

Please describe the technical details of the vulnerability

Vulnerability details

A resource listed in the manifest’s sandbox.pages must load in an opaque origin with no extension API bindings. Requesting the same file with one dot percent-encoded, sbx/frame%2Ehtml instead of sbx/frame.html, makes the manifest match fail while the file is still served. The document commits at the real chrome-extension://<id> origin, is classified as a privileged extension context, and receives chrome.* bindings.

The sandbox decision is made on the still percent-encoded path, while the file is loaded from the percent-decoded path.

Reproduction case

  1. Create a folder with manifest.json and a subfolder sbx containing frame.html, frame.js and panel.html. The manifest declares "sandbox": {"pages": ["sbx/frame.html"]} and "web_accessible_resources": [{"resources": ["sbx/*"], "matches": ["*://*/*"]}]. This is an ordinary extension: it renders untrusted content in a sandboxed page, which is the documented pattern, and keeps a normal page holding an account name and a sync token.
  2. At chrome://extensions, enable Developer mode, click Load unpacked, select that folder, copy the extension ID from its card.
  3. Put poc.html and server.py in a second folder and run py -3 server.py on Windows or python3 server.py on Linux. It serves 127.0.0.1:8399 and needs no elevation.
  4. Open http://127.0.0.1:8399/poc.html, paste the extension ID, click load.

Two iframes load the identical file. Left requests /sbx/frame.html, the path as written in sandbox.pages. Right requests /sbx/frame%2Ehtml.

Observed

Left frame, correctly sandboxed
  arm: PLAIN
  chrome.runtime : false
  everything below requires the extension origin, correctly unavailable here

Right frame, same file, encoded dot
  arm: ESCAPED (%2E)
  chrome.runtime : true
  chrome.runtime.id : hpcngfepkophmeocofcgbkafmdchagja
  chrome.storage.local : {"attacker_wrote":"from ESCAPED"}
  panel.html account : [email protected]
  panel.html token   : sync-token-9f3c8a1e
  >>> extension page content read from web content <<<

Expected

Both frames behave like the left frame. The left frame is the control: same extension, same file, one character of difference.

Root cause

extension_protocols.cc#340 reads the path with request.url.GetPath(), canonicalized but still percent-encoded, and passes it at #345 to CSPInfo::GetResourceContentSecurityPolicy. That picks the sandbox CSP or the ordinary extension pages CSP based only on csp_info.cc#255, a manifest pattern match at sandboxed_page_info.cc#47. /sbx/frame%2Ehtml does not match sbx/frame.html, so the non-sandbox CSP is emitted and the document is never opaquified.

The file is still found because file_util.cc#505 calls UnescapeBinaryURLComponentSafe(url_path, /*fail_on_path_separators=*/true, ...), which rejects %2F but not %2E. The encoded form reaches the matcher at all because url_features.cc#45 kPreservePercentEncodedDotInPath is FEATURE_ENABLED_BY_DEFAULT, and web content may request it because web_accessible_resources matching is glob based, so sbx/* admits the encoded form exactly as it admits the plain path.

Four protections consult that same failed match, so they fail open together:

Site Effect
extension_protocols.cc#340 sandbox CSP not emitted, origin not opaque
script_context.cc#178 context classified privileged, bindings injected
script_context.cc#469 “cannot be used within a sandboxed frame” never thrown
extensions_part.cc#380 frame not routed into a cross process sandbox

Both renderer sites already flag the comparison as wrong. script_context.cc#172 reads “This is checking the wrong thing”, and #461 reads “this check is silly. The frame’s document’s security origin already tells us if it’s sandboxed.”

Suggested fix

Make the security decision on the string the file is loaded from. Narrowly, in GetSecurityPolicyForURL at extension_protocols.cc#340, derive the relative path with ExtensionURLToRelativeFilePath or apply the same unescaping instead of passing request.url.GetPath(), so IsSandboxedPage sees the decoded path. Since four call sites compare the same encoded path, the durable fix is one shared helper returning the decoded relative path, used by every consumer that matches manifest patterns.

Issue 40091584 already tracks that URL rendering code is used for converting between URLs and filenames. This is a security relevant instance of it.

Impact analysis

Attack scenario

The attacker owns a website and nothing else. No local access, no malicious extension of their own, no control over what the victim installed.

The victim has installed an honest extension that uses sandbox.pages for its intended purpose, running untrusted content in an opaque origin, and exposes that path through a globbed web_accessible_resources entry such as sbx/*.

The victim visits the attacker’s page, which embeds chrome-extension://<id>/sbx/frame%2Ehtml in an iframe. Chrome serves the extension’s sandboxed file but does not sandbox it, so the attacker gets a frame executing inside the victim extension’s origin and reads and writes its chrome.storage. Extension IDs are fixed and public for Web Store extensions, so nothing has to be guessed, and the victim only has to visit a page.

Impact

A web page obtains a privileged extension context at the victim extension’s origin. The PoC demonstrates three things, all measured:

  1. chrome.runtime.id, confirming the real extension identity.
  2. Writing to and then reading back chrome.storage.local. Write access matters as much as read, because it lets the attacker persistently reconfigure the extension, and the change survives after the attacker’s tab is closed.
  3. Reading the DOM of another extension page, panel.html, recovering an account name and a sync token. A web page embedding that page directly cannot read it, because it commits at the extension origin. From inside the escaped frame it is same origin and readable.

The reachable chrome.* surface is bounded by the victim extension’s permissions, so the ceiling depends on which extension is installed.

sandbox.pages exists so an extension can run untrusted content safely, so this inverts the feature’s purpose: the developers who followed the security guidance are the ones exposed.


The cause

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

150.0.7871.182 Stable, installed, default settings. Also reproduced on a local ASan build of 152.0.7972.0

No, it is not related to a crash.

Choose the type of vulnerability

Privilege Escalation

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

M. Fauzan Wijaya (Gh05t666nero)

View on issue tracker
Links in the report