Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Navigation
DescriptionUse after free in Navigation
ComponentNavigation
Sub-componentUI>Browser>Navigation
Bug ClassUAF
Tracker553119084
Fix commit19fd007b4ff4 (chromium/src) +302/-30
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
ios/web/navigation/crw_wk_navigation_handler.mm
modified

Files Changed

  • ios/web/web_state/ui/crw_web_controller_unittest.mm
  • ios/web/BUILD.gn
  • ios/web/navigation/crw_wk_navigation_handler.mm
From 19fd007b4ff4d4674d90e0f67324f29dfdf0f36a Mon Sep 17 00:00:00 2001
From: Mike Dougherty <[email protected]>
Date: Tue, 22 Sep 2026 17:47:22 -0700
Subject: [PATCH] Prevent forged error page URL from navigating to WebUI pages

Do not trust the NSError's failing URL. It originates in the
(potentially compromised) WebContent process, which can forge
DidFailProvisionalLoadForFrame with an arbitrary failing URL (e.g.
chrome://settings). If it does not match the URL the browser actually
navigated to, rewrite the error with the navigation context URL,
mirroring the existing redirect handling in
webView:didFailProvisionalNavigation:withError:. Without this, the
forged URL is embedded into a browser-authorized error page file URL
(allowedErrorPageFileURL) and later written into the committed
NavigationItem's VirtualURL, whose app-specific scheme unlocks
renderer-initiated chrome:// WebUI navigations.

Fixed: 553119084
Change-Id: I92072efbdf554568b3e932eecadf2612aef1eb3a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8429670
Commit-Queue: Mike Dougherty <[email protected]>
Reviewed-by: Gauthier Ambard <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1703113}
---
diff --git a/ios/web/navigation/crw_wk_navigation_handler.mm b/ios/web/navigation/crw_wk_navigation_handler.mm
index 0b20019..b11408a 100644
--- a/ios/web/navigation/crw_wk_navigation_handler.mm
+++ b/ios/web/navigation/crw_wk_navigation_handler.mm
@@ -161,6 +161,44 @@
                                 failure_type);
 }
 
+// Returns a sanitized copy of `error` where `NSURLErrorFailingURLErrorKey` is
+// rewritten to match `navigation_context->GetUrl()` if they differ. Otherwise,
+// returns `error` unchanged.
+//
+// This handles cases where the failing URL in `error` diverges from the
+// navigation context URL:
+// - Redirects: When a provisional navigation fails after a redirect, WebKit
+//   reports the redirect target URL in `NSURLErrorFailingURLErrorKey`.
+//   Rewriting preserves the original requested URL for the error page and
+//   navigation item.
+// - Security: A compromised WebContent process can forge
+//   `NSURLErrorFailingURLErrorKey` with an app-specific URL to gain WebUI
+//   access via `allowedErrorPageFileURL` or manipulate navigation cancellation.
+NSError* SanitizeNavigationError(
+    NSError* error,
+    web::NavigationContextImpl* navigation_context) {
+  if (!error || !navigation_context) {
+    return error;
+  }
+
+  GURL reported_failing_url =
+      net::GURLWithNSURL(error.userInfo[NSURLErrorFailingURLErrorKey]);
+  if (reported_failing_url != navigation_context->GetUrl()) {
+    NSMutableDictionary* user_info = [error.userInfo mutableCopy];
+    NSURL* context_url = net::NSURLWithGURL(navigation_context->GetUrl());
+    if (context_url) {
+      user_info[NSURLErrorFailingURLErrorKey] = context_url;
+    } else {
+      [user_info removeObjectForKey:NSURLErrorFailingURLErrorKey];
+    }
+    return [NSError errorWithDomain:error.domain
+                               code:error.code
+                           userInfo:user_info];
+  }
+
+  return error;
+}
+
 }  // namespace
 
 @interface CRWWKNavigationHandler () <DownloadNativeTaskBridgeDelegate> {
@@ -756,9 +794,6 @@
                        withError:(NSError*)error {
   [self didReceiveWKNavigationDelegateCallback];
 
-  BOOL wasRedirected = [self.navigationStates stateForNavigation:navigation] ==
-                       web::WKNavigationState::REDIRECTED;
-
   [self.navigationStates setState:web::WKNavigationState::PROVISIONALY_FAILED
                     forNavigation:navigation];
 
@@ -771,16 +806,9 @@
 
   web::NavigationContextImpl* navigationContext =
       [self.navigationStates contextForNavigation:navigation];
-  if (wasRedirected && navigationContext) {
-    // If there was a redirect, change the URL to have the URL of the first
-    // page.
-    NSMutableDictionary* userInfo = [error.userInfo mutableCopy];
-    userInfo[NSURLErrorFailingURLErrorKey] =
-        net::NSURLWithGURL(navigationContext->GetUrl());
-    error = [NSError errorWithDomain:error.domain
-                                code:error.code
-                            userInfo:userInfo];
-  }
+  // If there was a redirect, or if a compromised WebContent process forged the
+  // failing URL, ensure the error reports the actual navigation target URL.
+  error = SanitizeNavigationError(error, navigationContext);
 
   if (@available(iOS 26, *)) {
     if ([error.domain isEqualToString:@(web::kWebKitErrorDomain)] &&
@@ -1125,6 +1153,10 @@
             withError:(NSError*)error {
   [self didReceiveWKNavigationDelegateCallback];
 
+  web::NavigationContextImpl* navigationContext =
+      [self.navigationStates contextForNavigation:navigation];
+  error = SanitizeNavigationError(error, navigationContext);
+
   // `webView:didFailNavigation:withError:` may be called after the document has
   // already loaded which should be ignored. This can happen when navigating
   // back to a page which loads from the back forward cache. See
@@ -1154,8 +1186,6 @@
       error.code == web::kWebKitErrorPlugInLoadFailed) {
     // In cases where a Plug-in handles the load, mark the navigation as
     // successful even though it is reported as a failed navigation.
-    web::NavigationContextImpl* navigationContext =
-        [self.navigationStates contextForNavigation:navigation];
     [self updateStateForNavigation:navigation
              toFinishedWithContext:navigationContext];
     return;
@@ -2020,6 +2050,21 @@
           forNavigation:(WKNavigation*)navigation
                 webView:(WKWebView*)webView
         provisionalLoad:(BOOL)provisionalLoad {
+  if (!navigation) {
+    base::RecordAction(base::UserMetricsAction("IOS.NilWKNavigationOnError"));
+    return;
+  }
+
+  web::NavigationContextImpl* navigationContext =
+      [self.navigationStates contextForNavigation:navigation];
+  if (!navigationContext) {
+    base::RecordAction(
+        base::UserMetricsAction("IOS.NilNavigationContextOnError"));
+    return;
+  }
+
+  error = SanitizeNavigationError(error, navigationContext);
+
   NSError* policyDecisionCancellationError =
       self.pendingNavigationInfo.cancellationError;
   if (!policyDecisionCancellationError && error.code == NSURLErrorCancelled) {
@@ -2037,19 +2082,6 @@
         contextError, policyDecisionCancellationError);
   }
 
-  if (!navigation) {
-    base::RecordAction(base::UserMetricsAction("IOS.NilWKNavigationOnError"));
-    return;
-  }
-
-  web::NavigationContextImpl* navigationContext =
-      [self.navigationStates contextForNavigation:navigation];
-  if (!navigationContext) {
-    base::RecordAction(
-        base::UserMetricsAction("IOS.NilNavigationContextOnError"));
-    return;
-  }
-
   web::HttpsUpgradeType failed_upgrade_type = GetFailedHttpsUpgradeType(
       error, navigationContext, policyDecisionCancellationError);
   if (failed_upgrade_type != web::HttpsUpgradeType::kNone) {
@@ -2291,9 +2323,12 @@
 - (void)handleCancelledError:(NSError*)error
                forNavigation:(WKNavigation*)navigation
              provisionalLoad:(BOOL)provisionalLoad {
+  web::NavigationContextImpl* context =
+      [self.navigationStates contextForNavigation:navigation];
+  error = SanitizeNavigationError(error, context);
+
   web::HttpsUpgradeType failed_upgrade_type = GetFailedHttpsUpgradeType(
-      error, [self.navigationStates contextForNavigation:navigation],
-      self.pendingNavigationInfo.cancellationError);
+      error, context, self.pendingNavigationInfo.cancellationError);
   if (failed_upgrade_type == web::HttpsUpgradeType::kNone &&
       ![self shouldCancelLoadForCancelledError:error
                                provisionalLoad:provisionalLoad]) {
diff --git a/ios/web/web_state/ui/crw_web_controller_unittest.mm b/ios/web/web_state/ui/crw_web_controller_unittest.mm
index 0146043..16360ff8 100644
--- a/ios/web/web_state/ui/crw_web_controller_unittest.mm
+++ b/ios/web/web_state/ui/crw_web_controller_unittest.mm
@@ -23,6 +23,7 @@
 #import "ios/web/common/uikit_ui_util.h"
 #import "ios/web/js_messaging/web_view_js_utils.h"
 #import "ios/web/navigation/block_universal_links_buildflags.h"
+#import "ios/web/navigation/crw_error_page_helper.h"
 #import "ios/web/navigation/crw_navigation_item_holder.h"
 #import "ios/web/navigation/crw_web_view_navigation_observer.h"
 #import "ios/web/navigation/crw_wk_navigation_handler.h"
@@ -151,6 +152,7 @@
 
 const char kTestURLString[] = "http://www.google.com/";
 const char kTestAppSpecificURL[] = "testwebui://test/";
+const char kTestAttackerURL[] = "https://attacker.example/";
 
 const char kTestMimeType[] = "application/vnd.test";
 
@@ -1715,6 +1717,240 @@
   }));
 }
 
+// Tests that when a provisional navigation fails with an NSError reporting a
+// forged failing URL that does not match the navigation context URL, the
+// error's failing URL is rewritten to the navigation context URL, and
+// navigation to an error page embedding the forged URL is cancelled.
+TEST_F(CRWWebControllerPolicyDeciderTest,
+       RejectNavigationToForgedErrorPageURLAfterFailingNavigation) {
+  WKNavigation* navigation =
+      static_cast<WKNavigation*>([[NSObject alloc] init]);
+  SetWebViewURL(@(kTestAttackerURL));
+  [navigation_delegate_ webView:mock_web_view_
+      didStartProvisionalNavigation:navigation];
+
+  NSURL* forged_failing_url = [NSURL URLWithString:@(kTestAppSpecificURL)];
+  NSDictionary* user_info = @{
+    NSURLErrorFailingURLErrorKey : forged_failing_url,
+  };
+  NSError* error = [NSError errorWithDomain:NSURLErrorDomain
+                                       code:NSURLErrorCannotConnectToHost
+                                   userInfo:user_info];
+
+  [navigation_delegate_ webView:mock_web_view_
+      didFailProvisionalNavigation:navigation
+                         withError:error];
+
+  NSURL* allowed_error_url =
+      [web_controller() navigationHandler].allowedErrorPageFileURL;
+  ASSERT_TRUE(allowed_error_url);
+
+  // The allowed error page file URL must embed the legitimate navigation
+  // context URL, not the forged failing URL.
+  GURL allowed_failing_url = [CRWErrorPageHelper
+      failedNavigationURLFromErrorPageFileURL:net::GURLWithNSURL(
+                                                  allowed_error_url)];
+  EXPECT_EQ(GURL(kTestAttackerURL), allowed_failing_url);
+  EXPECT_NE(GURL(kTestAppSpecificURL), allowed_failing_url);
+
+  // A navigation to the forged error page URL embedding the app-specific URL
+  // must be cancelled because the handler did not authorize it.
+  NSString* path =
+      [base::apple::FrameworkBundle() pathForResource:@"error_page_loaded"
+                                               ofType:@"html"];
+  ASSERT_TRUE(path);
+  NSURL* forged_error_page_url =
+      [NSURL URLWithString:[NSString stringWithFormat:@"file://%@?url=%@", path,
+                                                      @(kTestAppSpecificURL)]];
+  NSMutableURLRequest* forged_request =
+      [NSMutableURLRequest requestWithURL:forged_error_page_url];
+  forged_request.mainDocumentURL = forged_error_page_url;
+
+  FakeWKFrameInfo* fake_frame = [[FakeWKFrameInfo alloc] init];
+  fake_frame.mainFrame = YES;
+
+  CRWFakeWKNavigationAction* action = [[CRWFakeWKNavigationAction alloc] init];
+  action.request = forged_request;
+  action.targetFrame = (WKFrameInfo*)fake_frame;
+
+  __block bool callback_called = false;
+  [navigation_delegate_ webView:mock_web_view_
+      decidePolicyForNavigationAction:action
+                          preferences:[[WKWebpagePreferences alloc] init]
+                      decisionHandler:^(WKNavigationActionPolicy policy,
+                                        WKWebpagePreferences* ignored) {
+                        EXPECT_EQ(policy, WKNavigationActionPolicyCancel);
+                        callback_called = true;
+                      }];
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForPageLoadTimeout, ^{
+    return callback_called;
+  }));
+}
+
+// Tests that when a provisional navigation fails with a forged failing URL,
+// navigation to the legitimate error page with the rewritten failing URL is
+// allowed and clears `allowedErrorPageFileURL`.
+TEST_F(CRWWebControllerPolicyDeciderTest,
+       AllowNavigationToRewrittenErrorPageURLAfterFailingNavigation) {
+  WKNavigation* navigation =
+      static_cast<WKNavigation*>([[NSObject alloc] init]);
+  SetWebViewURL(@(kTestAttackerURL));
+  [navigation_delegate_ webView:mock_web_view_
+      didStartProvisionalNavigation:navigation];
+
+  NSURL* forged_failing_url = [NSURL URLWithString:@(kTestAppSpecificURL)];
+  NSDictionary* user_info = @{
+    NSURLErrorFailingURLErrorKey : forged_failing_url,
+  };
+  NSError* error = [NSError errorWithDomain:NSURLErrorDomain
+                                       code:NSURLErrorCannotConnectToHost
+                                   userInfo:user_info];
+
+  [navigation_delegate_ webView:mock_web_view_
+      didFailProvisionalNavigation:navigation
+                         withError:error];
+
+  NSURL* allowed_error_url =
+      [web_controller() navigationHandler].allowedErrorPageFileURL;
+  ASSERT_TRUE(allowed_error_url);
+
+  // The legitimate error page load with the rewritten context URL is allowed
+  // and clears `allowedErrorPageFileURL`.
+  NSMutableURLRequest* allowed_request =
+      [NSMutableURLRequest requestWithURL:allowed_error_url];
+  allowed_request.mainDocumentURL = allowed_error_url;
+
+  FakeWKFrameInfo* fake_frame = [[FakeWKFrameInfo alloc] init];
+  fake_frame.mainFrame = YES;
+
+  CRWFakeWKNavigationAction* action = [[CRWFakeWKNavigationAction alloc] init];
+  action.request = allowed_request;
+  action.targetFrame = (WKFrameInfo*)fake_frame;
+
+  __block bool callback_called = false;
+  [navigation_delegate_ webView:mock_web_view_
+      decidePolicyForNavigationAction:action
+                          preferences:[[WKWebpagePreferences alloc] init]
+                      decisionHandler:^(WKNavigationActionPolicy policy,
+                                        WKWebpagePreferences* ignored) {
+                        EXPECT_EQ(policy, WKNavigationActionPolicyAllow);
+                        callback_called = true;
+                      }];
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForPageLoadTimeout, ^{
+    return callback_called;
+  }));
+
+  EXPECT_FALSE([web_controller() navigationHandler].allowedErrorPageFileURL);
+}
+
+// Tests that when a provisional navigation fails with an NSError reporting the
+// actual URL of the navigation context, the failing URL is preserved.
+TEST_F(CRWWebControllerPolicyDeciderTest,
+       MatchingFailingURLInErrorPreservedInAllowedErrorPage) {
+  WKNavigation* navigation =
+      static_cast<WKNavigation*>([[NSObject alloc] init]);
+  SetWebViewURL(@(kTestAttackerURL));
+  [navigation_delegate_ webView:mock_web_view_
+      didStartProvisionalNavigation:navigation];
+
+  NSURL* legitimate_failing_url = [NSURL URLWithString:@(kTestAttackerURL)];
+  NSDictionary* user_info = @{
+    NSURLErrorFailingURLErrorKey : legitimate_failing_url,
+  };
+  NSError* error = [NSError errorWithDomain:NSURLErrorDomain
+                                       code:NSURLErrorCannotConnectToHost
+                                   userInfo:user_info];
+
+  [navigation_delegate_ webView:mock_web_view_
+      didFailProvisionalNavigation:navigation
+                         withError:error];
+
+  NSURL* allowed_error_url =
+      [web_controller() navigationHandler].allowedErrorPageFileURL;
+  ASSERT_TRUE(allowed_error_url);
+
+  GURL allowed_failing_url = [CRWErrorPageHelper
+      failedNavigationURLFromErrorPageFileURL:net::GURLWithNSURL(
+                                                  allowed_error_url)];
+  EXPECT_EQ(GURL(kTestAttackerURL), allowed_failing_url);
+}
+
+// Tests that when a provisional navigation fails with NSURLErrorCancelled and a
+// forged app-specific failing URL, the error is sanitized so the load is
+// correctly cancelled instead of ignored.
+TEST_F(CRWWebControllerPolicyDeciderTest,
+       CancelledErrorWithForgedAppSpecificURLStillCancelsNavigation) {
+  WKNavigation* navigation =
+      static_cast<WKNavigation*>([[NSObject alloc] init]);
+  SetWebViewURL(@(kTestAttackerURL));
+  [navigation_delegate_ webView:mock_web_view_
+      didStartProvisionalNavigation:navigation];
+
+  ASSERT_TRUE([[web_controller() navigationHandler].navigationStates
+      contextForNavigation:navigation]);
+
+  NSURL* forged_failing_url = [NSURL URLWithString:@(kTestAppSpecificURL)];
+  NSDictionary* user_info = @{
+    NSURLErrorFailingURLErrorKey : forged_failing_url,
+  };
+  NSError* error = [NSError errorWithDomain:NSURLErrorDomain
+                                       code:NSURLErrorCancelled
+                                   userInfo:user_info];
+
+  [navigation_delegate_ webView:mock_web_view_
+      didFailProvisionalNavigation:navigation
+                         withError:error];
+
+  // The navigation should be cancelled and removed from active navigations.
+  EXPECT_FALSE([[web_controller() navigationHandler].navigationStates
+      contextForNavigation:navigation]);
+}
+
+// Tests that when a provisional navigation is redirected and fails with an
+// NSError containing the pre-redirect URL in NSURLErrorFailingURLErrorKey,
+// the failing URL is rewritten to the navigation context's redirected URL in
+// the allowed error page.
+TEST_F(CRWWebControllerPolicyDeciderTest,
+       RedirectFailingURLRewrittenToNavigationContextURL) {
+  WKNavigation* navigation =
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ios/web/web_state/ui/crw_web_controller_unittest.mm b/ios/web/web_state/ui/crw_web_controller_unittest.mm
index 0146043..16360ff8 100644
--- a/ios/web/web_state/ui/crw_web_controller_unittest.mm
+++ b/ios/web/web_state/ui/crw_web_controller_unittest.mm
@@ -23,6 +23,7 @@
 #import "ios/web/common/uikit_ui_util.h"
 #import "ios/web/js_messaging/web_view_js_utils.h"
 #import "ios/web/navigation/block_universal_links_buildflags.h"
+#import "ios/web/navigation/crw_error_page_helper.h"
 #import "ios/web/navigation/crw_navigation_item_holder.h"
 #import "ios/web/navigation/crw_web_view_navigation_observer.h"
 #import "ios/web/navigation/crw_wk_navigation_handler.h"
@@ -151,6 +152,7 @@
 
 const char kTestURLString[] = "http://www.google.com/";
 const char kTestAppSpecificURL[] = "testwebui://test/";
+const char kTestAttackerURL[] = "https://attacker.example/";
 
 const char kTestMimeType[] = "application/vnd.test";
 
@@ -1715,6 +1717,240 @@
   }));
 }
 
+// Tests that when a provisional navigation fails with an NSError reporting a
+// forged failing URL that does not match the navigation context URL, the
+// error's failing URL is rewritten to the navigation context URL, and
+// navigation to an error page embedding the forged URL is cancelled.
+TEST_F(CRWWebControllerPolicyDeciderTest,
+       RejectNavigationToForgedErrorPageURLAfterFailingNavigation) {
+  WKNavigation* navigation =
+      static_cast<WKNavigation*>([[NSObject alloc] init]);
+  SetWebViewURL(@(kTestAttackerURL));
+  [navigation_delegate_ webView:mock_web_view_
+      didStartProvisionalNavigation:navigation];
+
+  NSURL* forged_failing_url = [NSURL URLWithString:@(kTestAppSpecificURL)];
+  NSDictionary* user_info = @{
+    NSURLErrorFailingURLErrorKey : forged_failing_url,
+  };
+  NSError* error = [NSError errorWithDomain:NSURLErrorDomain
+                                       code:NSURLErrorCannotConnectToHost
+                                   userInfo:user_info];
+
+  [navigation_delegate_ webView:mock_web_view_
+      didFailProvisionalNavigation:navigation
+                         withError:error];
+
+  NSURL* allowed_error_url =
+      [web_controller() navigationHandler].allowedErrorPageFileURL;
+  ASSERT_TRUE(allowed_error_url);
+
+  // The allowed error page file URL must embed the legitimate navigation
+  // context URL, not the forged failing URL.
+  GURL allowed_failing_url = [CRWErrorPageHelper
+      failedNavigationURLFromErrorPageFileURL:net::GURLWithNSURL(
+                                                  allowed_error_url)];
+  EXPECT_EQ(GURL(kTestAttackerURL), allowed_failing_url);
+  EXPECT_NE(GURL(kTestAppSpecificURL), allowed_failing_url);
+
+  // A navigation to the forged error page URL embedding the app-specific URL
+  // must be cancelled because the handler did not authorize it.
+  NSString* path =
+      [base::apple::FrameworkBundle() pathForResource:@"error_page_loaded"
+                                               ofType:@"html"];
+  ASSERT_TRUE(path);
+  NSURL* forged_error_page_url =
+      [NSURL URLWithString:[NSString stringWithFormat:@"file://%@?url=%@", path,
+                                                      @(kTestAppSpecificURL)]];
+  NSMutableURLRequest* forged_request =
+      [NSMutableURLRequest requestWithURL:forged_error_page_url];
+  forged_request.mainDocumentURL = forged_error_page_url;
+
+  FakeWKFrameInfo* fake_frame = [[FakeWKFrameInfo alloc] init];
+  fake_frame.mainFrame = YES;
+
+  CRWFakeWKNavigationAction* action = [[CRWFakeWKNavigationAction alloc] init];
+  action.request = forged_request;
+  action.targetFrame = (WKFrameInfo*)fake_frame;
+
+  __block bool callback_called = false;
+  [navigation_delegate_ webView:mock_web_view_
+      decidePolicyForNavigationAction:action
+                          preferences:[[WKWebpagePreferences alloc] init]
+                      decisionHandler:^(WKNavigationActionPolicy policy,
+                                        WKWebpagePreferences* ignored) {
+                        EXPECT_EQ(policy, WKNavigationActionPolicyCancel);
+                        callback_called = true;
+                      }];
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForPageLoadTimeout, ^{
+    return callback_called;
+  }));
+}
+
+// Tests that when a provisional navigation fails with a forged failing URL,
+// navigation to the legitimate error page with the rewritten failing URL is
+// allowed and clears `allowedErrorPageFileURL`.
+TEST_F(CRWWebControllerPolicyDeciderTest,
+       AllowNavigationToRewrittenErrorPageURLAfterFailingNavigation) {
+  WKNavigation* navigation =
+      static_cast<WKNavigation*>([[NSObject alloc] init]);
+  SetWebViewURL(@(kTestAttackerURL));
+  [navigation_delegate_ webView:mock_web_view_
+      didStartProvisionalNavigation:navigation];
+
+  NSURL* forged_failing_url = [NSURL URLWithString:@(kTestAppSpecificURL)];
+  NSDictionary* user_info = @{
+    NSURLErrorFailingURLErrorKey : forged_failing_url,
+  };
+  NSError* error = [NSError errorWithDomain:NSURLErrorDomain
+                                       code:NSURLErrorCannotConnectToHost
+                                   userInfo:user_info];
+
+  [navigation_delegate_ webView:mock_web_view_
+      didFailProvisionalNavigation:navigation
+                         withError:error];
+
+  NSURL* allowed_error_url =
+      [web_controller() navigationHandler].allowedErrorPageFileURL;
+  ASSERT_TRUE(allowed_error_url);
+
+  // The legitimate error page load with the rewritten context URL is allowed
+  // and clears `allowedErrorPageFileURL`.
+  NSMutableURLRequest* allowed_request =
+      [NSMutableURLRequest requestWithURL:allowed_error_url];
+  allowed_request.mainDocumentURL = allowed_error_url;
+
+  FakeWKFrameInfo* fake_frame = [[FakeWKFrameInfo alloc] init];
+  fake_frame.mainFrame = YES;
+
+  CRWFakeWKNavigationAction* action = [[CRWFakeWKNavigationAction alloc] init];
+  action.request = allowed_request;
+  action.targetFrame = (WKFrameInfo*)fake_frame;
+
+  __block bool callback_called = false;
+  [navigation_delegate_ webView:mock_web_view_
+      decidePolicyForNavigationAction:action
+                          preferences:[[WKWebpagePreferences alloc] init]
+                      decisionHandler:^(WKNavigationActionPolicy policy,
+                                        WKWebpagePreferences* ignored) {
+                        EXPECT_EQ(policy, WKNavigationActionPolicyAllow);
+                        callback_called = true;
+                      }];
+  EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForPageLoadTimeout, ^{
+    return callback_called;
+  }));
+
+  EXPECT_FALSE([web_controller() navigationHandler].allowedErrorPageFileURL);
+}
+
+// Tests that when a provisional navigation fails with an NSError reporting the
+// actual URL of the navigation context, the failing URL is preserved.
+TEST_F(CRWWebControllerPolicyDeciderTest,
+       MatchingFailingURLInErrorPreservedInAllowedErrorPage) {
+  WKNavigation* navigation =
+      static_cast<WKNavigation*>([[NSObject alloc] init]);
+  SetWebViewURL(@(kTestAttackerURL));
+  [navigation_delegate_ webView:mock_web_view_
+      didStartProvisionalNavigation:navigation];
+
+  NSURL* legitimate_failing_url = [NSURL URLWithString:@(kTestAttackerURL)];
+  NSDictionary* user_info = @{
+    NSURLErrorFailingURLErrorKey : legitimate_failing_url,
+  };
+  NSError* error = [NSError errorWithDomain:NSURLErrorDomain
+                                       code:NSURLErrorCannotConnectToHost
+                                   userInfo:user_info];
+
+  [navigation_delegate_ webView:mock_web_view_
+      didFailProvisionalNavigation:navigation
+                         withError:error];
+
+  NSURL* allowed_error_url =
+      [web_controller() navigationHandler].allowedErrorPageFileURL;
+  ASSERT_TRUE(allowed_error_url);
+
+  GURL allowed_failing_url = [CRWErrorPageHelper
+      failedNavigationURLFromErrorPageFileURL:net::GURLWithNSURL(
+                                                  allowed_error_url)];
+  EXPECT_EQ(GURL(kTestAttackerURL), allowed_failing_url);
+}
+
+// Tests that when a provisional navigation fails with NSURLErrorCancelled and a
+// forged app-specific failing URL, the error is sanitized so the load is
+// correctly cancelled instead of ignored.
+TEST_F(CRWWebControllerPolicyDeciderTest,
+       CancelledErrorWithForgedAppSpecificURLStillCancelsNavigation) {
+  WKNavigation* navigation =
+      static_cast<WKNavigation*>([[NSObject alloc] init]);
+  SetWebViewURL(@(kTestAttackerURL));
+  [navigation_delegate_ webView:mock_web_view_
+      didStartProvisionalNavigation:navigation];
+
+  ASSERT_TRUE([[web_controller() navigationHandler].navigationStates
+      contextForNavigation:navigation]);
+
+  NSURL* forged_failing_url = [NSURL URLWithString:@(kTestAppSpecificURL)];
+  NSDictionary* user_info = @{
+    NSURLErrorFailingURLErrorKey : forged_failing_url,
+  };
+  NSError* error = [NSError errorWithDomain:NSURLErrorDomain
+                                       code:NSURLErrorCancelled
+                                   userInfo:user_info];
+
+  [navigation_delegate_ webView:mock_web_view_
+      didFailProvisionalNavigation:navigation
+                         withError:error];
+
+  // The navigation should be cancelled and removed from active navigations.
+  EXPECT_FALSE([[web_controller() navigationHandler].navigationStates
+      contextForNavigation:navigation]);
+}
+
+// Tests that when a provisional navigation is redirected and fails with an
+// NSError containing the pre-redirect URL in NSURLErrorFailingURLErrorKey,
+// the failing URL is rewritten to the navigation context's redirected URL in
+// the allowed error page.
+TEST_F(CRWWebControllerPolicyDeciderTest,
+       RedirectFailingURLRewrittenToNavigationContextURL) {
+  WKNavigation* navigation =
+      static_cast<WKNavigation*>([[NSObject alloc] init]);
+  const char kInitialURL[] = "https://initial.example/";
+  const char kRedirectURL[] = "https://redirect.example/";
+
+  SetWebViewURL(@(kInitialURL));
+  [navigation_delegate_ webView:mock_web_view_
+      didStartProvisionalNavigation:navigation];
+
+  // Simulate server redirect to kRedirectURL.
+  SetWebViewURL(@(kRedirectURL));
+  [navigation_delegate_ webView:mock_web_view_
+      didReceiveServerRedirectForProvisionalNavigation:navigation];
+
+  // The error still reports the pre-redirect URL.
+  NSURL* initial_failing_url = [NSURL URLWithString:@(kInitialURL)];
+  NSDictionary* user_info = @{
+    NSURLErrorFailingURLErrorKey : initial_failing_url,
+  };
+  NSError* error = [NSError errorWithDomain:NSURLErrorDomain
+                                       code:NSURLErrorCannotConnectToHost
+                                   userInfo:user_info];
+
+  [navigation_delegate_ webView:mock_web_view_
+      didFailProvisionalNavigation:navigation
+                         withError:error];
+
+  NSURL* allowed_error_url =
+      [web_controller() navigationHandler].allowedErrorPageFileURL;
+  ASSERT_TRUE(allowed_error_url);
+
+  // The allowed error page must embed the redirected navigation context URL,
+  // not the pre-redirect URL from the NSError.
+  GURL allowed_failing_url = [CRWErrorPageHelper
+      failedNavigationURLFromErrorPageFileURL:net::GURLWithNSURL(
+                                                  allowed_error_url)];
+  EXPECT_EQ(GURL(kRedirectURL), allowed_failing_url);
+}
+
 // Test fixture for window.open tests.
 class WindowOpenByDomTest : public WebTestWithWebController {
  protected:
Loading diff…

Original Bug Report

reported by [email protected]

WebUI privilege escalation via forged failingURL in error navigation on iOS

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: In Chrome for iOS, provisional navigation error handling trusts the unvalidated NSURLErrorFailingURLErrorKey from the NSError delivered by WKWebView. A compromised WebContent renderer process can supply an app-specific URL such as chrome://settings during navigation failure, leading to a poisoned error-page file authorization and committed VirtualURL. This allows the renderer to bypass the shouldAllowAppSpecificURLNavigationAction navigation gate, navigate to privileged chrome:// WebUI pages, and invoke browser-process message handlers.

Affected files:

  • ios/web/navigation/crw_wk_navigation_handler.mm
  • ios/web/navigation/crw_error_page_helper.mm

Estimated timestamp from git blame: 2020-01-20

Description

In Chrome for iOS, navigation failure handling derives the failed URL from the NSError’s NSURLErrorFailingURLErrorKey user info entry without validating it against the original request or pending navigation context URL in non-redirect cases (ios/web/navigation/crw_wk_navigation_handler.mm:714-786).

When a navigation failure occurs, displayErrorPageWithError: (crw_wk_navigation_handler.mm:2145-2184) uses CRWErrorPageHelper to construct an error-page file URL (crw_error_page_helper.mm:74-89) containing the attacker-supplied failing URL. The browser process sets self.allowedErrorPageFileURL to this file URL (line 2173) and loads it into WKWebView. The navigation policy check in decidePolicyForNavigationAction: (crw_wk_navigation_handler.mm:351-361) verifies requestURL == net::GURLWithNSURL(allowedErrorPageFileURL), which succeeds by construction.

When this error navigation finishes, loadErrorPageForNavigationItem: (crw_wk_navigation_handler.mm:2286-2291) executes:

GURL failingURL = net::GURLWithNSURL(error.userInfo[NSURLErrorFailingURLErrorKey]);
GURL itemURL = item->GetURL();
if (itemURL != failingURL) {
  item->SetVirtualURL(failingURL);
}

This mutates the committed NavigationItem’s VirtualURL to the forged chrome:// URL.

Subsequently, if the renderer navigates to an app-specific WebUI page (e.g. chrome://flags), shouldAllowAppSpecificURLNavigationAction: (crw_wk_navigation_handler.mm:1472-1479) checks:

web::NavigationItem* lastItem = self.webStateImpl->GetNavigationManager()->GetLastCommittedItem();
if (lastItem && (web::GetWebClient()->IsAppSpecificURL(lastItem->GetVirtualURL()))) {
  return YES;
}

Because IsAppSpecificURL only checks url.SchemeIs(kChromeUIScheme) (ios/chrome/browser/web/model/chrome_web_client.mm:337-339) without host verification, the navigation is permitted. This triggers createWebUIForURL: (crw_wk_navigation_handler.mm:439), initializing web_ui_ (ios/web/web_state/web_state_impl_realized_web_state.mm:404-413), loading WebUI resources via CRWWebUISchemeHandler, and exposing browser-process chrome.send message handlers via WebUIMessagingJavaScriptFeature (ios/web/webui/web_ui_messaging_java_script_feature.mm:38-82).

Potential Reproduction Steps

Note: These are potential steps based on static code analysis; dynamic execution was not performed by automated tooling.

  1. From a compromised WebContent process in a Chrome for iOS tab, initiate a benign main-frame navigation (e.g. window.location = 'https://benign.example/slow').
  2. Before the navigation commits, send an IPC message (DidFailProvisionalLoadForFrame) from the WebContent process containing a WebCore::ResourceError with domain NSURLErrorDomain, code -1001 (NSURLErrorTimedOut), and failingURL chrome://settings.
  3. The browser process receives webView:didFailProvisionalNavigation:withError: and generates an error page file load for file://<bundle>/error_page_loaded.html?url=chrome://settings, authorizing it via self.allowedErrorPageFileURL.
  4. When the error navigation finishes, loadErrorPageForNavigationItem: sets the committed item’s VirtualURL to chrome://settings.
  5. The renderer initiates a navigation to chrome://flags. The shouldAllowAppSpecificURLNavigationAction gate returns YES due to the committed item’s app-specific VirtualURL.
  6. The browser creates the FlagsUI controller and CRWWebUISchemeHandler serves the page. The renderer sends WebUIMessage messages to invoke privileged browser-process WebUI handlers.

Suggested Fix

  1. In webView:didFailProvisionalNavigation:withError:, validate error.userInfo[NSURLErrorFailingURLErrorKey] against navigationContext->GetUrl() and overwrite it with the expected context URL (similar to the redirect handling at lines 734-743).
  2. Disallow building error pages or mutating VirtualURL to app-specific schemes unless the failed navigation was explicitly browser-initiated and authorized.
  3. In shouldAllowAppSpecificURLNavigationAction:, enforce host matching rather than permitting any chrome:// destination based purely on scheme equality.

Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c


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.

View on issue tracker