CVE-2026-87482
Overview
Background
- HTTPS-Only Mode / HTTPS-Upgrades
- iOS Chrome features that force main-frame navigations onto HTTPS and show an interstitial (or fall back) rather than sending a cleartext HTTP request.
- `WebStatePolicyDecider`
- a web-layer interface whose
ShouldAllowRequestis consulted before each network request is issued and whoseShouldAllowResponseis consulted for a received response;HttpsOnlyModeUpgradeTabHelperimplements it to enforce upgrades. - `ShouldAllowResponse` vs redirect chains
- per the commit message and the removed comment, WKWebView invokes
ShouldAllowResponseonly for the final non-3xx response of a redirect chain, so intermediate hops are never seen there. - `State` machine
HttpsOnlyModeUpgradeTabHelpertracks an upgrade lifecycle across states such askNone,kUpgraded,kStoppedToFallback,kFallbackStarted, andkDone, driven by navigation callbacks.
Root Cause Analysis
Before the patch, HttpsOnlyModeUpgradeTabHelper enforced HTTPS upgrading only in ShouldAllowResponse, which WKWebView calls solely for the final non-3xx response of a redirect chain. A navigation such as HTTPS -> HTTP -> HTTPS therefore transmitted the intermediate HTTP request in cleartext and then rendered the final HTTPS page with no interstitial, because the enforcement point never observed the HTTP hop. The removed comment in ShouldAllowResponse explicitly acknowledged this gap (“this doesn’t handle HTTP URLs in the middle of redirects such as HTTPS -> HTTP -> HTTPS”) and claimed there was “not enough information” at request time to act.
The fix adds a ShouldAllowRequest override that inspects each request before it is sent and, for a main-frame redirect to an HTTP URL within an in-progress navigation, cancels the request and re-drives it through the upgrade/fallback state machine. A new was_navigation_started_ flag distinguishes a redirect hop (where cancelling yields a matching DidFinishNavigation) from the initial request of a fresh navigation (still handled at response time), and the kStoppedToFallback state routes an already-upgraded navigation’s HTTP redirect through the existing fallback path. The change is a tightening: it adds a pre-send guard where none previously existed, closing the mid-chain cleartext leak.
ShouldAllowResponse), which never sees intermediate redirect hops, so a cleartext HTTP link in the middle of a chain was sent before any check ran. The fix moves enforcement earlier by overriding ShouldAllowRequest to cancel main-frame HTTP redirect targets before the request leaves the device.Attack Path
- Serve a redirecting faux-HTTPS entry point
The attacker (or a page the victim visits) provides an HTTPS URL that server-redirects into the chain
HTTPS -> HTTP -> HTTPS, matching thetest_HTTPSRedirectsToHTTP_ShouldUpgradeRedirectTargetEG scenario inhttps_only_mode_egtest.mm. - Bypass the response-only check
Because pre-patch enforcement lived only in
ShouldAllowResponse, which WKWebView invokes only for the final non-3xx response, the intermediate HTTP hop is never evaluated and the request is allowed to proceed. - Emit the cleartext request The main-frame HTTP request in the middle of the chain is sent in cleartext, exposing its URL and any request data (e.g., cookies/headers/sensitive path or query data) to an on-path network observer, while the browser continues to the final HTTPS page with no interstitial shown.
- Escalate via the exposed cleartext data (inferred) Any further use of the intercepted or tamperable cleartext hop (session/credential disclosure, request tampering) depends on what the specific navigation carries and is not demonstrated by the diff.
Impact Assessment
ShouldAllowRequest), the request is a non-POST main-frame navigation, and the HTTP host is not allowlisted or localhost or a test fake. The advisory classifies this as cleartext transmission of sensitive data (a LogicError in HttpsUpgrades); it does not report exploitation in the wild, and any downstream credential theft or request tampering beyond the cleartext exposure itself is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.mm |
modified | |
BindOnceios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper_unittest.mm |
modified | |
TEST_Pios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper_unittest.mm |
modified |
Files Changed
ios/chrome/browser/https_upgrades/model/https_only_mode_egtest.mmios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.mmios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper_unittest.mmios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.h
Audit Directions
- Enforcement only at response timeLook for other security policies (upgrade, blocking, interstitial) implemented solely in
ShouldAllowResponseor equivalent response-time hooks that miss intermediate redirect hops WKWebView never surfaces there. - Redirect-chain gapsAudit navigation logic that reasons about “the URL” of a navigation without iterating every hop, especially where a downgrade or scheme change in the middle of a chain is not re-validated before the request is sent.
- State/flag lifecycleReview helpers that gate behavior on a navigation-lifecycle flag like
was_navigation_started_, checking that the flag is set and cleared on every navigation path (start, finish, cancel, same-document) so redirects and fresh requests are not confused.
Patch
From 50dff058f0c710121b0454dbf024b2d881d60099 Mon Sep 17 00:00:00 2001
From: Kouhei Ueno <kouhei@chromium.org>
Date: Tue, 28 Jul 2026 03:47:52 -0700
Subject: [PATCH] [iOS] HTTPS-Only Mode: upgrade HTTP redirect targets at request
HttpsOnlyModeUpgradeTabHelper only enforced in ShouldAllowResponse,
which WKWebView calls for the final non-3xx response of a redirect
chain. A chain of HTTPS -> HTTP -> HTTPS would therefore send the
cleartext request and render the final HTTPS page without an
interstitial.
Override ShouldAllowRequest so that main-frame redirects to HTTP within
an in-progress navigation are cancelled before being sent and re-issued
through the existing upgrade state machine. A new
was_navigation_started_ flag distinguishes redirect hops, where
cancelling produces a matching DidFinishNavigation, from the initial
request of a new navigation (which is still handled at response time).
The previously unused kStoppedToFallback state is now set when an
already-upgraded navigation redirects to HTTP so that
DidFinishNavigation routes through the existing fallback path.
Add a unit test that drives ShouldAllowRequest before and after
DidStartNavigation, and an EG test that loads a faux-HTTPS page
redirecting to HTTP and checks that the HTTP server is never reached.
TAG=agy
CONV=4f6d8236-305b-4859-b9ef-29b97faef52f
Bug: 503736006
Change-Id: I657e0c3ad9bd49b1b1237a550bc2890d78a4eed0
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8017366
Commit-Queue: Kouhei Ueno <kouhei@chromium.org>
Reviewed-by: Mustafa Emre Acer <meacer@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1669338}
---
diff --git a/ios/chrome/browser/https_upgrades/model/https_only_mode_egtest.mm b/ios/chrome/browser/https_upgrades/model/https_only_mode_egtest.mm
index 1ff8ae3..a8bcb0ff 100644
--- a/ios/chrome/browser/https_upgrades/model/https_only_mode_egtest.mm
+++ b/ios/chrome/browser/https_upgrades/model/https_only_mode_egtest.mm
@@ -402,6 +402,23 @@
[self assertFailedUpgrade:1];
}
+// Navigate to an HTTPS URL that redirects to an HTTP URL. The HTTP redirect
+// target should be upgraded to HTTPS before the request is sent so that the
+// HTTP server never receives a cleartext request.
+- (void)test_HTTPSRedirectsToHTTP_ShouldUpgradeRedirectTarget {
+ [HttpsUpgradeAppInterface setHTTPSPortForTesting:self.goodHTTPSServer->port()
+ useFakeHTTPS:true];
+
+ GURL targetURL = self.testServer->GetURL("/");
+ GURL testURL = self.goodHTTPSServer->GetURL("/?redirect=" + targetURL.spec());
+ [ChromeEarlGrey loadURL:testURL];
+ [ChromeEarlGrey waitForWebStateContainingText:"HTTPS_RESPONSE"];
+ [self assertSuccessfulUpgrade];
+
+ GREYAssertEqual(0, _HTTPResponseCounter,
+ @"The HTTP server should not have been reached");
+}
+
// Tests that prerendered navigations that should be upgraded are cancelled.
// This test is adapted from testTapPrerenderSuggestions() in
// prerender_egtest.mm.
diff --git a/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.h b/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.h
index e2bfeae..1ed3bb4 100644
--- a/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.h
+++ b/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.h
@@ -86,6 +86,10 @@
void ResetState();
// web::WebStatePolicyDecider implementation:
+ void ShouldAllowRequest(
+ NSURLRequest* request,
+ WebStatePolicyDecider::RequestInfo request_info,
+ web::WebStatePolicyDecider::PolicyDecisionCallback callback) override;
void ShouldAllowResponse(
NSURLResponse* response,
WebStatePolicyDecider::ResponseInfo response_info,
@@ -115,6 +119,11 @@
// Used to check if the navigation should be upgraded when a response is
// received. Cleared when the current navigation finishes.
bool navigation_is_post_ = false;
+ // Set to true when a main frame navigation has started but not yet finished.
+ // Used to distinguish ShouldAllowRequest calls for redirects within an
+ // in-progress navigation from those for the initial request of a new
+ // navigation.
+ bool was_navigation_started_ = false;
base::OneShotTimer timer_;
diff --git a/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.mm b/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.mm
index 9fb8d628..98a1bd6 100644
--- a/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.mm
+++ b/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.mm
@@ -141,6 +141,75 @@
}
// web::WebStatePolicyDecider
+void HttpsOnlyModeUpgradeTabHelper::ShouldAllowRequest(
+ NSURLRequest* request,
+ WebStatePolicyDecider::RequestInfo request_info,
+ base::OnceCallback<void(web::WebStatePolicyDecider::PolicyDecision)>
+ callback) {
+ GURL url = net::GURLWithNSURL(request.URL);
+ // Only handle main frame redirects to HTTP within an in-progress navigation.
+ // The initial request of a navigation is handled in ShouldAllowResponse(),
+ // because cancelling it here does not produce the DidFinishNavigation()
+ // callback that drives the upgrade state machine.
+ if (!was_navigation_started_ || !request_info.target_frame_is_main ||
+ !url.SchemeIs(url::kHttpScheme) || service_->IsFakeHTTPSForTesting(url) ||
+ IsHttpAllowedForUrl(url) || state_ == State::kFallbackStarted) {
+ std::move(callback).Run(
+ web::WebStatePolicyDecider::PolicyDecision::Allow());
+ return;
+ }
+
+ if ((!base::FeatureList::IsEnabled(
+ security_interstitials::features::kHttpsUpgrades) &&
+ !(prefs_ && prefs_->GetBoolean(prefs::kHttpsOnlyModeEnabled))) ||
+ service_->IsLocalhost(url) || navigation_is_post_) {
+ std::move(callback).Run(
+ web::WebStatePolicyDecider::PolicyDecision::Allow());
+ return;
+ }
+
+ web::NavigationItem* item_pending =
+ web_state()->GetNavigationManager()->GetPendingItem();
+ web::HttpsUpgradeType upgrade_type = item_pending
+ ? item_pending->GetHttpsUpgradeType()
+ : web::HttpsUpgradeType::kNone;
+
+ // Omnibox upgrade failures are handled in TypedNavigationUpgradeTabHelper.
+ // Ignore them here.
+ if (upgrade_type != web::HttpsUpgradeType::kNone &&
+ upgrade_type != web::HttpsUpgradeType::kHttpsOnlyMode) {
+ std::move(callback).Run(
+ web::WebStatePolicyDecider::PolicyDecision::Allow());
+ return;
+ }
+
+ // If the tab is being prerendered, cancel the prerender instead of upgrading.
+ if (PrerenderTabHelper::FromWebState(web_state())) {
+ RecordUMA(Event::kPrerenderCancelled);
+ ResetState();
+ return std::move(callback)
+ .Then(base::BindOnce(&CancelPrerender, web_state()->GetWeakPtr()))
+ .Run(web::WebStatePolicyDecider::PolicyDecision::Cancel());
+ }
+
+ if (upgrade_type == web::HttpsUpgradeType::kHttpsOnlyMode) {
+ // The previously upgraded navigation has redirected to HTTP. Stop it and
+ // let DidFinishNavigation() trigger the fallback navigation.
+ timer_.Stop();
+ state_ = State::kStoppedToFallback;
+ http_url_ = url;
+ std::move(callback).Run(
+ web::WebStatePolicyDecider::PolicyDecision::Cancel());
+ return;
+ }
+
+ // The navigation has redirected to an HTTP URL. Cancel the request before it
+ // is sent and let DidFinishNavigation() start the upgraded navigation.
+ StopToUpgrade(url,
+ item_pending ? item_pending->GetReferrer() : web::Referrer(),
+ std::move(callback));
+}
+
void HttpsOnlyModeUpgradeTabHelper::ShouldAllowResponse(
NSURLResponse* response,
WebStatePolicyDecider::ResponseInfo response_info,
@@ -263,15 +332,9 @@
}
// The navigation was already upgraded but landed on an HTTP URL, possibly
- // through redirects (e.g. upgraded HTTPS -> HTTP). In this case, show the
- // interstitial.
- // Note that this doesn't handle HTTP URLs in the middle of redirects such as
- // HTTPS -> HTTP -> HTTPS. The alternative is to do this check in
- // ShouldAllowRequest(), but we don't have enough information there to ensure
- // whether the HTTP URL is part of the redirect chain or a completely new
- // navigation.
- // This is a divergence from the desktop implementation of this feature which
- // relies on a redirect loop triggering a net error.
+ // through redirects (e.g. upgraded HTTPS -> HTTP). HTTP redirect targets are
+ // normally cancelled in ShouldAllowRequest() before being sent, but this can
+ // still be reached when the navigation uses POST. Show the interstitial.
DCHECK(state_ == State::kUpgraded || state_ == State::kNone);
timer_.Stop();
state_ = State::kDone;
@@ -297,6 +360,7 @@
if (navigation_context->IsSameDocument()) {
return;
}
+ was_navigation_started_ = true;
if (state_ == State::kUpgraded) {
DCHECK(!timer_.IsRunning());
// `timer_` is deleted when the tab helper is deleted, so it's safe to use
@@ -323,6 +387,7 @@
return;
}
navigation_is_post_ = false;
+ was_navigation_started_ = false;
if (state_ == State::kNone) {
return;
}
diff --git a/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper_unittest.mm b/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper_unittest.mm
index 097f513..5ffac52 100644
--- a/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper_unittest.mm
+++ b/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper_unittest.mm
@@ -17,6 +17,7 @@
#import "ios/components/security_interstitials/https_only_mode/https_upgrade_service.h"
#import "ios/components/security_interstitials/https_only_mode/https_upgrade_test_util.h"
#import "ios/web/public/navigation/web_state_policy_decider.h"
+#import "ios/web/public/test/fakes/fake_navigation_context.h"
#import "ios/web/public/test/fakes/fake_navigation_manager.h"
#import "ios/web/public/test/fakes/fake_web_state.h"
#import "ios/web/public/test/web_task_environment.h"
@@ -132,6 +133,32 @@
return policy_decision;
}
+ // Helper function that calls into WebState::ShouldAllowRequest with the
+ // given `url` and `for_main_frame`, waits for the callback with the decision
+ // to be called, and returns the decision.
+ web::WebStatePolicyDecider::PolicyDecision ShouldAllowRequestUrl(
+ const GURL& url,
+ bool for_main_frame) {
+ NSURLRequest* request =
+ [NSURLRequest requestWithURL:net::NSURLWithGURL(url)];
+ __block bool callback_called = false;
+ __block web::WebStatePolicyDecider::PolicyDecision policy_decision =
+ web::WebStatePolicyDecider::PolicyDecision::Allow();
+ auto callback =
+ base::BindOnce(^(web::WebStatePolicyDecider::PolicyDecision decision) {
+ policy_decision = decision;
+ callback_called = true;
+ });
+ web::WebStatePolicyDecider::RequestInfo request_info(
+ ui::PAGE_TRANSITION_CLIENT_REDIRECT, for_main_frame,
+ /*target_frame_is_cross_origin=*/false,
+ /*target_window_is_cross_origin=*/false,
+ /*is_user_initiated=*/false, /*user_tapped_recently=*/false);
+ web_state_.ShouldAllowRequest(request, request_info, std::move(callback));
+ EXPECT_TRUE(callback_called);
+ return policy_decision;
+ }
+
base::HistogramTester histogram_tester_;
web::FakeWebState web_state_;
@@ -142,9 +169,7 @@
};
// Tests that ShouldAllowResponse properly upgrades navigations and
-// ignores subframe navigations. ShouldAllowRequest should always allow
-// the navigation.
-// Also tests that UMA records correctly.
+// ignores subframe navigations. Also tests that UMA records correctly.
TEST_P(HttpsOnlyModeUpgradeTabHelperTest, ShouldAllowResponse) {
// Create a navigation item.
auto fake_navigation_manager_ =
@@ -186,6 +211,61 @@
.ShouldAllowNavigation());
}
+// Tests that ShouldAllowRequest allows the initial request of a navigation so
+// that ShouldAllowResponse can handle the upgrade, but cancels HTTP redirect
+// targets in the middle of an in-progress main frame navigation so that the
+// redirect chain does not transit cleartext.
+TEST_P(HttpsOnlyModeUpgradeTabHelperTest, ShouldAllowRequest) {
+ auto fake_navigation_manager = std::make_unique<web::FakeNavigationManager>();
+ std::unique_ptr<web::NavigationItem> pending_item =
+ web::NavigationItem::Create();
+ fake_navigation_manager->SetPendingItem(pending_item.release());
+ web_state_.SetNavigationManager(std::move(fake_navigation_manager));
+
+ GURL https_url("https://example.com/");
+ GURL http_url("http://example.com/");
+
+ // Before the navigation has started, the initial request should be allowed
+ // regardless of scheme; ShouldAllowResponse handles upgrades for the first
+ // hop.
+ EXPECT_TRUE(ShouldAllowRequestUrl(http_url, /*main_frame=*/true)
+ .ShouldAllowNavigation());
+ EXPECT_TRUE(ShouldAllowRequestUrl(https_url, /*main_frame=*/true)
+ .ShouldAllowNavigation());
+
+ // Start an HTTPS main frame navigation. Subsequent ShouldAllowRequest calls
+ // simulate server redirects.
+ web::FakeNavigationContext context;
+ context.SetUrl(https_url);
+ web_state_.OnNavigationStarted(&context);
+
+ // Redirects to HTTPS should always be allowed.
+ EXPECT_TRUE(ShouldAllowRequestUrl(https_url, /*main_frame=*/true)
+ .ShouldAllowNavigation());
+ // Subframe HTTP requests should be allowed.
+ EXPECT_TRUE(ShouldAllowRequestUrl(http_url, /*main_frame=*/false)
+ .ShouldAllowNavigation());
+ // Allowlisted hosts shouldn't be blocked.
+ ProfileIOS* profile =
+ ProfileIOS::FromBrowserState(web_state_.GetBrowserState());
+ HttpsUpgradeService* service =
+ HttpsUpgradeServiceFactory::GetForProfile(profile);
+ service->AllowHttpForHost("allowlisted.com");
+ EXPECT_TRUE(ShouldAllowRequestUrl(GURL("http://allowlisted.com/"),
+ /*main_frame=*/true)
+ .ShouldAllowNavigation());
+
+ // If either HTTPS-Only Mode or HTTPS-Upgrades is enabled, redirects to HTTP
+ // in the main frame should be cancelled before the request is sent.
+ if (GetParam() != HttpsUpgradesTestType::kNone) {
+ EXPECT_FALSE(ShouldAllowRequestUrl(http_url, /*main_frame=*/true)
+ .ShouldAllowNavigation());
+ } else {
+ EXPECT_TRUE(ShouldAllowRequestUrl(http_url, /*main_frame=*/true)
+ .ShouldAllowNavigation());
+ }
+}
+
TEST_P(HttpsOnlyModeUpgradeTabHelperTest, GetUpgradedHttpsUrl) {
ProfileIOS* profile =
ProfileIOS::FromBrowserState(web_state_.GetBrowserState());
Regression Test / PoC
diff --git a/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper_unittest.mm b/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper_unittest.mm
index 097f513..5ffac52 100644
--- a/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper_unittest.mm
+++ b/ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper_unittest.mm
@@ -17,6 +17,7 @@
#import "ios/components/security_interstitials/https_only_mode/https_upgrade_service.h"
#import "ios/components/security_interstitials/https_only_mode/https_upgrade_test_util.h"
#import "ios/web/public/navigation/web_state_policy_decider.h"
+#import "ios/web/public/test/fakes/fake_navigation_context.h"
#import "ios/web/public/test/fakes/fake_navigation_manager.h"
#import "ios/web/public/test/fakes/fake_web_state.h"
#import "ios/web/public/test/web_task_environment.h"
@@ -132,6 +133,32 @@
return policy_decision;
}
+ // Helper function that calls into WebState::ShouldAllowRequest with the
+ // given `url` and `for_main_frame`, waits for the callback with the decision
+ // to be called, and returns the decision.
+ web::WebStatePolicyDecider::PolicyDecision ShouldAllowRequestUrl(
+ const GURL& url,
+ bool for_main_frame) {
+ NSURLRequest* request =
+ [NSURLRequest requestWithURL:net::NSURLWithGURL(url)];
+ __block bool callback_called = false;
+ __block web::WebStatePolicyDecider::PolicyDecision policy_decision =
+ web::WebStatePolicyDecider::PolicyDecision::Allow();
+ auto callback =
+ base::BindOnce(^(web::WebStatePolicyDecider::PolicyDecision decision) {
+ policy_decision = decision;
+ callback_called = true;
+ });
+ web::WebStatePolicyDecider::RequestInfo request_info(
+ ui::PAGE_TRANSITION_CLIENT_REDIRECT, for_main_frame,
+ /*target_frame_is_cross_origin=*/false,
+ /*target_window_is_cross_origin=*/false,
+ /*is_user_initiated=*/false, /*user_tapped_recently=*/false);
+ web_state_.ShouldAllowRequest(request, request_info, std::move(callback));
+ EXPECT_TRUE(callback_called);
+ return policy_decision;
+ }
+
base::HistogramTester histogram_tester_;
web::FakeWebState web_state_;
@@ -142,9 +169,7 @@
};
// Tests that ShouldAllowResponse properly upgrades navigations and
-// ignores subframe navigations. ShouldAllowRequest should always allow
-// the navigation.
-// Also tests that UMA records correctly.
+// ignores subframe navigations. Also tests that UMA records correctly.
TEST_P(HttpsOnlyModeUpgradeTabHelperTest, ShouldAllowResponse) {
// Create a navigation item.
auto fake_navigation_manager_ =
@@ -186,6 +211,61 @@
.ShouldAllowNavigation());
}
+// Tests that ShouldAllowRequest allows the initial request of a navigation so
+// that ShouldAllowResponse can handle the upgrade, but cancels HTTP redirect
+// targets in the middle of an in-progress main frame navigation so that the
+// redirect chain does not transit cleartext.
+TEST_P(HttpsOnlyModeUpgradeTabHelperTest, ShouldAllowRequest) {
+ auto fake_navigation_manager = std::make_unique<web::FakeNavigationManager>();
+ std::unique_ptr<web::NavigationItem> pending_item =
+ web::NavigationItem::Create();
+ fake_navigation_manager->SetPendingItem(pending_item.release());
+ web_state_.SetNavigationManager(std::move(fake_navigation_manager));
+
+ GURL https_url("https://example.com/");
+ GURL http_url("http://example.com/");
+
+ // Before the navigation has started, the initial request should be allowed
+ // regardless of scheme; ShouldAllowResponse handles upgrades for the first
+ // hop.
+ EXPECT_TRUE(ShouldAllowRequestUrl(http_url, /*main_frame=*/true)
+ .ShouldAllowNavigation());
+ EXPECT_TRUE(ShouldAllowRequestUrl(https_url, /*main_frame=*/true)
+ .ShouldAllowNavigation());
+
+ // Start an HTTPS main frame navigation. Subsequent ShouldAllowRequest calls
+ // simulate server redirects.
+ web::FakeNavigationContext context;
+ context.SetUrl(https_url);
+ web_state_.OnNavigationStarted(&context);
+
+ // Redirects to HTTPS should always be allowed.
+ EXPECT_TRUE(ShouldAllowRequestUrl(https_url, /*main_frame=*/true)
+ .ShouldAllowNavigation());
+ // Subframe HTTP requests should be allowed.
+ EXPECT_TRUE(ShouldAllowRequestUrl(http_url, /*main_frame=*/false)
+ .ShouldAllowNavigation());
+ // Allowlisted hosts shouldn't be blocked.
+ ProfileIOS* profile =
+ ProfileIOS::FromBrowserState(web_state_.GetBrowserState());
+ HttpsUpgradeService* service =
+ HttpsUpgradeServiceFactory::GetForProfile(profile);
+ service->AllowHttpForHost("allowlisted.com");
+ EXPECT_TRUE(ShouldAllowRequestUrl(GURL("http://allowlisted.com/"),
+ /*main_frame=*/true)
+ .ShouldAllowNavigation());
+
+ // If either HTTPS-Only Mode or HTTPS-Upgrades is enabled, redirects to HTTP
+ // in the main frame should be cancelled before the request is sent.
+ if (GetParam() != HttpsUpgradesTestType::kNone) {
+ EXPECT_FALSE(ShouldAllowRequestUrl(http_url, /*main_frame=*/true)
+ .ShouldAllowNavigation());
+ } else {
+ EXPECT_TRUE(ShouldAllowRequestUrl(http_url, /*main_frame=*/true)
+ .ShouldAllowNavigation());
+ }
+}
+
TEST_P(HttpsOnlyModeUpgradeTabHelperTest, GetUpgradedHttpsUrl) {
ProfileIOS* profile =
ProfileIOS::FromBrowserState(web_state_.GetBrowserState());
Original Bug Report
Potential iOS HTTPS-Only Mode bypass via HTTP redirect chains
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 go/chrome-ai-generated-security-bugs-faq for more information.
Overview: On iOS, Chrome’s HTTPS-Only Mode (HttpsOnlyModeUpgradeTabHelper) only inspects the final URL of a navigation chain when deciding whether to block insecure requests or show an interstitial. Because WKWebView issues cleartext requests for intermediate HTTP redirects before the final response is received, an on-path attacker can intercept these requests, steal cookies, and inject a redirect to an HTTPS site. This completely bypasses the HTTPS-Only Mode protection without the user ever seeing a warning.
Affected files:
ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.mm
Estimated timestamp from git blame: 2022-05-04
Description
On iOS Chrome, HttpsOnlyModeUpgradeTabHelper enforces the “Always Use Secure Connections” (HTTPS-Only/HTTPS-First Mode) setting by overriding WebStatePolicyDecider::ShouldAllowResponse.
However, ShouldAllowResponse is driven by WKWebView’s webView:decidePolicyForNavigationResponse:decisionHandler:, which Apple’s WebKit only invokes for the final, non-redirect response of a navigation chain. WKWebView processes intermediate 3xx redirects internally and issues the new network requests before the final response policy is checked.
As a result, if a user with HTTPS-Only Mode enabled follows an HTTPS link that redirects to an HTTP URL, WKWebView will issue the HTTP request in cleartext over the network. HttpsOnlyModeUpgradeTabHelper does not override ShouldAllowRequest to block this (noted explicitly in developer comments at ios/chrome/browser/https_upgrades/model/https_only_mode_upgrade_tab_helper.mm:269-272). Because iOS Chrome sets NSAllowsArbitraryLoads to true to disable App Transport Security (ATS) globally, the system allows the cleartext request to proceed.
An on-path network attacker can observe this cleartext request, steal sensitive headers (like cookies or Referer), and respond with a spoofed 302 redirect to an arbitrary HTTPS destination. When WKWebView follows this final redirect and receives the HTTPS response, HttpsOnlyModeUpgradeTabHelper::ShouldAllowResponse observes an https:// scheme and unconditionally allows the navigation. The user never sees the HTTPS-Only Mode warning interstitial, despite their traffic being intercepted.
Potential Attack Scenario
Note: These are suggested steps based on source code analysis; a working proof-of-concept has not been executed.
- The victim enables “Always Use Secure Connections” in iOS Chrome settings.
- The attacker establishes an on-path network position (e.g., controlling a rogue Wi-Fi hotspot).
- The victim navigates to an initial HTTPS URL that redirects (e.g.,
https://attacker.com/start). - The server at
attacker.comreturns a 302 redirect tohttp://insecure.com/cleartext. WKWebViewinvokesdecidePolicyForNavigationActionfor the HTTP URL. BecauseHttpsOnlyModeUpgradeTabHelperdoes not implementShouldAllowRequest, the request is allowed.WKWebViewissues the cleartext HTTP request over the wire, leaking the victim’s cookies/headers forinsecure.comto the attacker.- The attacker intercepts the HTTP request and injects a response:
302 Location: https://example.com/final. WKWebViewrequestshttps://example.com/finaland receives a200 OK.WKWebViewinvokesdecidePolicyForNavigationResponsefor the final HTTPS response.HttpsOnlyModeUpgradeTabHelper::ShouldAllowResponsesees the finalhttps://scheme, considers the navigation secure, and allows it without showing the security interstitial.
Suggested Fix
HttpsOnlyModeUpgradeTabHelper must override ShouldAllowRequest (which corresponds to decidePolicyForNavigationAction) to intercept and block or upgrade http:// URLs before the request is issued by WKWebView. While the current comments note that ShouldAllowRequest lacks context about whether an HTTP URL is part of a redirect chain, failing to block it here allows the cleartext request to escape onto the network, defeating the purpose of HTTPS-Only Mode.
Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09
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.