CVE-2026-106361
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifios/chrome/browser/ssl/model/ios_ssl_blocking_page.mm |
modified | |
ifios/chrome/browser/ssl/model/ios_ssl_error_handler.mm |
modified | |
BindOnceios/chrome/browser/ssl/model/ios_ssl_error_handler_unittest.mm |
modified | |
TEST_Fios/chrome/browser/ssl/model/ios_ssl_error_handler_unittest.mm |
modified |
Files Changed
ios/chrome/browser/ssl/model/ios_ssl_error_handler_unittest.mmios/chrome/browser/ssl/model/ios_ssl_blocking_page.mmios/chrome/browser/ssl/model/ios_ssl_error_handler.hios/chrome/browser/ssl/model/ios_ssl_error_handler.mm
Patch
From 0429cf97ce26e023e568744c183952ba913d7100 Mon Sep 17 00:00:00 2001
From: Federica Germinario <fedegermi@google.com>
Date: Fri, 04 Sep 2026 02:11:02 -0700
Subject: [PATCH] [ios] Enforce strict certificate errors for known HSTS hosts
On iOS, WKWebView delegates TLS trust evaluation to Apple's
SecTrustEvaluateWithError API, which categorizes untrusted or
self-signed certificates as recoverable errors
(kSecTrustResultRecoverableTrustFailure) without consulting Chromium's
HSTS preload list or dynamic TransportSecurityState. Consequently,
IOSSSLErrorHandler previously received `overridable = true` and
presented a soft-override SSL interstitial ("Proceed (unsafe)") even
for known HSTS hosts such as accounts.google.com.
This CL updates IOSSSLErrorHandler::StartHandlingError() to query
TransportSecurityState::ShouldSSLErrorsBeFatal() on the network task
runner before presenting the SSL interstitial. If the host requires
strict certificate error enforcement:
- `overridable` is forced to false (`STRICT_ENFORCEMENT`).
- The SSL interstitial hides the "Proceed (unsafe)" link and displays
the strict HSTS error message.
- Captive portal detection continues to run beforehand so users behind
Wi-Fi captive portals are still routed to the captive portal login
page.
Bug: 513781133
Change-Id: I12e2a63a3c3476e86131906da568d7ece998a4ac
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8304554
Reviewed-by: Carlos IL <carlosil@chromium.org>
Commit-Queue: Federica Germinario <fedegermi@google.com>
Cr-Commit-Position: refs/heads/main@{#1692289}
---
diff --git a/ios/chrome/browser/ssl/model/ios_ssl_blocking_page.mm b/ios/chrome/browser/ssl/model/ios_ssl_blocking_page.mm
index e54691e..9d8826e 100644
--- a/ios/chrome/browser/ssl/model/ios_ssl_blocking_page.mm
+++ b/ios/chrome/browser/ssl/model/ios_ssl_blocking_page.mm
@@ -91,13 +91,16 @@
void IOSSSLBlockingPage::HandleCommand(
security_interstitials::SecurityInterstitialCommand command) {
- // If a proceed command is received, allowlist the certificate and reload
- // the page to re-initiate the original navigation.
+ // If a proceed command is received for an overridable error, allowlist the
+ // certificate and reload the page to re-initiate the original navigation.
if (command == security_interstitials::CMD_PROCEED) {
- web_state_->GetSessionCertificatePolicyCache()->RegisterAllowedCertificate(
- ssl_info_.cert, request_url().GetHost(), ssl_info_.cert_status);
- web_state_->GetNavigationManager()->Reload(web::ReloadType::NORMAL,
- /*check_for_repost=*/true);
+ if (overridable_) {
+ web_state_->GetSessionCertificatePolicyCache()
+ ->RegisterAllowedCertificate(ssl_info_.cert, request_url().GetHost(),
+ ssl_info_.cert_status);
+ web_state_->GetNavigationManager()->Reload(web::ReloadType::NORMAL,
+ /*check_for_repost=*/true);
+ }
return;
}
diff --git a/ios/chrome/browser/ssl/model/ios_ssl_error_handler.h b/ios/chrome/browser/ssl/model/ios_ssl_error_handler.h
index d4df0ff..0c88d53 100644
--- a/ios/chrome/browser/ssl/model/ios_ssl_error_handler.h
+++ b/ios/chrome/browser/ssl/model/ios_ssl_error_handler.h
@@ -70,9 +70,13 @@
int64_t navigation_id,
base::OnceCallback<void(NSString*)> blocking_page_callback);
- // Begins captive portal detection to determine which interstitial should be
- // displayed.
+ // Begins handling the error by looking up whether `request_url_` requires
+ // strict certificate-error enforcement, then starting captive portal
+ // detection to determine which interstitial should be displayed.
void StartHandlingError();
+ // Stores the result of the strict-enforcement lookup and begins captive
+ // portal detection.
+ void StartCaptivePortalDetection(bool should_ssl_errors_be_fatal);
// Presents the appropriate interstitial based on the `results` of the captive
// portal detection.
void HandleCaptivePortalDetectionResult(
@@ -102,6 +106,9 @@
// Whether or not the user can ignore this error in order to continue loading
// `request_url_`.
const bool overridable_ = false;
+ // Whether the host's transport security state requires strict
+ // certificate-error enforcement.
+ bool should_ssl_errors_be_fatal_ = false;
// The id of the navigation.
const int64_t navigation_id_ = 0;
// The callback to run for showing a committed interstitial.
diff --git a/ios/chrome/browser/ssl/model/ios_ssl_error_handler.mm b/ios/chrome/browser/ssl/model/ios_ssl_error_handler.mm
index 794d0de..92a3dc5 100644
--- a/ios/chrome/browser/ssl/model/ios_ssl_error_handler.mm
+++ b/ios/chrome/browser/ssl/model/ios_ssl_error_handler.mm
@@ -10,6 +10,7 @@
#import "base/memory/ptr_util.h"
#import "base/metrics/histogram_macros.h"
#import "base/strings/sys_string_conversions.h"
+#import "base/task/single_thread_task_runner.h"
#import "components/application_locale_storage/application_locale_storage.h"
#import "components/captive_portal/core/captive_portal_detector.h"
#import "components/security_interstitials/core/metrics_helper.h"
@@ -22,9 +23,13 @@
#import "ios/chrome/browser/ssl/model/ios_ssl_blocking_page.h"
#import "ios/components/security_interstitials/ios_blocking_page_metrics_helper.h"
#import "ios/components/security_interstitials/ios_blocking_page_tab_helper.h"
+#import "ios/web/public/browser_state.h"
#import "ios/web/public/web_state.h"
+#import "net/http/transport_security_state.h"
#import "net/ssl/ssl_info.h"
#import "net/traffic_annotation/network_traffic_annotation.h"
+#import "net/url_request/url_request_context.h"
+#import "net/url_request/url_request_context_getter.h"
using captive_portal::CaptivePortalDetector;
using security_interstitials::IOSBlockingPageTabHelper;
@@ -46,6 +51,18 @@
return std::make_unique<security_interstitials::IOSBlockingPageMetricsHelper>(
web_state, request_url, reporting_info);
}
+
+// Runs on the network task runner. Returns whether `host` has transport
+// security state that requires certificate errors to be fatal.
+bool ShouldSSLErrorsBeFatalOnNetworkThread(
+ scoped_refptr<net::URLRequestContextGetter> context_getter,
+ const std::string& host) {
+ net::URLRequestContext* context = context_getter->GetURLRequestContext();
+ if (!context || !context->transport_security_state()) {
+ return false;
+ }
+ return context->transport_security_state()->ShouldSSLErrorsBeFatal(host);
+}
} // namespace
// static
@@ -98,6 +115,20 @@
return;
}
+ scoped_refptr<net::URLRequestContextGetter> context_getter =
+ web_state_->GetBrowserState()->GetRequestContext();
+ context_getter->GetNetworkTaskRunner()->PostTaskAndReplyWithResult(
+ FROM_HERE,
+ base::BindOnce(&ShouldSSLErrorsBeFatalOnNetworkThread, context_getter,
+ std::string(request_url_.host())),
+ base::BindOnce(&IOSSSLErrorHandler::StartCaptivePortalDetection,
+ weak_factory_.GetWeakPtr()));
+}
+
+void IOSSSLErrorHandler::StartCaptivePortalDetection(
+ bool should_ssl_errors_be_fatal) {
+ should_ssl_errors_be_fatal_ = should_ssl_errors_be_fatal;
+
if (captive_portal_detector_) {
timer_.Stop();
captive_portal_detector_->Cancel();
@@ -145,8 +176,9 @@
captive_portal_detector_.reset();
}
+ const bool overridable = overridable_ && !should_ssl_errors_be_fatal_;
int options_mask =
- overridable_
+ overridable
? security_interstitials::SSLErrorOptionsMask::SOFT_OVERRIDE_ENABLED
: security_interstitials::SSLErrorOptionsMask::STRICT_ENFORCEMENT;
auto page = std::make_unique<IOSSSLBlockingPage>(
@@ -154,7 +186,7 @@
base::Time::NowFromSystemTime(),
std::make_unique<security_interstitials::IOSBlockingPageControllerClient>(
web_state_,
- CreateMetricsHelper(web_state_, request_url_, overridable_),
+ CreateMetricsHelper(web_state_, request_url_, overridable),
GetApplicationContext()->GetApplicationLocaleStorage()->Get()));
std::string error_html = page->GetHtmlContents();
IOSBlockingPageTabHelper::FromWebState(web_state_)
diff --git a/ios/chrome/browser/ssl/model/ios_ssl_error_handler_unittest.mm b/ios/chrome/browser/ssl/model/ios_ssl_error_handler_unittest.mm
index 1172b816..1aced455 100644
--- a/ios/chrome/browser/ssl/model/ios_ssl_error_handler_unittest.mm
+++ b/ios/chrome/browser/ssl/model/ios_ssl_error_handler_unittest.mm
@@ -7,6 +7,7 @@
#import "base/functional/bind.h"
#import "base/run_loop.h"
#import "base/test/ios/wait_util.h"
+#import "base/test/test_future.h"
#import "components/captive_portal/core/captive_portal_detector.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
#import "ios/chrome/browser/ssl/model/captive_portal_tab_helper.h"
@@ -16,9 +17,12 @@
#import "ios/web/public/test/web_task_environment.h"
#import "ios/web/public/web_state.h"
#import "net/http/http_status_code.h"
+#import "net/http/transport_security_state.h"
#import "net/ssl/ssl_info.h"
#import "net/test/cert_test_util.h"
#import "net/test/test_data_directory.h"
+#import "net/url_request/url_request_context.h"
+#import "net/url_request/url_request_context_getter.h"
#import "services/network/public/cpp/weak_wrapper_shared_url_loader_factory.h"
#import "services/network/test/test_url_loader_factory.h"
#import "testing/platform_test.h"
@@ -124,20 +128,55 @@
net::SSLInfo ssl_info;
ssl_info.cert = cert();
GURL url(kTestHostName);
- __block bool blocking_page_callback_called = false;
- base::OnceCallback<void(bool)> null_callback;
- base::OnceCallback<void(NSString*)> blocking_page_callback =
- base::BindOnce(^(NSString* blocking_page_html) {
- EXPECT_NE(blocking_page_html, nil);
- blocking_page_callback_called = true;
- });
- IOSSSLErrorHandler::HandleSSLError(
- web_state(), net::ERR_CERT_AUTHORITY_INVALID, ssl_info, url, true, 0,
- std::move(blocking_page_callback));
+ base::test::TestFuture<NSString*> test_future;
+ IOSSSLErrorHandler::HandleSSLError(web_state(),
+ net::ERR_CERT_AUTHORITY_INVALID, ssl_info,
+ url, true, 0, test_future.GetCallback());
- EXPECT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
- base::test::ios::kWaitForActionTimeout, ^bool() {
- base::RunLoop().RunUntilIdle();
- return blocking_page_callback_called;
- }));
+ NSString* blocking_page_html = test_future.Get();
+ EXPECT_NE(blocking_page_html, nil);
+}
+
+// Tests that an otherwise-overridable certificate error is presented as
+// non-overridable when the request host has known transport security state
+// requiring strict enforcement.
+TEST_F(IOSSSLErrorHandlerTest, StrictEnforcementForKnownHSTSHost) {
+ GURL url(kTestHostName);
+ profile_->GetRequestContext()
+ ->GetURLRequestContext()
+ ->transport_security_state()
+ ->AddHSTS(url.host(), base::Time::Now() + base::Days(1000),
+ /*include_subdomains=*/false);
+
+ net::SSLInfo ssl_info;
+ ssl_info.cert = cert();
+ base::test::TestFuture<NSString*> test_future;
+ IOSSSLErrorHandler::HandleSSLError(
+ web_state(), net::ERR_CERT_AUTHORITY_INVALID, ssl_info, url,
+ /*overridable=*/true,
+ /*navigation_id=*/0, test_future.GetCallback());
+
+ NSString* blocking_page_html = test_future.Get();
+ EXPECT_NE(blocking_page_html, nil);
+
+ // The interstitial must not offer a proceed link.
+ EXPECT_FALSE([blocking_page_html containsString:@"id=\"proceed-link\""]);
+}
+
+// Tests that a certificate error remains overridable when the request host has
+// no transport security state requiring strict enforcement.
+TEST_F(IOSSSLErrorHandlerTest, OverridableWithoutKnownHSTS) {
+ net::SSLInfo ssl_info;
+ ssl_info.cert = cert();
+ GURL url(kTestHostName);
+ base::test::TestFuture<NSString*> test_future;
+ IOSSSLErrorHandler::HandleSSLError(
+ web_state(), net::ERR_CERT_AUTHORITY_INVALID, ssl_info, url,
+ /*overridable=*/true,
+ /*navigation_id=*/0, test_future.GetCallback());
+
+ NSString* blocking_page_html = test_future.Get();
+ EXPECT_NE(blocking_page_html, nil);
+
+ EXPECT_TRUE([blocking_page_html containsString:@"id=\"proceed-link\""]);
}
Regression Test / PoC
diff --git a/ios/chrome/browser/ssl/model/ios_ssl_error_handler_unittest.mm b/ios/chrome/browser/ssl/model/ios_ssl_error_handler_unittest.mm
index 1172b816..1aced455 100644
--- a/ios/chrome/browser/ssl/model/ios_ssl_error_handler_unittest.mm
+++ b/ios/chrome/browser/ssl/model/ios_ssl_error_handler_unittest.mm
@@ -7,6 +7,7 @@
#import "base/functional/bind.h"
#import "base/run_loop.h"
#import "base/test/ios/wait_util.h"
+#import "base/test/test_future.h"
#import "components/captive_portal/core/captive_portal_detector.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
#import "ios/chrome/browser/ssl/model/captive_portal_tab_helper.h"
@@ -16,9 +17,12 @@
#import "ios/web/public/test/web_task_environment.h"
#import "ios/web/public/web_state.h"
#import "net/http/http_status_code.h"
+#import "net/http/transport_security_state.h"
#import "net/ssl/ssl_info.h"
#import "net/test/cert_test_util.h"
#import "net/test/test_data_directory.h"
+#import "net/url_request/url_request_context.h"
+#import "net/url_request/url_request_context_getter.h"
#import "services/network/public/cpp/weak_wrapper_shared_url_loader_factory.h"
#import "services/network/test/test_url_loader_factory.h"
#import "testing/platform_test.h"
@@ -124,20 +128,55 @@
net::SSLInfo ssl_info;
ssl_info.cert = cert();
GURL url(kTestHostName);
- __block bool blocking_page_callback_called = false;
- base::OnceCallback<void(bool)> null_callback;
- base::OnceCallback<void(NSString*)> blocking_page_callback =
- base::BindOnce(^(NSString* blocking_page_html) {
- EXPECT_NE(blocking_page_html, nil);
- blocking_page_callback_called = true;
- });
- IOSSSLErrorHandler::HandleSSLError(
- web_state(), net::ERR_CERT_AUTHORITY_INVALID, ssl_info, url, true, 0,
- std::move(blocking_page_callback));
+ base::test::TestFuture<NSString*> test_future;
+ IOSSSLErrorHandler::HandleSSLError(web_state(),
+ net::ERR_CERT_AUTHORITY_INVALID, ssl_info,
+ url, true, 0, test_future.GetCallback());
- EXPECT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
- base::test::ios::kWaitForActionTimeout, ^bool() {
- base::RunLoop().RunUntilIdle();
- return blocking_page_callback_called;
- }));
+ NSString* blocking_page_html = test_future.Get();
+ EXPECT_NE(blocking_page_html, nil);
+}
+
+// Tests that an otherwise-overridable certificate error is presented as
+// non-overridable when the request host has known transport security state
+// requiring strict enforcement.
+TEST_F(IOSSSLErrorHandlerTest, StrictEnforcementForKnownHSTSHost) {
+ GURL url(kTestHostName);
+ profile_->GetRequestContext()
+ ->GetURLRequestContext()
+ ->transport_security_state()
+ ->AddHSTS(url.host(), base::Time::Now() + base::Days(1000),
+ /*include_subdomains=*/false);
+
+ net::SSLInfo ssl_info;
+ ssl_info.cert = cert();
+ base::test::TestFuture<NSString*> test_future;
+ IOSSSLErrorHandler::HandleSSLError(
+ web_state(), net::ERR_CERT_AUTHORITY_INVALID, ssl_info, url,
+ /*overridable=*/true,
+ /*navigation_id=*/0, test_future.GetCallback());
+
+ NSString* blocking_page_html = test_future.Get();
+ EXPECT_NE(blocking_page_html, nil);
+
+ // The interstitial must not offer a proceed link.
+ EXPECT_FALSE([blocking_page_html containsString:@"id=\"proceed-link\""]);
+}
+
+// Tests that a certificate error remains overridable when the request host has
+// no transport security state requiring strict enforcement.
+TEST_F(IOSSSLErrorHandlerTest, OverridableWithoutKnownHSTS) {
+ net::SSLInfo ssl_info;
+ ssl_info.cert = cert();
+ GURL url(kTestHostName);
+ base::test::TestFuture<NSString*> test_future;
+ IOSSSLErrorHandler::HandleSSLError(
+ web_state(), net::ERR_CERT_AUTHORITY_INVALID, ssl_info, url,
+ /*overridable=*/true,
+ /*navigation_id=*/0, test_future.GetCallback());
+
+ NSString* blocking_page_html = test_future.Get();
+ EXPECT_NE(blocking_page_html, nil);
+
+ EXPECT_TRUE([blocking_page_html containsString:@"id=\"proceed-link\""]);
}
Original Bug Report
Potential HSTS hard-fail bypass on iOS due to missing HSTS checks in SSL error handling
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: Chrome for iOS potentially fails to enforce non-overridable (hard-fail) SSL interstitials for HSTS-preloaded hosts. This allows a network attacker to present an overridable security warning on strictly protected domains like accounts.google.com, violating RFC 6797.
Affected files:
ios/chrome/browser/ssl/model/ios_ssl_error_handler.mmios/web/security/crw_cert_verification_controller.mmios/web/navigation/crw_wk_navigation_handler.mmios/chrome/browser/web/model/chrome_web_client.mmios/chrome/browser/ssl/model/ios_ssl_blocking_page.mm
Estimated timestamp from git blame: 2019-10-23
Description
On most Chromium platforms, a certificate error on an HSTS-protected host results in a non-overridable (hard-fail) interstitial. This is enforced by consulting net::TransportSecurityState, which identifies the host as requiring strict enforcement and sets the appropriate flags in the security interstitial UI to omit the ‘Proceed’ link.
On iOS, however, navigation and SSL trust evaluation are handled via WKWebView and the system’s SecTrust API. When an SSL error occurs, Chrome iOS determines the overridability of the error based on the system’s trust result rather than its own HSTS preload data. Specifically, a recoverable failure (like a self-signed certificate) is incorrectly marked as overridable even for hosts on the HSTS preload list.
Technical Details
- Policy Determination: In
ios/web/security/crw_cert_verification_controller.mm, the methodloadPolicyForRejectedTrustResultreturnsweb::CERT_ACCEPT_POLICY_RECOVERABLE_ERROR_UNDECIDED_BY_USERfor errors like self-signed certificates (kSecTrustResultRecoverableTrustFailure). This decision is made without any check against Chromium’s HSTS preload list ornet::TransportSecurityState. - State Tracking: In
ios/web/navigation/crw_wk_navigation_handler.mm, this policy is mapped to aweb::CertVerificationErrorwithis_recoverable = true. - Flag Assignment: When preparing the error page in
loadErrorPageForNavigationItem, thisis_recoverablevalue is assigned tonet::SSLInfo::is_fatal_cert_error(line 2204). - Semantic Inversion: On iOS, the
is_fatal_cert_errorflag has inverted semantics compared to other platforms:trueindicates the error is overridable (soft-fail), whilefalseindicates it is not. This is documented inios/web_view/internal/cwv_ssl_error_handler.mm(line 41). - Interstitial Display:
IOSSSLErrorHandler::ShowSSLInterstitialuses this flag to setSOFT_OVERRIDE_ENABLED(line 150). As a result, the user is presented with a ‘Proceed to [host] (unsafe)’ link on domains where such bypasses should be forbidden.
Potential Impact
A network attacker performing a Man-in-the-Middle (MITM) attack against HSTS-preloaded hosts (e.g., accounts.google.com) can present a self-signed certificate. Users will see an overridable warning, allowing them to bypass the security protection and expose sensitive session data or credentials to the attacker.
Potential Steps to Reproduce
- Use a proxy (like mitmproxy) to MITM
https://accounts.google.com/with a self-signed certificate. - Navigate to
https://accounts.google.com/in Chrome for iOS. - Observe the ‘Your connection is not private’ interstitial.
- Tap ‘Advanced’ and check for the existence of the ‘Proceed to accounts.google.com (unsafe)’ link.
Suggested Fix
Modify CRWCertVerificationController or CRWWKNavigationHandler to consult a source of HSTS state (such as a lookup in net::TransportSecurityState or a dedicated iOS-compatible HSTS list) when determining if an SSL error should be recoverable. If the host is HSTS-protected, is_recoverable should be forced to false regardless of the SecTrust result.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.