Chrome · Offline › Offline Pages
CVE-2026-113045
OOB in Offline (Offline Pages)
Overview
Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/offline_pages/offline_page_tab_helper.cc |
modified | |
TEST_Fchrome/browser/offline_pages/offline_page_tab_helper_unittest.cc |
modified |
Files Changed
chrome/browser/offline_pages/offline_page_tab_helper_unittest.ccchrome/browser/offline_pages/offline_page_tab_helper.cc
Patch
From 370fb330e15375c712ecb822d833c5cb4e90bf27 Mon Sep 17 00:00:00 2001 From: Carlos Knippschild <[email protected]> Date: Fri, 18 Sep 2026 09:43:58 -0700 Subject: [PATCH] [Offline Pages] Prevent origin spoofing for untrusted MHTML files Ensure untrusted MHTML archives loaded via file:// or content:// cannot spoof the origin displayed in Page Info on Android. Previously, OfflinePageTabHelper set offline_page->url to the unverified Content-Location header URL reported by the renderer. On Android, Page Info displayed this URL as the page's origin and title. Now, untrusted archives always use the navigated local URL. Changes: - Use the navigated URL as offline_page->url for untrusted offline pages. - Reject mismatched renderer-reported URLs for HTTP/HTTPS navigations. - Add unit tests for untrusted local archives and HTTP/HTTPS matching. Fixed: 514040146 Change-Id: I2a4d160b7eab314bbc62bde0a00be73b6a6a6964 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8422981 Reviewed-by: Dan H <[email protected]> Auto-Submit: Carlos Knippschild <[email protected]> Commit-Queue: Dan H <[email protected]> Cr-Commit-Position: refs/heads/main@{#1700868} --- diff --git a/chrome/browser/offline_pages/offline_page_tab_helper.cc b/chrome/browser/offline_pages/offline_page_tab_helper.cc index 419b8836..30de9b7c 100644 --- a/chrome/browser/offline_pages/offline_page_tab_helper.cc +++ b/chrome/browser/offline_pages/offline_page_tab_helper.cc @@ -45,8 +45,9 @@ namespace { bool SchemeIsForUntrustedOfflinePages(const GURL& url) { #if BUILDFLAG(IS_ANDROID) - if (url.SchemeIs(url::kContentScheme)) + if (url.SchemeIs(url::kContentScheme)) { return true; + } #endif return url.SchemeIsFile(); } @@ -71,11 +72,13 @@ receiver, content::RenderFrameHost* rfh) { auto* web_contents = content::WebContents::FromRenderFrameHost(rfh); - if (!web_contents) + if (!web_contents) { return; + } auto* tab_helper = OfflinePageTabHelper::FromWebContents(web_contents); - if (!tab_helper) + if (!tab_helper) { return; + } tab_helper->mhtml_page_notifier_receivers_.Bind(rfh, std::move(receiver)); } @@ -150,22 +153,26 @@ } // Sanity checking the input URL. - if (!main_frame_url.is_valid() || !main_frame_url.SchemeIsHTTPOrHTTPS()) + if (!main_frame_url.is_valid() || !main_frame_url.SchemeIsHTTPOrHTTPS()) { return; + } - if (!provisional_offline_info_.IsValid()) + if (!provisional_offline_info_.IsValid()) { provisional_offline_info_ = LoadedOfflinePageInfo::MakeUntrusted(); + } provisional_offline_info_.offline_page->url = main_frame_url; - if (!date.is_null()) + if (!date.is_null()) { provisional_offline_info_.offline_page->creation_time = date; + } } void OfflinePageTabHelper::DidStartNavigation( content::NavigationHandle* navigation_handle) { // Skips non-main frame. - if (!navigation_handle->IsInPrimaryMainFrame()) + if (!navigation_handle->IsInPrimaryMainFrame()) { return; + } // The provisional offline info can be cleared no matter how. provisional_offline_info_.Clear(); @@ -174,14 +181,17 @@ void OfflinePageTabHelper::DidFinishNavigation( content::NavigationHandle* navigation_handle) { // Skips non-main frame. - if (!navigation_handle->IsInPrimaryMainFrame()) + if (!navigation_handle->IsInPrimaryMainFrame()) { return; + } - if (!navigation_handle->HasCommitted()) + if (!navigation_handle->HasCommitted()) { return; + } - if (navigation_handle->IsSameDocument()) + if (navigation_handle->IsSameDocument()) { return; + } if (offline_info_.IsValid()) { // Do not store the offline page we are navigating away from in bfcache. @@ -209,8 +219,9 @@ content::NavigationHandle* navigation_handle) { offline_info_.Clear(); - if (navigation_handle->IsErrorPage()) + if (navigation_handle->IsErrorPage()) { return; + } GURL navigated_url = navigation_handle->GetURL(); @@ -230,14 +241,18 @@ } else { // Otherwise, just use an empty untrusted page. offline_info_ = LoadedOfflinePageInfo::MakeUntrusted(); + } + if (offline_info_.trusted_state == OfflinePageTrustedState::UNTRUSTED) { offline_info_.offline_page->url = navigated_url; } } else if (navigated_url.SchemeIsHTTPOrHTTPS()) { // For http/https URL, commit the provisional offline info if any. if (provisional_offline_info_.IsValid()) { - DCHECK(EqualsIgnoringFragment( - navigated_url, provisional_offline_info_.offline_page->url)); - offline_info_ = std::move(provisional_offline_info_); + if (EqualsIgnoringFragment(navigated_url, + provisional_offline_info_.offline_page->url)) { + offline_info_ = std::move(provisional_offline_info_); + offline_info_.offline_page->url = navigated_url; + } provisional_offline_info_.Clear(); } } @@ -247,14 +262,16 @@ content::NavigationHandle* navigation_handle) { // If the offline page has been loaded successfully, nothing more to do. net::Error error_code = navigation_handle->GetNetErrorCode(); - if (error_code == net::OK) + if (error_code == net::OK) { return; + } // We might be reloading the URL in order to fetch the offline page. // * If successful, nothing to do. // * Otherwise, we're hitting error again. Bail out to avoid loop. - if (reloading_url_on_net_error_) + if (reloading_url_on_net_error_) { return; + } // When the navigation starts, the request might be intercepted to serve the // offline content if the network is detected to be in disconnected or poor @@ -353,10 +370,12 @@ } const OfflinePageItem* OfflinePageTabHelper::GetOfflinePreviewItem() const { - if (provisional_offline_info_.is_showing_offline_preview) + if (provisional_offline_info_.is_showing_offline_preview) { return provisional_offline_info_.offline_page.get(); - if (offline_info_.is_showing_offline_preview) + } + if (offline_info_.is_showing_offline_preview) { return offline_info_.offline_page.get(); + } return nullptr; } @@ -409,8 +428,9 @@ offline_pages::RequestCoordinator* request_coordinator = offline_pages::RequestCoordinatorFactory::GetForBrowserContext( web_contents->GetBrowserContext()); - if (!request_coordinator) + if (!request_coordinator) { return; + } offline_pages::RequestCoordinator::SavePageLaterParams params; params.url = url; diff --git a/chrome/browser/offline_pages/offline_page_tab_helper_unittest.cc b/chrome/browser/offline_pages/offline_page_tab_helper_unittest.cc index c79b664d..572efc7 100644 --- a/chrome/browser/offline_pages/offline_page_tab_helper_unittest.cc +++ b/chrome/browser/offline_pages/offline_page_tab_helper_unittest.cc @@ -393,5 +393,68 @@ EXPECT_EQ(tab_helper()->GetOfflinePreviewItem(), offline_page_item); } +TEST_F(OfflinePageTabHelperTest, UntrustedMhtmlFileLoadOriginNotSpoofed) { + const GURL kFileUrl("file:///sdcard/Download/GoogleSignIn.mhtml"); + const GURL kSpoofedUrl("https://accounts.google.com/signin"); + + tab_helper()->SetCurrentTargetFrameForTest( + web_contents()->GetPrimaryMainFrame()); + + CreateNavigationSimulator(kFileUrl); + navigation_simulator()->Start(); + + navigation_simulator()->SetContentsMimeType("multipart/related"); + tab_helper()->NotifyMhtmlPageLoadAttempted( + MHTMLLoadResult::kSuccess, kSpoofedUrl, kTestMhtmlCreationTime); + navigation_simulator()->Commit(); + + ASSERT_NE(nullptr, tab_helper()->offline_page()); + EXPECT_EQ(OfflinePageTrustedState::UNTRUSTED, tab_helper()->trusted_state()); + EXPECT_FALSE(tab_helper()->IsShowingTrustedOfflinePage()); + EXPECT_EQ(kTestMhtmlCreationTime, + tab_helper()->offline_page()->creation_time); + // Offline page URL must reflect the actual navigated file URL, preventing + // origin spoofing in Page Info and open-online reload. + EXPECT_EQ(kFileUrl, tab_helper()->offline_page()->url); +} + +TEST_F(OfflinePageTabHelperTest, HttpMhtmlLoadMismatchedUrlRejected) { + const GURL kNavigatedUrl("https://example.com/page.html"); + const GURL kMismatchedUrl("https://evil.com/phish.html"); + + tab_helper()->SetCurrentTargetFrameForTest( + web_contents()->GetPrimaryMainFrame()); + + CreateNavigationSimulator(kNavigatedUrl); + navigation_simulator()->Start(); + + navigation_simulator()->SetContentsMimeType("multipart/related"); + tab_helper()->NotifyMhtmlPageLoadAttempted( + MHTMLLoadResult::kSuccess, kMismatchedUrl, kTestMhtmlCreationTime); + navigation_simulator()->Commit(); + + // Mismatched renderer-reported URL for HTTP/HTTPS navigation must be + // rejected. + EXPECT_EQ(nullptr, tab_helper()->offline_page()); +} + +TEST_F(OfflinePageTabHelperTest, HttpMhtmlLoadMatchingUrlAccepted) { + const GURL kNavigatedUrl("https://example.com/page.html"); + + tab_helper()->SetCurrentTargetFrameForTest( + web_contents()->GetPrimaryMainFrame()); + + CreateNavigationSimulator(kNavigatedUrl); + navigation_simulator()->Start(); + + navigation_simulator()->SetContentsMimeType("multipart/related"); + tab_helper()->NotifyMhtmlPageLoadAttempted( + MHTMLLoadResult::kSuccess, kNavigatedUrl, kTestMhtmlCreationTime); + navigation_simulator()->Commit(); + + ASSERT_NE(nullptr, tab_helper()->offline_page()); + EXPECT_EQ(kNavigatedUrl, tab_helper()->offline_page()->url); +} + } // namespace } // namespace offline_pages
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/chrome/browser/offline_pages/offline_page_tab_helper_unittest.cc b/chrome/browser/offline_pages/offline_page_tab_helper_unittest.cc
index c79b664d..572efc7 100644
--- a/chrome/browser/offline_pages/offline_page_tab_helper_unittest.cc
+++ b/chrome/browser/offline_pages/offline_page_tab_helper_unittest.cc
@@ -393,5 +393,68 @@
EXPECT_EQ(tab_helper()->GetOfflinePreviewItem(), offline_page_item);
}
+TEST_F(OfflinePageTabHelperTest, UntrustedMhtmlFileLoadOriginNotSpoofed) {
+ const GURL kFileUrl("file:///sdcard/Download/GoogleSignIn.mhtml");
+ const GURL kSpoofedUrl("https://accounts.google.com/signin");
+
+ tab_helper()->SetCurrentTargetFrameForTest(
+ web_contents()->GetPrimaryMainFrame());
+
+ CreateNavigationSimulator(kFileUrl);
+ navigation_simulator()->Start();
+
+ navigation_simulator()->SetContentsMimeType("multipart/related");
+ tab_helper()->NotifyMhtmlPageLoadAttempted(
+ MHTMLLoadResult::kSuccess, kSpoofedUrl, kTestMhtmlCreationTime);
+ navigation_simulator()->Commit();
+
+ ASSERT_NE(nullptr, tab_helper()->offline_page());
+ EXPECT_EQ(OfflinePageTrustedState::UNTRUSTED, tab_helper()->trusted_state());
+ EXPECT_FALSE(tab_helper()->IsShowingTrustedOfflinePage());
+ EXPECT_EQ(kTestMhtmlCreationTime,
+ tab_helper()->offline_page()->creation_time);
+ // Offline page URL must reflect the actual navigated file URL, preventing
+ // origin spoofing in Page Info and open-online reload.
+ EXPECT_EQ(kFileUrl, tab_helper()->offline_page()->url);
+}
+
+TEST_F(OfflinePageTabHelperTest, HttpMhtmlLoadMismatchedUrlRejected) {
+ const GURL kNavigatedUrl("https://example.com/page.html");
+ const GURL kMismatchedUrl("https://evil.com/phish.html");
+
+ tab_helper()->SetCurrentTargetFrameForTest(
+ web_contents()->GetPrimaryMainFrame());
+
+ CreateNavigationSimulator(kNavigatedUrl);
+ navigation_simulator()->Start();
+
+ navigation_simulator()->SetContentsMimeType("multipart/related");
+ tab_helper()->NotifyMhtmlPageLoadAttempted(
+ MHTMLLoadResult::kSuccess, kMismatchedUrl, kTestMhtmlCreationTime);
+ navigation_simulator()->Commit();
+
+ // Mismatched renderer-reported URL for HTTP/HTTPS navigation must be
+ // rejected.
+ EXPECT_EQ(nullptr, tab_helper()->offline_page());
+}
+
+TEST_F(OfflinePageTabHelperTest, HttpMhtmlLoadMatchingUrlAccepted) {
+ const GURL kNavigatedUrl("https://example.com/page.html");
+
+ tab_helper()->SetCurrentTargetFrameForTest(
+ web_contents()->GetPrimaryMainFrame());
+
+ CreateNavigationSimulator(kNavigatedUrl);
+ navigation_simulator()->Start();
+
+ navigation_simulator()->SetContentsMimeType("multipart/related");
+ tab_helper()->NotifyMhtmlPageLoadAttempted(
+ MHTMLLoadResult::kSuccess, kNavigatedUrl, kTestMhtmlCreationTime);
+ navigation_simulator()->Commit();
+
+ ASSERT_NE(nullptr, tab_helper()->offline_page());
+ EXPECT_EQ(kNavigatedUrl, tab_helper()->offline_page()->url);
+}
+
} // namespace
} // namespace offline_pages
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page