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 <carlosk@google.com>
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 <harringtond@chromium.org>
Auto-Submit: Carlos Knippschild <carlosk@chromium.org>
Commit-Queue: Dan H <harringtond@chromium.org>
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