Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Chrome for iOS
DescriptionInsufficient policy enforcement in Chrome for iOS
ComponentChrome for iOS
Bug ClassLogic Error
Tracker502503860
Fix commit6b67808f449e (chromium/src) +159/-30
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
source_set
ios/web/webui/BUILD.gn
modified
if
ios/web/webui/crw_web_ui_scheme_handler.mm
modified

Files Changed

  • ios/chrome/browser/webui/ui_bundled/web_ui_egtest.mm
  • ios/web/webui/BUILD.gn
  • ios/web/webui/crw_web_ui_scheme_handler.mm
  • ios/web/webui/shared_resources_data_source_ios.mm
From 6b67808f449e4e6ebfd00182dcf09c7a37bb75b9 Mon Sep 17 00:00:00 2001
From: Mike Dougherty <michaeldo@chromium.org>
Date: Mon, 27 Apr 2026 02:18:09 -0700
Subject: [PATCH] Correctly pass WebUI headers to resource response

This CL forwards headers set on URLRequestChromeJob instances inside
URLDataManagerIOSBackend to the actual response. Previously these
headers were unintentionally discarded.

Although set_add_content_security_policy is being explicitly disabled in
this CL, it represents no change in behavior because the header was not
previously forwarded to the response.

Bug: 502503860
Change-Id: I85b33794a405bf132b094c47e890df0473c1b591
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7764734
Auto-Submit: Mike Dougherty <michaeldo@chromium.org>
Commit-Queue: Gauthier Ambard <gambard@chromium.org>
Reviewed-by: Gauthier Ambard <gambard@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1620954}
---

diff --git a/ios/chrome/browser/webui/ui_bundled/web_ui_egtest.mm b/ios/chrome/browser/webui/ui_bundled/web_ui_egtest.mm
index be47522..bdfb9f3d 100644
--- a/ios/chrome/browser/webui/ui_bundled/web_ui_egtest.mm
+++ b/ios/chrome/browser/webui/ui_bundled/web_ui_egtest.mm
@@ -325,4 +325,33 @@
                       "This page is not available in incognito mode."];
 }
 
+// Tests that cross-origin requests between different WebUI pages are blocked.
+- (void)testWebUICrossOriginAccess {
+  // WebUI page must already exist before any request to it will succeed.
+  [ChromeEarlGrey loadURL:GURL(kChromeUIChromeURLsURL)];
+  [ChromeEarlGrey waitForWebStateVisibleURL:GURL(kChromeUIChromeURLsURL)];
+
+  [ChromeEarlGrey openNewTab];
+  [ChromeEarlGrey loadURL:GURL(kChromeUIVersionURL)];
+  [ChromeEarlGrey waitForWebStateVisibleURL:GURL(kChromeUIVersionURL)];
+
+  NSString* fetchChromeUrlsScript =
+      @"(function() {"
+       "  var xhr = new XMLHttpRequest();"
+       "  xhr.open('GET', 'chrome://chrome-urls/', false);"
+       "  try {"
+       "    xhr.send(null);"
+       "    return xhr.status == 200;"
+       "  } catch (e) {"
+       "    return false;"
+       "  }"
+       "})()";
+  std::optional<bool> chromeUrlsResult =
+      [ChromeEarlGrey evaluateJavaScript:fetchChromeUrlsScript].GetIfBool();
+  GREYAssertTrue(chromeUrlsResult, @"Result type is not a boolean.");
+  GREYAssertFalse(chromeUrlsResult.value(),
+                  @"Should not be able to fetch chrome://chrome-urls/ from "
+                  @"chrome://version");
+}
+
 @end
diff --git a/ios/web/webui/BUILD.gn b/ios/web/webui/BUILD.gn
index 42f4c15..cf5121d 100644
--- a/ios/web/webui/BUILD.gn
+++ b/ios/web/webui/BUILD.gn
@@ -6,6 +6,7 @@
 
 source_set("webui") {
   deps = [
+    ":internal_constants",
     "//base",
     "//ios/web:resources",
     "//ios/web:resources_grit",
@@ -54,3 +55,11 @@
   # enable the diagnostic by removing this line.
   configs += [ "//build/config/compiler:no_exit_time_destructors" ]
 }
+
+source_set("internal_constants") {
+  visibility = [ ":webui" ]
+  sources = [
+    "web_ui_constants.h",
+    "web_ui_constants.mm",
+  ]
+}
diff --git a/ios/web/webui/crw_web_ui_scheme_handler.mm b/ios/web/webui/crw_web_ui_scheme_handler.mm
index 17ec639..6328a22 100644
--- a/ios/web/webui/crw_web_ui_scheme_handler.mm
+++ b/ios/web/webui/crw_web_ui_scheme_handler.mm
@@ -8,10 +8,13 @@
 #import <map>
 
 #import "base/files/file_path.h"
+#import "base/strings/sys_string_conversions.h"
 #import "ios/web/webui/url_fetcher_block_adapter.h"
+#import "ios/web/webui/web_ui_constants.h"
 #import "ios/web/webui/web_ui_ios_controller_factory_registry.h"
 #import "net/base/apple/url_conversions.h"
 #import "url/gurl.h"
+#import "url/scheme_host_port.h"
 
 namespace {
 // Returns the error code associated with `URL`.
@@ -45,8 +48,8 @@
   GURL URL = net::GURLWithNSURL(urlSchemeTask.request.URL);
   // Check the mainDocumentURL as the URL might be one of the subresource, so
   // not a WebUI URL itself.
-  NSInteger errorCode = GetErrorCodeForUrl(
-      net::GURLWithNSURL(urlSchemeTask.request.mainDocumentURL));
+  GURL webUIURL = net::GURLWithNSURL(urlSchemeTask.request.mainDocumentURL);
+  NSInteger errorCode = GetErrorCodeForUrl(webUIURL);
   if (errorCode != 0) {
     NSError* error = [NSError
         errorWithDomain:NSURLErrorDomain
@@ -58,34 +61,58 @@
     return;
   }
 
+  // The "Access-Control-Allow-Origin" header is required below to allow
+  // requests from any WebUI page to load chrome://resources URLs. However,
+  // requests between different WebUI pages are blocked directly instead.
+  if (!URL.DomainIs(web::kWebUIResourcesHost) && webView.URL &&
+      url::SchemeHostPort(URL) !=
+          url::SchemeHostPort(net::GURLWithNSURL(webView.URL))) {
+    NSError* error = [NSError
+        errorWithDomain:NSURLErrorDomain
+                   code:NSURLErrorNoPermissionsToReadFile
+               userInfo:@{
+                 NSURLErrorFailingURLErrorKey : urlSchemeTask.request.URL
+               }];
+    [urlSchemeTask didFailWithError:error];
+    return;
+  }
+
   __weak CRWWebUISchemeHandler* weakSelf = self;
   std::unique_ptr<web::URLFetcherBlockAdapter> adapter =
       std::make_unique<web::URLFetcherBlockAdapter>(
           URL, _URLLoaderFactory,
-          ^(NSData* data, web::URLFetcherBlockAdapter* fetcher) {
+          ^(NSData* data, NSDictionary* headers,
+            web::URLFetcherBlockAdapter* fetcher) {
             CRWWebUISchemeHandler* strongSelf = weakSelf;
             if (!strongSelf ||
                 strongSelf.map->find(urlSchemeTask) == strongSelf.map->end()) {
               return;
             }
-            NSString* mimeType = @"text/html";
-            base::FilePath filePath =
-                base::FilePath(fetcher->getUrl().ExtractFileName());
-            if (filePath.Extension() == ".js") {
-              mimeType = @"text/javascript; charset=UTF-8";
-            } else if (filePath.Extension() == ".css") {
-              mimeType = @"text/css; charset=UTF-8";
-            } else if (filePath.Extension() == ".svg") {
-              mimeType = @"image/svg+xml";
+            // Content type must be set. Derive it from the file extension if it
+            // was not already provided in the headers.
+            if (!headers[@"Content-Type"]) {
+              NSMutableDictionary* mutableHeaders =
+                  [[NSMutableDictionary alloc] initWithDictionary:headers];
+
+              NSString* mimeType = @"text/html";
+              base::FilePath filePath =
+                  base::FilePath(fetcher->getUrl().ExtractFileName());
+              if (filePath.Extension() == ".js") {
+                mimeType = @"text/javascript; charset=UTF-8";
+              } else if (filePath.Extension() == ".css") {
+                mimeType = @"text/css; charset=UTF-8";
+              } else if (filePath.Extension() == ".svg") {
+                mimeType = @"image/svg+xml";
+              }
+              mutableHeaders[@"Content-Type"] = mimeType;
+              headers = mutableHeaders;
             }
+
             NSHTTPURLResponse* response =
                 [[NSHTTPURLResponse alloc] initWithURL:urlSchemeTask.request.URL
                                             statusCode:200
                                            HTTPVersion:@"HTTP/1.1"
-                                          headerFields:@{
-                                            @"Content-Type" : mimeType,
-                                            @"Access-Control-Allow-Origin" : @"*"
-                                          }];
+                                          headerFields:headers];
             [urlSchemeTask didReceiveResponse:response];
             [urlSchemeTask didReceiveData:data];
             [urlSchemeTask didFinish];
diff --git a/ios/web/webui/shared_resources_data_source_ios.mm b/ios/web/webui/shared_resources_data_source_ios.mm
index 8c8e642..c209249 100644
--- a/ios/web/webui/shared_resources_data_source_ios.mm
+++ b/ios/web/webui/shared_resources_data_source_ios.mm
@@ -12,6 +12,7 @@
 #import "ios/web/grit/ios_web_resources.h"
 #import "ios/web/grit/ios_web_resources_map.h"
 #import "ios/web/public/web_client.h"
+#import "ios/web/webui/web_ui_constants.h"
 #import "mojo/public/js/grit/mojo_bindings_resources.h"
 #import "mojo/public/js/grit/mojo_bindings_resources_map.h"
 #import "net/base/mime_util.h"
@@ -24,10 +25,6 @@
 
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/web/webui/url_fetcher_block_adapter_unittest.mm b/ios/web/webui/url_fetcher_block_adapter_unittest.mm
index d04bf02..7cc8e12 100644
--- a/ios/web/webui/url_fetcher_block_adapter_unittest.mm
+++ b/ios/web/webui/url_fetcher_block_adapter_unittest.mm
@@ -39,8 +39,11 @@
   NSData* expected_data = [NSData dataWithBytes:response.c_str()
                                          length:response.size()];
   web::URLFetcherBlockAdapterCompletion completion_handler =
-      ^(NSData* data, web::URLFetcherBlockAdapter* fetcher) {
+      ^(NSData* data, NSDictionary* headers,
+        web::URLFetcherBlockAdapter* fetcher) {
         EXPECT_NSEQ(expected_data, data);
+        EXPECT_EQ(1UL, headers.count);
+        EXPECT_NSEQ(@"text/html", headers[@"Content-type"]);
       };
 
   network::TestURLLoaderFactory test_url_loader_factory;
@@ -66,7 +69,8 @@
   NSData* expected_data = [NSData
       dataWithContentsOfFile:base::SysUTF8ToNSString(favicon_path.value())];
   web::URLFetcherBlockAdapterCompletion completion_handler =
-      ^(NSData* data, URLFetcherBlockAdapter* fetcher) {
+      ^(NSData* data, NSDictionary* headers,
+        web::URLFetcherBlockAdapter* fetcher) {
         EXPECT_NSEQ(expected_data, data);
       };
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential defense-in-depth regression: CRWWebUISchemeHandler drops security headers and sets wildcard CORS

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.

Overview: On iOS, CRWWebUISchemeHandler drops HTTP response headers generated by the WebUI backend, completely removing Content-Security-Policy and X-Frame-Options. It instead constructs a response with a hardcoded wildcard CORS header, compromising isolation between different chrome:// origins if an attacker achieves script execution within a WebUI context.

Affected files:

  • ios/web/webui/crw_web_ui_scheme_handler.mm
  • ios/web/webui/url_data_manager_ios_backend.mm

Estimated timestamp from git blame: 2021-03-25

Summary

On iOS, the CRWWebUISchemeHandler class serves chrome:// scheme requests to WKWebView. The current implementation inadvertently strips critical security headers generated by the WebUI backend (such as Content-Security-Policy and X-Frame-Options: DENY) and replaces them with a hardcoded NSHTTPURLResponse. Notably, this hardcoded response includes an overly permissive Access-Control-Allow-Origin: * header, completely breaking the Same-Origin Policy between distinct WebUI origins.

Technical Details

When a chrome:// resource is requested, CRWWebUISchemeHandler delegates the fetch to URLFetcherBlockAdapter, which uses a network::SimpleURLLoader.

The iOS WebUI backend (URLRequestChromeJob in ios/web/webui/url_data_manager_ios_backend.mm) correctly generates necessary security headers for the response.

However, two steps cause these headers to be dropped and replaced with insecure defaults:

  1. Header Dropping: In URLFetcherBlockAdapter::OnURLLoadComplete (ios/web/webui/url_fetcher_block_adapter.mm), the code extracts only the response body string and immediately destroys the underlying loader (url_loader_.reset()). The response headers (url_loader_->ResponseInfo()) are never read or passed to the completion handler.
  2. Insecure Response Construction: Inside CRWWebUISchemeHandler::webView:startURLSchemeTask: (ios/web/webui/crw_web_ui_scheme_handler.mm), the completion block manually constructs an NSHTTPURLResponse from scratch:
NSHTTPURLResponse* response =
    [[NSHTTPURLResponse alloc] initWithURL:urlSchemeTask.request.URL
                                statusCode:200
                               HTTPVersion:@"HTTP/1.1"
                              headerFields:@{
                                @"Content-Type" : mimeType,
                                @"Access-Control-Allow-Origin" : @"*"
                              }];

Impact

This is a significant defense-in-depth regression that weakens the isolation between chrome:// hosts on iOS.

Standard websites are blocked from navigating to or framing chrome:// URLs by CRWWKNavigationHandler. However, if an attacker manages to achieve script execution in any WebUI context (e.g., through a separate HTML injection vulnerability in chrome://version), they can trivially escalate their privileges:

  • Universal CORS: The Access-Control-Allow-Origin: * header allows the compromised WebUI to use fetch() to read sensitive data from any other chrome:// origin (e.g., chrome://prefs-internals).
  • Cross-Origin Framing: The lack of X-Frame-Options: DENY allows the attacker to embed any other WebUI page within an <iframe>.
  • Missing CSP: The lack of Content-Security-Policy makes the initial exploitation of any WebUI XSS trivial, as inline scripts, eval(), and outbound connections to attacker servers are not blocked.

Potential Reproduction Steps

(Note: These are suggested steps based on static analysis, as our tooling agent cannot execute code).

  1. Navigate to a WebUI page in iOS Chrome (e.g., chrome://version).
  2. Assume an attacker has achieved script execution on this page (e.g., via the console for testing purposes).
  3. Execute a fetch request to another WebUI origin: fetch('chrome://prefs-internals').then(r => r.text()).then(console.log).
  4. Observe that the request succeeds and the cross-origin data is returned due to the wildcard CORS header.
  5. Execute document.body.innerHTML='<iframe src="chrome://prefs-internals">'. Observe that the page is framed successfully due to the missing X-Frame-Options header.

Suggested Fix

  1. Modify URLFetcherBlockAdapter and its completion handler signature to capture and pass the network::mojom::URLResponseHead (or simply an NSDictionary of the headers) obtained from network::SimpleURLLoader::ResponseInfo().
  2. Update CRWWebUISchemeHandler::webView:startURLSchemeTask: to construct the NSHTTPURLResponse using the headers provided by the backend via the adapter, rather than hardcoding its own dictionary. Remove the hardcoded Access-Control-Allow-Origin: * header.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker