CVE-2026-113118
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
GURLextensions/browser/api/web_accessible_resources/web_accessible_resources_router.h |
modified | |
ifextensions/browser/api/web_request/extension_web_request_event_router.cc |
modified | |
WebAccessibleResourcesServiceWorkerBrowserTestchrome/browser/extensions/web_accessible_resources_browsertest.cc |
modified |
Files Changed
chrome/browser/extensions/web_accessible_resources_browsertest.ccextensions/browser/api/web_accessible_resources/web_accessible_resources_router.hextensions/browser/api/web_request/extension_web_request_event_router.ccextensions/browser/api/web_accessible_resources/web_accessible_resources_router.cc
Patch
From ae17a4786b15d3ddcb3b0bf407e1a7ba86aa0e6c Mon Sep 17 00:00:00 2001
From: Kelvin Jiang <kelvinjiang@chromium.org>
Date: Wed, 16 Sep 2026 23:21:57 -0700
Subject: [PATCH] [DNR] Only substitute the dynamic URL for the rule owner
TransformToDynamicURLIfNecessary() rewrites a static-id
chrome-extension:// redirect target to its dynamic URL host so that DNR
rules can redirect to the rule owner's use_dynamic_url web accessible
resources without knowing the session GUID. Previously, the rewrite was
applied to whichever extension owned the redirect target, regardless of
which extension owned the redirect rule.
Pass the redirecting extension's ID through GetNewUrl() and only perform
the substitution when the redirect target belongs to that extension.
Both the OnBeforeRequest and OnHeadersReceived DNR redirect paths are
updated. Redirects to a different extension's static URL are left
unchanged, causing standard web accessible resource checks (and
ExtensionNavigationRegistry::CanRedirect() for navigations) to reject
unauthorized cross-extension redirects to dynamic-URL resources.
TAG=agy
Fixed: 513184498
Change-Id: I8c6671dc6fbb9dca6905a0b733be098f89aa7680
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8414395
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Commit-Queue: Kelvin Jiang <kelvinjiang@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1699548}
---
diff --git a/extensions/browser/api/web_accessible_resources/web_accessible_resources_router.cc b/extensions/browser/api/web_accessible_resources/web_accessible_resources_router.cc
index 478bf8eb..1accf3bf 100644
--- a/extensions/browser/api/web_accessible_resources/web_accessible_resources_router.cc
+++ b/extensions/browser/api/web_accessible_resources/web_accessible_resources_router.cc
@@ -6,23 +6,19 @@
#include <optional>
-#include "base/feature_list.h"
-#include "base/types/optional_util.h"
-#include "components/crx_file/id_util.h"
#include "extensions/browser/extension_registry.h"
#include "extensions/common/constants.h"
-#include "extensions/common/extension_features.h"
#include "extensions/common/manifest_handlers/web_accessible_resources_info.h"
#include "url/gurl.h"
namespace extensions {
std::optional<GURL> TransformToDynamicURLIfNecessary(
+ const ExtensionId& extension_id,
const GURL& url,
content::BrowserContext* browser_context) {
- // Verify that the feature is enabled and the host is a valid extension id.
- if (!url.SchemeIs(kExtensionScheme) ||
- !crx_file::id_util::IdIsValid(url.GetHost())) {
+ // Verify that the host matches the extension that initiated the action.
+ if (!url.SchemeIs(kExtensionScheme) || url.host() != extension_id) {
return std::nullopt;
}
@@ -30,7 +26,7 @@
auto* registry = ExtensionRegistry::Get(browser_context);
DCHECK(registry);
const Extension* extension =
- registry->enabled_extensions().GetByID(url.GetHost());
+ registry->enabled_extensions().GetByID(extension_id);
if (!extension || extension->manifest_version() < 3 ||
!WebAccessibleResourcesInfo::ShouldUseDynamicUrl(extension,
url.GetPath())) {
diff --git a/extensions/browser/api/web_accessible_resources/web_accessible_resources_router.h b/extensions/browser/api/web_accessible_resources/web_accessible_resources_router.h
index b44cfa0..ba06d52 100644
--- a/extensions/browser/api/web_accessible_resources/web_accessible_resources_router.h
+++ b/extensions/browser/api/web_accessible_resources/web_accessible_resources_router.h
@@ -7,6 +7,8 @@
#include <optional>
+#include "extensions/common/extension_id.h"
+
class GURL;
namespace content {
@@ -17,8 +19,9 @@
// `use_dynamic_url` as true requires that web accessible resources be loaded
// from a dynamic URL. Return the dynamic URL for the provided static url if it
-// points to resources using `use_dynamic_url`.
+// points to resources of the given `extension_id` using `use_dynamic_url`.
std::optional<GURL> TransformToDynamicURLIfNecessary(
+ const ExtensionId& extension_id,
const GURL& url,
content::BrowserContext* browser_context);
diff --git a/extensions/browser/api/web_request/extension_web_request_event_router.cc b/extensions/browser/api/web_request/extension_web_request_event_router.cc
index 4affa6d0..8a4a20e 100644
--- a/extensions/browser/api/web_request/extension_web_request_event_router.cc
+++ b/extensions/browser/api/web_request/extension_web_request_event_router.cc
@@ -427,10 +427,11 @@
// The `use_dynamic_url` feature for web accessible resources requires that the
// requested url be a dynamic url. A dynamic url is one where a session GUID is
// used for the host instead of the static extension id.
-GURL GetNewUrl(const GURL& redirect_url,
+GURL GetNewUrl(const ExtensionId& extension_id,
+ const GURL& redirect_url,
content::BrowserContext* browser_context) {
- auto dynamic_url =
- TransformToDynamicURLIfNecessary(redirect_url, browser_context);
+ auto dynamic_url = TransformToDynamicURLIfNecessary(
+ extension_id, redirect_url, browser_context);
return dynamic_url.value_or(redirect_url);
}
@@ -1069,7 +1070,8 @@
DCHECK_EQ(1u, actions.size());
DCHECK(action.redirect_url);
OnDNRActionMatched(browser_context, *request, action);
- *new_url = GetNewUrl(action.redirect_url.value(), browser_context);
+ *new_url = GetNewUrl(action.extension_id, action.redirect_url.value(),
+ browser_context);
// Collect redirect action data for the Extension Telemetry Service.
if (action.type == DNRRequestAction::Type::REDIRECT) {
ExtensionsBrowserClient::Get()
@@ -1303,7 +1305,8 @@
extension_web_request_api_helpers::
RedirectRequestAfterHeadersReceived(
- GetNewUrl(action.redirect_url.value(), browser_context),
+ GetNewUrl(action.extension_id, action.redirect_url.value(),
+ browser_context),
**override_response_headers,
preserve_fragment_on_redirect_url);
return net::OK;
diff --git a/chrome/browser/extensions/web_accessible_resources_browsertest.cc b/chrome/browser/extensions/web_accessible_resources_browsertest.cc
index 1709a8577..7777b4c 100644
--- a/chrome/browser/extensions/web_accessible_resources_browsertest.cc
+++ b/chrome/browser/extensions/web_accessible_resources_browsertest.cc
@@ -737,6 +737,208 @@
EXPECT_EQ("ok\n", result.ExtractString());
}
+// Verify that a DNR rule from one extension redirecting to a static URL of
+// a different extension does not transform the destination into the target's
+// dynamic URL.
+IN_PROC_BROWSER_TEST_F(WebAccessibleResourcesBrowserTest,
+ DNRRedirectDifferentExtensionDynamicUrl) {
+ // Load target extension with use_dynamic_url: true and its own DNR rules.
+ TestExtensionDir target_dir;
+ static constexpr char kTargetManifest[] = R"({
+ "name": "Target Extension",
+ "version": "0.1",
+ "manifest_version": 3,
+ "permissions": ["declarativeNetRequest"],
+ "host_permissions": ["*://example.com/*"],
+ "web_accessible_resources": [
+ {
+ "resources": ["dynamic_resource.js", "dynamic_page.html"],
+ "matches": ["*://example.com/*"],
+ "use_dynamic_url": true
+ }
+ ],
+ "declarative_net_request": {
+ "rule_resources": [
+ {
+ "id": "ruleset_1",
+ "path": "rules_1.json",
+ "enabled": true
+ }
+ ]
+ }
+ })";
+ static constexpr char kTargetRules[] = R"([
+ {
+ "id": 1,
+ "priority": 1,
+ "action": {
+ "type": "redirect",
+ "redirect": {
+ "extensionPath": "/dynamic_resource.js"
+ }
+ },
+ "condition": {
+ "regexFilter": ".*/own_resource.js",
+ "resourceTypes": ["script"]
+ }
+ },
+ {
+ "id": 2,
+ "priority": 1,
+ "action": {
+ "type": "redirect",
+ "redirect": {
+ "extensionPath": "/dynamic_page.html"
+ }
+ },
+ "condition": {
+ "regexFilter": ".*/own_subframe.html",
+ "resourceTypes": ["sub_frame"]
+ }
+ }
+ ])";
+ target_dir.WriteManifest(kTargetManifest);
+ target_dir.WriteFile(FILE_PATH_LITERAL("rules_1.json"), kTargetRules);
+ target_dir.WriteFile(FILE_PATH_LITERAL("dynamic_resource.js"),
+ "window.dynamicResourceLoaded = true;");
+ target_dir.WriteFile(FILE_PATH_LITERAL("dynamic_page.html"),
+ "<body>dynamic page content</body>");
+ const Extension* target_extension = LoadExtension(target_dir.UnpackedPath());
+ ASSERT_TRUE(target_extension);
+
+ // Load a separate extension with DNR redirect rules pointing to the target
+ // extension's static resource URLs.
+ TestExtensionDir redirecting_dir;
+ static constexpr char kRedirectingManifest[] = R"({
+ "name": "Redirecting Extension",
+ "version": "0.1",
+ "manifest_version": 3,
+ "permissions": ["declarativeNetRequest"],
+ "host_permissions": ["*://example.com/*"],
+ "declarative_net_request": {
+ "rule_resources": [
+ {
+ "id": "ruleset_1",
+ "path": "rules_1.json",
+ "enabled": true
+ }
+ ]
+ }
+ })";
+ std::string redirecting_rules = base::StringPrintf(
+ R"([
+ {
+ "id": 1,
+ "priority": 1,
+ "action": {
+ "type": "redirect",
+ "redirect": {
+ "url": "chrome-extension://%s/dynamic_resource.js"
+ }
+ },
+ "condition": {
+ "regexFilter": ".*/cross_resource.js",
+ "resourceTypes": ["script"]
+ }
+ },
+ {
+ "id": 2,
+ "priority": 1,
+ "action": {
+ "type": "redirect",
+ "redirect": {
+ "url": "chrome-extension://%s/dynamic_page.html"
+ }
+ },
+ "condition": {
+ "regexFilter": ".*/cross_subframe.html",
+ "resourceTypes": ["sub_frame"]
+ }
+ }
+ ])",
+ target_extension->id().c_str(), target_extension->id().c_str());
+
+ redirecting_dir.WriteManifest(kRedirectingManifest);
+ redirecting_dir.WriteFile(FILE_PATH_LITERAL("rules_1.json"),
+ redirecting_rules);
+ const Extension* redirecting_extension =
+ LoadExtension(redirecting_dir.UnpackedPath());
+ ASSERT_TRUE(redirecting_extension);
+
+ // Navigate to a webpage on example.com with an iframe.
+ content::WebContents* web_contents = GetActiveWebContents();
+ GURL page_url =
+ embedded_test_server()->GetURL("example.com", "/iframe_blank.html");
+ ASSERT_TRUE(content::NavigateToURL(web_contents, page_url));
+
+ // 1. Subresource (script): Verify that the target extension's own DNR
+ // redirect properly resolves to its dynamic URL and executes the script.
+ auto own_script_result = EvalJs(web_contents, R"(
+ new Promise((resolve) => {
+ const script = document.createElement('script');
+ script.src = '/own_resource.js';
+ script.onload = () => resolve(window.dynamicResourceLoaded === true);
+ script.onerror = () => resolve(false);
+ document.body.appendChild(script);
+ });
+ )");
+ EXPECT_EQ(true, own_script_result);
+
+ // 2. Subresource (script): Verify that a different extension's DNR redirect
+ // to the target's static URL is not transformed to the dynamic URL and fails.
+ ASSERT_TRUE(ExecJs(web_contents, "window.dynamicResourceLoaded = false;"));
+ auto cross_script_result = EvalJs(web_contents, R"(
+ new Promise((resolve) => {
+ const script = document.createElement('script');
+ script.src = '/cross_resource.js';
+ script.onload = () => resolve('LOADED');
+ script.onerror = () => resolve('BLOCKED');
+ document.body.appendChild(script);
+ });
+ )");
+ EXPECT_EQ("BLOCKED", cross_script_result);
+ EXPECT_EQ(false, EvalJs(web_contents, "window.dynamicResourceLoaded"));
+
+ // 3. Subframe navigation: Verify that the target extension's own DNR redirect
+ // succeeds and commits the expected page.
+ {
+ GURL own_subframe_trigger =
+ embedded_test_server()->GetURL("example.com", "/own_subframe.html");
+ content::TestNavigationObserver nav_observer(web_contents);
+ EXPECT_TRUE(content::NavigateIframeToURL(web_contents, "test",
+ own_subframe_trigger));
+ nav_observer.Wait();
+ EXPECT_TRUE(nav_observer.last_navigation_succeeded());
+ EXPECT_EQ(net::OK, nav_observer.last_net_error_code());
+ content::RenderFrameHost* iframe =
+ content::ChildFrameAt(web_contents->GetPrimaryMainFrame(), 0);
+ ASSERT_TRUE(iframe);
+ EXPECT_EQ(target_extension->GetResourceURL("dynamic_page.html"),
+ iframe->GetLastCommittedURL());
+ EXPECT_EQ("dynamic page content",
+ EvalJs(iframe, "document.body.innerText"));
+ }
+
+ // 4. Subframe navigation: Verify that a different extension's DNR redirect to
+ // the target's static URL is not rewritten to the target's dynamic URL and
+ // is blocked by ExtensionNavigationThrottle. Note that failed cross-origin
+ // subframe redirects sanitize the URL to its origin
+ // (target_extension->url()).
+ {
+ GURL cross_subframe_trigger =
+ embedded_test_server()->GetURL("example.com", "/cross_subframe.html");
+ content::TestNavigationObserver nav_observer(web_contents);
+ EXPECT_TRUE(content::NavigateIframeToURL(web_contents, "test",
+ cross_subframe_trigger));
+ nav_observer.Wait();
+ EXPECT_FALSE(nav_observer.last_navigation_succeeded());
+ EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, nav_observer.last_net_error_code());
+ EXPECT_EQ(target_extension->url(), nav_observer.last_navigation_url());
+ EXPECT_NE(target_extension->dynamic_url(),
+ nav_observer.last_navigation_url());
+ }
+}
+
class WebAccessibleResourcesServiceWorkerBrowserTest
: public WebAccessibleResourcesBrowserTest {
public:
Regression Test / PoC
diff --git a/chrome/browser/extensions/web_accessible_resources_browsertest.cc b/chrome/browser/extensions/web_accessible_resources_browsertest.cc
index 1709a8577..7777b4c 100644
--- a/chrome/browser/extensions/web_accessible_resources_browsertest.cc
+++ b/chrome/browser/extensions/web_accessible_resources_browsertest.cc
@@ -737,6 +737,208 @@
EXPECT_EQ("ok\n", result.ExtractString());
}
+// Verify that a DNR rule from one extension redirecting to a static URL of
+// a different extension does not transform the destination into the target's
+// dynamic URL.
+IN_PROC_BROWSER_TEST_F(WebAccessibleResourcesBrowserTest,
+ DNRRedirectDifferentExtensionDynamicUrl) {
+ // Load target extension with use_dynamic_url: true and its own DNR rules.
+ TestExtensionDir target_dir;
+ static constexpr char kTargetManifest[] = R"({
+ "name": "Target Extension",
+ "version": "0.1",
+ "manifest_version": 3,
+ "permissions": ["declarativeNetRequest"],
+ "host_permissions": ["*://example.com/*"],
+ "web_accessible_resources": [
+ {
+ "resources": ["dynamic_resource.js", "dynamic_page.html"],
+ "matches": ["*://example.com/*"],
+ "use_dynamic_url": true
+ }
+ ],
+ "declarative_net_request": {
+ "rule_resources": [
+ {
+ "id": "ruleset_1",
+ "path": "rules_1.json",
+ "enabled": true
+ }
+ ]
+ }
+ })";
+ static constexpr char kTargetRules[] = R"([
+ {
+ "id": 1,
+ "priority": 1,
+ "action": {
+ "type": "redirect",
+ "redirect": {
+ "extensionPath": "/dynamic_resource.js"
+ }
+ },
+ "condition": {
+ "regexFilter": ".*/own_resource.js",
+ "resourceTypes": ["script"]
+ }
+ },
+ {
+ "id": 2,
+ "priority": 1,
+ "action": {
+ "type": "redirect",
+ "redirect": {
+ "extensionPath": "/dynamic_page.html"
+ }
+ },
+ "condition": {
+ "regexFilter": ".*/own_subframe.html",
+ "resourceTypes": ["sub_frame"]
+ }
+ }
+ ])";
+ target_dir.WriteManifest(kTargetManifest);
+ target_dir.WriteFile(FILE_PATH_LITERAL("rules_1.json"), kTargetRules);
+ target_dir.WriteFile(FILE_PATH_LITERAL("dynamic_resource.js"),
+ "window.dynamicResourceLoaded = true;");
+ target_dir.WriteFile(FILE_PATH_LITERAL("dynamic_page.html"),
+ "<body>dynamic page content</body>");
+ const Extension* target_extension = LoadExtension(target_dir.UnpackedPath());
+ ASSERT_TRUE(target_extension);
+
+ // Load a separate extension with DNR redirect rules pointing to the target
+ // extension's static resource URLs.
+ TestExtensionDir redirecting_dir;
+ static constexpr char kRedirectingManifest[] = R"({
+ "name": "Redirecting Extension",
+ "version": "0.1",
+ "manifest_version": 3,
+ "permissions": ["declarativeNetRequest"],
+ "host_permissions": ["*://example.com/*"],
+ "declarative_net_request": {
+ "rule_resources": [
+ {
+ "id": "ruleset_1",
+ "path": "rules_1.json",
+ "enabled": true
+ }
+ ]
+ }
+ })";
+ std::string redirecting_rules = base::StringPrintf(
+ R"([
+ {
+ "id": 1,
+ "priority": 1,
+ "action": {
+ "type": "redirect",
+ "redirect": {
+ "url": "chrome-extension://%s/dynamic_resource.js"
+ }
+ },
+ "condition": {
+ "regexFilter": ".*/cross_resource.js",
+ "resourceTypes": ["script"]
+ }
+ },
+ {
+ "id": 2,
+ "priority": 1,
+ "action": {
+ "type": "redirect",
+ "redirect": {
+ "url": "chrome-extension://%s/dynamic_page.html"
+ }
+ },
+ "condition": {
+ "regexFilter": ".*/cross_subframe.html",
+ "resourceTypes": ["sub_frame"]
+ }
+ }
+ ])",
+ target_extension->id().c_str(), target_extension->id().c_str());
+
+ redirecting_dir.WriteManifest(kRedirectingManifest);
+ redirecting_dir.WriteFile(FILE_PATH_LITERAL("rules_1.json"),
+ redirecting_rules);
+ const Extension* redirecting_extension =
+ LoadExtension(redirecting_dir.UnpackedPath());
+ ASSERT_TRUE(redirecting_extension);
+
+ // Navigate to a webpage on example.com with an iframe.
+ content::WebContents* web_contents = GetActiveWebContents();
+ GURL page_url =
+ embedded_test_server()->GetURL("example.com", "/iframe_blank.html");
+ ASSERT_TRUE(content::NavigateToURL(web_contents, page_url));
+
+ // 1. Subresource (script): Verify that the target extension's own DNR
+ // redirect properly resolves to its dynamic URL and executes the script.
+ auto own_script_result = EvalJs(web_contents, R"(
+ new Promise((resolve) => {
+ const script = document.createElement('script');
+ script.src = '/own_resource.js';
+ script.onload = () => resolve(window.dynamicResourceLoaded === true);
+ script.onerror = () => resolve(false);
+ document.body.appendChild(script);
+ });
+ )");
+ EXPECT_EQ(true, own_script_result);
+
+ // 2. Subresource (script): Verify that a different extension's DNR redirect
+ // to the target's static URL is not transformed to the dynamic URL and fails.
+ ASSERT_TRUE(ExecJs(web_contents, "window.dynamicResourceLoaded = false;"));
+ auto cross_script_result = EvalJs(web_contents, R"(
+ new Promise((resolve) => {
+ const script = document.createElement('script');
+ script.src = '/cross_resource.js';
+ script.onload = () => resolve('LOADED');
+ script.onerror = () => resolve('BLOCKED');
+ document.body.appendChild(script);
+ });
+ )");
+ EXPECT_EQ("BLOCKED", cross_script_result);
+ EXPECT_EQ(false, EvalJs(web_contents, "window.dynamicResourceLoaded"));
+
+ // 3. Subframe navigation: Verify that the target extension's own DNR redirect
+ // succeeds and commits the expected page.
+ {
+ GURL own_subframe_trigger =
+ embedded_test_server()->GetURL("example.com", "/own_subframe.html");
+ content::TestNavigationObserver nav_observer(web_contents);
+ EXPECT_TRUE(content::NavigateIframeToURL(web_contents, "test",
+ own_subframe_trigger));
+ nav_observer.Wait();
+ EXPECT_TRUE(nav_observer.last_navigation_succeeded());
+ EXPECT_EQ(net::OK, nav_observer.last_net_error_code());
+ content::RenderFrameHost* iframe =
+ content::ChildFrameAt(web_contents->GetPrimaryMainFrame(), 0);
+ ASSERT_TRUE(iframe);
+ EXPECT_EQ(target_extension->GetResourceURL("dynamic_page.html"),
+ iframe->GetLastCommittedURL());
+ EXPECT_EQ("dynamic page content",
+ EvalJs(iframe, "document.body.innerText"));
+ }
+
+ // 4. Subframe navigation: Verify that a different extension's DNR redirect to
+ // the target's static URL is not rewritten to the target's dynamic URL and
+ // is blocked by ExtensionNavigationThrottle. Note that failed cross-origin
+ // subframe redirects sanitize the URL to its origin
+ // (target_extension->url()).
+ {
+ GURL cross_subframe_trigger =
+ embedded_test_server()->GetURL("example.com", "/cross_subframe.html");
+ content::TestNavigationObserver nav_observer(web_contents);
+ EXPECT_TRUE(content::NavigateIframeToURL(web_contents, "test",
+ cross_subframe_trigger));
+ nav_observer.Wait();
+ EXPECT_FALSE(nav_observer.last_navigation_succeeded());
+ EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT, nav_observer.last_net_error_code());
+ EXPECT_EQ(target_extension->url(), nav_observer.last_navigation_url());
+ EXPECT_NE(target_extension->dynamic_url(),
+ nav_observer.last_navigation_url());
+ }
+}
+
class WebAccessibleResourcesServiceWorkerBrowserTest
: public WebAccessibleResourcesBrowserTest {
public:
Original Bug Report
Bypass of Extension 'use_dynamic_url' via Declarative Net Request (DNR) redirects
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A logic flaw in the Declarative Net Request (DNR) API may allow a malicious extension to bypass the ‘use_dynamic_url’ security feature of other extensions. The browser automatically rewrites static extension URLs to their session-specific dynamic GUIDs without verifying if the redirect initiator is authorized to access the target’s secret GUID.
Affected files:
extensions/browser/api/web_accessible_resources/web_accessible_resources_router.ccextensions/browser/api/web_request/extension_web_request_event_router.ccextensions/browser/extension_navigation_throttle.ccextensions/common/manifest_handlers/web_accessible_resources_info.ccextensions/browser/extension_navigation_registry.ccextensions/browser/api/declarative_net_request/indexed_rule.ccextensions/browser/api/declarative_net_request/ruleset_matcher_base.ccextensions/browser/extension_protocols.ccextensions/browser/url_request_util.cc
Estimated timestamp from git blame: 2024-08-23
Description
A potential security vulnerability exists in the Chromium extension system where the use_dynamic_url protection for web-accessible resources can be bypassed. This feature is intended to prevent unauthorized websites and extensions from fingerprinting or loading extension resources by requiring a secret, session-specific GUID in the URL.
The issue resides in TransformToDynamicURLIfNecessary() within extensions/browser/api/web_accessible_resources/web_accessible_resources_router.cc. This function is responsible for converting a static extension URL (e.g., chrome-extension://<STATIC_ID>/resource) into its dynamic equivalent (e.g., chrome-extension://<GUID>/resource) for Manifest V3 extensions using the use_dynamic_url property.
When a declarativeNetRequest (DNR) redirect rule matches, the browser calls this function to rewrite the destination URL. Crucially, the function resolves the target extension based solely on the host of the redirect URL and automatically substitutes that extension’s session GUID without verifying if the extension initiating the DNR redirect owns the target resource or is otherwise authorized to know its GUID.
Potential Impact
- GUID Leakage: An attacker can register a DNR rule to redirect a public URL to a target extension’s static resource URL. The browser rewrites this to the dynamic GUID URL. This rewritten URL is then visible to any extension with
webRequestpermissions (including the attacker) via theonBeforeRedirectevent, revealing the secret session GUID. - Resource Loading Bypass: By providing the correct GUID automatically, the DNR pipeline satisfies the Web Accessible Resource (WAR) check in
IsResourceWebAccessibleImpl(), which mandates the use of the dynamic GUID for protected resources. This allows the attacker to force-load resources that should remain hidden from entities that do not already know the GUID. - Cross-Extension Defense Bypass: The automatic rewrite occurs early in the request pipeline, causing the subsequent WAR check to succeed. This may allow the request to bypass more restrictive checks like
ExtensionNavigationRegistry::CanRedirect()which are intended to prevent unauthorized cross-extension navigations.
Potential Step-by-Step Reproduction
Note: These steps are based on source code analysis; a functional proof-of-concept has not yet been executed.
- Target Extension (B): Install an extension that defines a resource with
"use_dynamic_url": trueand amatchespattern forhttps://example.com/*. - Attacker Extension (A): Install an extension with
declarativeNetRequestpermissions and host permissions forhttps://example.com/*. - Extension A registers a DNR rule to redirect
https://example.com/triggertochrome-extension://<Extension-B-ID>/resource.html. - A navigation to
https://example.com/triggeris initiated (e.g., via an iframe onhttps://example.com). - The DNR rule triggers.
TransformToDynamicURLIfNecessaryrewrites the destination tochrome-extension://<Extension-B-GUID>/resource.html. - Extension A observes the secret GUID in the
onBeforeRedirectevent via its ownwebRequestlistener. ExtensionNavigationThrottleperforms a WAR check. Since the URL now contains the correct GUID, the check succeeds, and Extension B’s resource is loaded.
Suggested Fix
Modify TransformToDynamicURLIfNecessary (and its caller GetNewUrl in extension_web_request_event_router.cc) to accept the ExtensionId of the extension initiating the redirect. The transformation should only be performed if the initiator’s ID matches the target extension’s ID, or if the initiator is a privileged context (e.g., browser-initiated). If the initiator is a different extension, the static URL should not be automatically rewritten to the dynamic one.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.