CVE-2026-91742
Overview
Background
- `PriceNotificationsPriceTrackingMediator`
- the iOS mediator that backs the price-tracking / price-notifications UI and drives navigation on behalf of the user when a tracked item is tapped.
- `navigateToWebpageForURL:disposition:`
- the mediator method that opens a URL taken from a tracked-item’s
entryURLby callingself.webState->OpenURL(...). - Confused deputy
- a trusted component (here the mediator) performing an action with its own elevated authority using an attacker-influenced input, so the attacker borrows that authority.
- `WebState::OpenURL` / `OpenURLParams`
- the
web::WebStatenavigation entry point; the mediator invokes it withis_renderer_initiated=falseandui::PAGE_TRANSITION_GENERATED, marking the navigation as browser-initiated and therefore trusted.
Root Cause Analysis
Before the patch, navigateToWebpageForURL:disposition: passed the tracked item’s entryURL straight into self.webState->OpenURL(...) with no scheme check, and it did so as a browser-initiated navigation (is_renderer_initiated=false). Because the entryURL originates from data associated with a tracked product/bookmark rather than a vetted, freshly-parsed HTTP(S) address, a non-web scheme such as javascript:, chrome://, or file:// could be handed to the navigation stack while wearing the mediator’s browser-initiated trust — a classic confused-deputy condition.
The fix adds if (!URL.SchemeIsHTTPOrHTTPS()) { return; } as the first statement, so any non-HTTP/HTTPS URL early-exits before OpenURL is ever reached; this is a tightening of an input that previously had no restriction.
The patch also adds if (!self.webState) return; in both navigateToWebpageForURL:disposition: and navigateToBookmarks (and makes price_notifications_view_coordinator.mm pass nullptr instead of dereferencing a null webState), converting a raw _webState-> dereference into a guarded self.webState-> call to prevent a null-dereference crash when the web state has gone away. The scheme guard runs before both the null check and the sink, so the ordering of the early-exit path correctly blocks the dangerous navigation.
entryURL into a browser-initiated OpenURL without validating the scheme, letting a non-web URL inherit the mediator’s trust; the fix restricts the sink to HTTP/HTTPS URLs (and null-guards the web state) so only ordinary web navigations reach OpenURL.Attack Path
- Plant a malicious tracked-item URL
An attacker arranges for a price-tracked item whose
entryURLis a non-web scheme (e.g.javascript:alert(1),chrome://version, orfile:///etc/passwd), the exact schemes the added test enumerates as now-blocked. - User invokes navigation from the price-tracking UI
The user taps the tracked item, and the UI calls
navigateToWebpageForItem:, which forwardsitem.entryURLintonavigateToWebpageForURL:disposition:. - Mediator opens the URL with browser-initiated trust
Pre-patch, the method calls
self.webState->OpenURL(...)withis_renderer_initiated=falseandPAGE_TRANSITION_GENERATED, so the non-web URL is dispatched as a trusted navigation rather than an untrusted renderer request. - Elevated-context navigation / script execution
The browser-initiated
javascript:,chrome://, orfile://navigation runs in a more privileged context than a renderer-initiated one would be allowed, potentially executing script or reaching privileged pages/local files (inferred — the patch demonstrates only that the URL reaches/does-not-reachOpenURL, not the resulting execution primitive).
Impact Assessment
entryURL can cause the price-tracking mediator to issue a browser-initiated navigation to a non-web scheme, gaining a confused-deputy navigation (e.g. javascript:/chrome:///file://) in the browser UI context on iOS Chrome. Escalation to actual script execution, access to privileged chrome:// surfaces, or local-file disclosure is (inferred) and depends on downstream handling the patch does not show. No exploited-in-the-wild status or severity rating is provided by the material supplied; the secondary null-webState guard additionally prevents a crash when the web state has been torn down.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator.mm |
modified | |
TestFakeWebStateios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator_unittest.mm |
modified |
Files Changed
ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator_unittest.mmios/chrome/browser/price_notifications/ui_bundled/price_notifications_view_coordinator.mmios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator.mm
Audit Directions
- Unvalidated URL into browser-initiated `OpenURL`Audit every UI mediator/coordinator that calls
WebState::OpenURL(or similar navigation APIs) withis_renderer_initiated=falseon a URL sourced from stored items, bookmarks, notifications, or server data, and confirm aSchemeIsHTTPOrHTTPS()-style scheme allowlist runs first. - Raw web-state dereference without a lifetime guardLook for
_webState->/webState->dereferences (especially through weak pointers) in mediators that outlive their web state, and verify a null check precedes each use. - Attacker-influenceable `entryURL`/item fields feeding navigationTrace where tracked-item, notification, or feed model fields flow into navigation, image, or resource loads, and ensure schemes are constrained at the trust boundary rather than assumed to be web URLs.
Patch
From d1c54bd927b3bfe174efd5327fd75618f57e7113 Mon Sep 17 00:00:00 2001
From: David M <davidjm@google.com>
Date: Mon, 03 Aug 2026 18:42:45 -0700
Subject: [PATCH] Add scheme validation and webState null checks in PriceNotificationsPriceTrackingMediator
Add scheme validation and webState null checks to block non-HTTP/HTTPS
URLs (such as javascript: or chrome://) by adding HTTP/HTTPS URL scheme
validation to navigateToWebpageForURL.
Also add null checks for self.webState before dereferencing it in
navigateToBookmarks and navigateToWebpageForURL.
Bug: 513858387
Change-Id: If847cb64202b428a1d75bdfe7878a213d975fc97
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8175357
Reviewed-by: Daniel White <danieltwhite@google.com>
Commit-Queue: David Maunder <davidjm@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1673027}
---
diff --git a/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator.mm b/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator.mm
index 6015dd4..8949b1a3 100644
--- a/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator.mm
+++ b/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator.mm
@@ -180,7 +180,10 @@
- (void)navigateToBookmarks {
[self.handler hidePriceTrackedItems];
- GURL URL = _webState->GetLastCommittedURL();
+ if (!self.webState) {
+ return;
+ }
+ GURL URL = self.webState->GetLastCommittedURL();
[self.bookmarksHandler showBookmarkInBookmarksUI:URL];
}
@@ -545,6 +548,12 @@
- (void)navigateToWebpageForURL:(const GURL&)URL
disposition:(WindowOpenDisposition)disposition {
+ if (!URL.SchemeIsHTTPOrHTTPS()) {
+ return;
+ }
+ if (!self.webState) {
+ return;
+ }
self.webState->OpenURL(web::WebState::OpenURLParams(
URL, web::Referrer(), disposition, ui::PAGE_TRANSITION_GENERATED,
/*is_renderer_initiated=*/false));
diff --git a/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_view_coordinator.mm b/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_view_coordinator.mm
index 42cc6b6..5acabda 100644
--- a/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_view_coordinator.mm
+++ b/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_view_coordinator.mm
@@ -101,7 +101,7 @@
initWithShoppingService:shoppingService
bookmarkModel:bookmarkModel
imageFetcher:std::move(imageFetcher)
- webState:webState->GetWeakPtr()
+ webState:webState ? webState->GetWeakPtr() : nullptr
pushNotificationService:pushNotificationService];
self.mediator.consumer = self.tableViewController;
self.mediator.presenter = self;
diff --git a/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator_unittest.mm b/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator_unittest.mm
index f025286..912abec 100644
--- a/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator_unittest.mm
+++ b/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator_unittest.mm
@@ -58,6 +58,21 @@
const char kBookmarkTitle[] = "My product title";
constexpr uint64_t kClusterId = 12345L;
+// A FakeWebState that records parameters passed to OpenURL.
+class TestFakeWebState : public web::FakeWebState {
+ public:
+ void OpenURL(const web::WebState::OpenURLParams& params) override {
+ last_open_url_params_ =
+ std::make_unique<web::WebState::OpenURLParams>(params);
+ }
+ web::WebState::OpenURLParams* last_open_url_params() {
+ return last_open_url_params_.get();
+ }
+
+ private:
+ std::unique_ptr<web::WebState::OpenURLParams> last_open_url_params_;
+};
+
PriceInsightsItem* GetPriceInsightsItem() {
PriceInsightsItem* item = [[PriceInsightsItem alloc] init];
item.title = base::SysUTF8ToNSString(kBookmarkTitle);
@@ -137,7 +152,7 @@
browser_list_ = BrowserListFactory::GetForProfile(test_profile);
browser_ = std::make_unique<TestBrowser>(test_profile);
browser_list_->AddBrowser(browser_.get());
- web_state_ = std::make_unique<web::FakeWebState>();
+ web_state_ = std::make_unique<TestFakeWebState>();
std::unique_ptr<web::FakeNavigationManager> navigation_manager =
std::make_unique<web::FakeNavigationManager>();
navigation_manager->AddItem(GURL(kTestUrl), ui::PAGE_TRANSITION_LINK);
@@ -187,7 +202,7 @@
TestProfileManagerIOS profile_manager_;
std::unique_ptr<Browser> browser_;
PriceNotificationsPriceTrackingMediator* mediator_;
- std::unique_ptr<web::FakeWebState> web_state_;
+ std::unique_ptr<TestFakeWebState> web_state_;
raw_ptr<commerce::MockShoppingService> shopping_service_;
raw_ptr<bookmarks::BookmarkModel> bookmark_model_;
raw_ptr<BrowserList> browser_list_;
@@ -526,3 +541,55 @@
EXPECT_OCMOCK_VERIFY(mock_notification_center_);
}
+
+TEST_F(PriceNotificationsPriceTrackingMediatorTest,
+ NavigateToWebpageForURLWithHTTPOrHTTPSAllowed) {
+ PriceNotificationsTableViewItem* item =
+ [[PriceNotificationsTableViewItem alloc] init];
+ item.tracking = YES;
+ item.entryURL = GURL("https://www.merchant.com");
+ [mediator_ navigateToWebpageForItem:item];
+
+ EXPECT_NE(web_state_->last_open_url_params(), nullptr);
+ EXPECT_EQ(web_state_->last_open_url_params()->url,
+ GURL("https://www.merchant.com"));
+}
+
+TEST_F(PriceNotificationsPriceTrackingMediatorTest,
+ NavigateToWebpageForURLWithNonHTTPOrHTTPSBlocked) {
+ PriceNotificationsTableViewItem* item =
+ [[PriceNotificationsTableViewItem alloc] init];
+ item.tracking = YES;
+
+ item.entryURL = GURL("javascript:alert(1)");
+ [mediator_ navigateToWebpageForItem:item];
+ EXPECT_EQ(web_state_->last_open_url_params(), nullptr);
+
+ item.entryURL = GURL("chrome://version");
+ [mediator_ navigateToWebpageForItem:item];
+ EXPECT_EQ(web_state_->last_open_url_params(), nullptr);
+
+ item.entryURL = GURL("file:///etc/passwd");
+ [mediator_ navigateToWebpageForItem:item];
+ EXPECT_EQ(web_state_->last_open_url_params(), nullptr);
+}
+
+TEST_F(PriceNotificationsPriceTrackingMediatorTest,
+ NavigateToWebpageForURLWithNullWebState) {
+ web_state_.reset();
+ PriceNotificationsTableViewItem* item =
+ [[PriceNotificationsTableViewItem alloc] init];
+ item.tracking = YES;
+ item.entryURL = GURL("https://www.merchant.com");
+
+ // Should not crash.
+ [mediator_ navigateToWebpageForItem:item];
+}
+
+TEST_F(PriceNotificationsPriceTrackingMediatorTest,
+ NavigateToBookmarksWithNullWebState) {
+ web_state_.reset();
+
+ // Should not crash.
+ [mediator_ navigateToBookmarks];
+}
Regression Test / PoC
diff --git a/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator_unittest.mm b/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator_unittest.mm
index f025286..912abec 100644
--- a/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator_unittest.mm
+++ b/ios/chrome/browser/price_notifications/ui_bundled/price_notifications_price_tracking_mediator_unittest.mm
@@ -58,6 +58,21 @@
const char kBookmarkTitle[] = "My product title";
constexpr uint64_t kClusterId = 12345L;
+// A FakeWebState that records parameters passed to OpenURL.
+class TestFakeWebState : public web::FakeWebState {
+ public:
+ void OpenURL(const web::WebState::OpenURLParams& params) override {
+ last_open_url_params_ =
+ std::make_unique<web::WebState::OpenURLParams>(params);
+ }
+ web::WebState::OpenURLParams* last_open_url_params() {
+ return last_open_url_params_.get();
+ }
+
+ private:
+ std::unique_ptr<web::WebState::OpenURLParams> last_open_url_params_;
+};
+
PriceInsightsItem* GetPriceInsightsItem() {
PriceInsightsItem* item = [[PriceInsightsItem alloc] init];
item.title = base::SysUTF8ToNSString(kBookmarkTitle);
@@ -137,7 +152,7 @@
browser_list_ = BrowserListFactory::GetForProfile(test_profile);
browser_ = std::make_unique<TestBrowser>(test_profile);
browser_list_->AddBrowser(browser_.get());
- web_state_ = std::make_unique<web::FakeWebState>();
+ web_state_ = std::make_unique<TestFakeWebState>();
std::unique_ptr<web::FakeNavigationManager> navigation_manager =
std::make_unique<web::FakeNavigationManager>();
navigation_manager->AddItem(GURL(kTestUrl), ui::PAGE_TRANSITION_LINK);
@@ -187,7 +202,7 @@
TestProfileManagerIOS profile_manager_;
std::unique_ptr<Browser> browser_;
PriceNotificationsPriceTrackingMediator* mediator_;
- std::unique_ptr<web::FakeWebState> web_state_;
+ std::unique_ptr<TestFakeWebState> web_state_;
raw_ptr<commerce::MockShoppingService> shopping_service_;
raw_ptr<bookmarks::BookmarkModel> bookmark_model_;
raw_ptr<BrowserList> browser_list_;
@@ -526,3 +541,55 @@
EXPECT_OCMOCK_VERIFY(mock_notification_center_);
}
+
+TEST_F(PriceNotificationsPriceTrackingMediatorTest,
+ NavigateToWebpageForURLWithHTTPOrHTTPSAllowed) {
+ PriceNotificationsTableViewItem* item =
+ [[PriceNotificationsTableViewItem alloc] init];
+ item.tracking = YES;
+ item.entryURL = GURL("https://www.merchant.com");
+ [mediator_ navigateToWebpageForItem:item];
+
+ EXPECT_NE(web_state_->last_open_url_params(), nullptr);
+ EXPECT_EQ(web_state_->last_open_url_params()->url,
+ GURL("https://www.merchant.com"));
+}
+
+TEST_F(PriceNotificationsPriceTrackingMediatorTest,
+ NavigateToWebpageForURLWithNonHTTPOrHTTPSBlocked) {
+ PriceNotificationsTableViewItem* item =
+ [[PriceNotificationsTableViewItem alloc] init];
+ item.tracking = YES;
+
+ item.entryURL = GURL("javascript:alert(1)");
+ [mediator_ navigateToWebpageForItem:item];
+ EXPECT_EQ(web_state_->last_open_url_params(), nullptr);
+
+ item.entryURL = GURL("chrome://version");
+ [mediator_ navigateToWebpageForItem:item];
+ EXPECT_EQ(web_state_->last_open_url_params(), nullptr);
+
+ item.entryURL = GURL("file:///etc/passwd");
+ [mediator_ navigateToWebpageForItem:item];
+ EXPECT_EQ(web_state_->last_open_url_params(), nullptr);
+}
+
+TEST_F(PriceNotificationsPriceTrackingMediatorTest,
+ NavigateToWebpageForURLWithNullWebState) {
+ web_state_.reset();
+ PriceNotificationsTableViewItem* item =
+ [[PriceNotificationsTableViewItem alloc] init];
+ item.tracking = YES;
+ item.entryURL = GURL("https://www.merchant.com");
+
+ // Should not crash.
+ [mediator_ navigateToWebpageForItem:item];
+}
+
+TEST_F(PriceNotificationsPriceTrackingMediatorTest,
+ NavigateToBookmarksWithNullWebState) {
+ web_state_.reset();
+
+ // Should not crash.
+ [mediator_ navigateToBookmarks];
+}