CVE-2026-11298
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
source_setios/web/webui/BUILD.gn |
modified | |
ifios/web/webui/crw_web_ui_scheme_handler.mm |
modified |
Files Changed
ios/chrome/browser/webui/ui_bundled/web_ui_egtest.mmios/web/webui/BUILD.gnios/web/webui/crw_web_ui_scheme_handler.mmios/web/webui/shared_resources_data_source_ios.mm
Patch
From 6b67808f449e4e6ebfd00182dcf09c7a37bb75b9 Mon Sep 17 00:00:00 2001 From: Mike Dougherty <[email protected]> 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 <[email protected]> Commit-Queue: Gauthier Ambard <[email protected]> Reviewed-by: Gauthier Ambard <[email protected]> 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 @@
Regression Test / PoC
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);
};
Original Bug Report
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.mmios/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:
- 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. - Insecure Response Construction: Inside
CRWWebUISchemeHandler::webView:startURLSchemeTask:(ios/web/webui/crw_web_ui_scheme_handler.mm), the completion block manually constructs anNSHTTPURLResponsefrom 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 usefetch()to read sensitive data from any otherchrome://origin (e.g.,chrome://prefs-internals). - Cross-Origin Framing: The lack of
X-Frame-Options: DENYallows the attacker to embed any other WebUI page within an<iframe>. - Missing CSP: The lack of
Content-Security-Policymakes 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).
- Navigate to a WebUI page in iOS Chrome (e.g.,
chrome://version). - Assume an attacker has achieved script execution on this page (e.g., via the console for testing purposes).
- Execute a fetch request to another WebUI origin:
fetch('chrome://prefs-internals').then(r => r.text()).then(console.log). - Observe that the request succeeds and the cross-origin data is returned due to the wildcard CORS header.
- Execute
document.body.innerHTML='<iframe src="chrome://prefs-internals">'. Observe that the page is framed successfully due to the missingX-Frame-Optionsheader.
Suggested Fix
- Modify
URLFetcherBlockAdapterand its completion handler signature to capture and pass thenetwork::mojom::URLResponseHead(or simply anNSDictionaryof the headers) obtained fromnetwork::SimpleURLLoader::ResponseInfo(). - Update
CRWWebUISchemeHandler::webView:startURLSchemeTask:to construct theNSHTTPURLResponseusing the headers provided by the backend via the adapter, rather than hardcoding its own dictionary. Remove the hardcodedAccess-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.