CVE-2026-17789
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
FakeContentNotificationServiceios/chrome/browser/content_notification/model/content_notification_client_unittest.mm |
modified | |
ifios/chrome/browser/content_notification/model/content_notification_client_unittest.mm |
modified | |
ContentNotificationClientTestios/chrome/browser/content_notification/model/content_notification_client_unittest.mm |
modified | |
ContentNotificationClientTestios/chrome/browser/content_notification/model/content_notification_client_unittest.mm |
modified | |
TEST_Fios/chrome/browser/content_notification/model/content_notification_client_unittest.mm |
modified |
Files Changed
ios/chrome/browser/content_notification/model/BUILD.gnios/chrome/browser/content_notification/model/content_notification_client.mmios/chrome/browser/content_notification/model/content_notification_client_unittest.mm
Patch
From b752b1f06c1590497c16c0d9c1afec84ca6b76b2 Mon Sep 17 00:00:00 2001
From: Guillaume Jenkins <gujen@google.com>
Date: Wed, 03 Jun 2026 12:37:59 -0700
Subject: [PATCH] [iOS][ContentNotifications] Validate destination URL scheme
The Content Notification client extracts a destination URL from push
notification payloads and initiates a trusted navigation when the user
interacts with the notification. This CL introduces scheme validation in
ContentNotificationClient before navigating, ensuring that only valid
HTTP(S) schemes are loaded in the browser.
Fixed: 513855922
Change-Id: I1c7bcfe1e564c175eea9574800a8065cc0036ab3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7887734
Reviewed-by: Scott Yoder <scottyoder@google.com>
Auto-Submit: Guillaume Jenkins <gujen@google.com>
Commit-Queue: Scott Yoder <scottyoder@google.com>
Cr-Commit-Position: refs/heads/main@{#1641117}
---
diff --git a/ios/chrome/browser/content_notification/model/BUILD.gn b/ios/chrome/browser/content_notification/model/BUILD.gn
index e2e6c00..2cb07c2 100644
--- a/ios/chrome/browser/content_notification/model/BUILD.gn
+++ b/ios/chrome/browser/content_notification/model/BUILD.gn
@@ -113,6 +113,7 @@
deps = [
":constants",
":content_notification_client",
+ ":content_notification_service",
":content_notification_service_factory",
"//base",
"//base/test:test_support",
diff --git a/ios/chrome/browser/content_notification/model/content_notification_client.mm b/ios/chrome/browser/content_notification/model/content_notification_client.mm
index 5c2a2c3..daa3182 100644
--- a/ios/chrome/browser/content_notification/model/content_notification_client.mm
+++ b/ios/chrome/browser/content_notification/model/content_notification_client.mm
@@ -100,7 +100,7 @@
kContentNotificationActionHistogramName,
NotificationActionType::kNotificationActionTypeOpened);
const GURL& url = contentNotificationService->GetDestinationUrl(payload);
- if (url.is_empty()) {
+ if (url.is_empty() || !url.is_valid() || !url.SchemeIsHTTPOrHTTPS()) {
base::UmaHistogramBoolean("ContentNotifications.OpenURLAction.HasURL",
false);
return true;
diff --git a/ios/chrome/browser/content_notification/model/content_notification_client_unittest.mm b/ios/chrome/browser/content_notification/model/content_notification_client_unittest.mm
index 26494715..1b9ce0d 100644
--- a/ios/chrome/browser/content_notification/model/content_notification_client_unittest.mm
+++ b/ios/chrome/browser/content_notification/model/content_notification_client_unittest.mm
@@ -6,9 +6,12 @@
#import <UserNotifications/UserNotifications.h>
+#import "base/strings/sys_string_conversions.h"
#import "base/test/metrics/histogram_tester.h"
#import "base/threading/thread_restrictions.h"
#import "components/prefs/scoped_user_pref_update.h"
+#import "ios/chrome/browser/content_notification/model/content_notification_service.h"
+#import "ios/chrome/browser/content_notification/model/content_notification_service_factory.h"
#import "ios/chrome/browser/default_browser/model/promo_source.h"
#import "ios/chrome/browser/default_browser/model/utils.h"
#import "ios/chrome/browser/default_browser/model/utils_test_support.h"
@@ -24,6 +27,7 @@
#import "ios/chrome/browser/shared/model/profile/test/test_profile_manager_ios.h"
#import "ios/chrome/browser/shared/public/commands/browser_coordinator_commands.h"
#import "ios/chrome/browser/shared/public/commands/command_dispatcher.h"
+#import "ios/chrome/browser/shared/public/commands/open_new_tab_command.h"
#import "ios/chrome/browser/shared/public/commands/scene_commands.h"
#import "ios/chrome/browser/shared/public/commands/settings_commands.h"
#import "ios/chrome/test/ios_chrome_scoped_testing_local_state.h"
@@ -34,11 +38,46 @@
#import "third_party/ocmock/OCMock/OCMock.h"
#import "third_party/ocmock/gtest_support.h"
+namespace {
+
+class FakeContentNotificationService : public ContentNotificationService {
+ public:
+ FakeContentNotificationService() = default;
+ ~FakeContentNotificationService() override = default;
+
+ GURL GetDestinationUrl(NSDictionary<NSString*, id>* payload) override {
+ NSString* url_string = payload[@"destination_url"];
+ if (url_string) {
+ return GURL(base::SysNSStringToUTF8(url_string));
+ }
+ return GURL::EmptyGURL();
+ }
+
+ NSDictionary<NSString*, NSString*>* GetFeedbackPayload(
+ NSDictionary<NSString*, id>* payload) override {
+ return nil;
+ }
+
+ void SendNAUForConfiguration(
+ ContentNotificationNAUConfiguration* configuration) override {}
+};
+
+std::unique_ptr<KeyedService> CreateFakeContentNotificationService(
+ ProfileIOS* profile) {
+ return std::make_unique<FakeContentNotificationService>();
+}
+
+} // namespace
+
class ContentNotificationClientTest : public PlatformTest {
protected:
ContentNotificationClientTest() {
+ TestProfileIOS::Builder builder;
+ builder.AddTestingFactory(
+ ContentNotificationServiceFactory::GetInstance(),
+ base::BindRepeating(&CreateFakeContentNotificationService));
ProfileIOS* profile =
- profile_manager_.AddProfileWithBuilder(TestProfileIOS::Builder());
+ profile_manager_.AddProfileWithBuilder(std::move(builder));
BrowserList* list = BrowserListFactory::GetForProfile(profile);
mock_scene_state_ = OCMClassMock([SceneState class]);
OCMStub([mock_scene_state_ activationLevel])
@@ -82,6 +121,24 @@
return request;
}
+ id CreateMockResponse(NSString* action_identifier,
+ NSDictionary<NSString*, id>* payload) {
+ id mock_response = OCMClassMock([UNNotificationResponse class]);
+ id mock_notification = OCMClassMock([UNNotification class]);
+ id mock_request = OCMClassMock([UNNotificationRequest class]);
+ id mock_content = OCMClassMock([UNNotificationContent class]);
+
+ OCMStub([mock_response notification]).andReturn(mock_notification);
+ OCMStub([mock_notification request]).andReturn(mock_request);
+ OCMStub([mock_request content]).andReturn(mock_content);
+ OCMStub([mock_response actionIdentifier]).andReturn(action_identifier);
+ OCMStub([mock_content categoryIdentifier])
+ .andReturn(kContentNotificationFeedbackCategoryIdentifier);
+ OCMStub([mock_content userInfo]).andReturn(payload);
+
+ return mock_response;
+ }
+
web::WebTaskEnvironment task_environment_;
IOSChromeScopedTestingLocalState scoped_testing_local_state_;
TestProfileManagerIOS profile_manager_;
@@ -108,3 +165,73 @@
EXPECT_EQ(secondaryActions.firstObject.identifier,
kContentNotificationFeedbackCategoryIdentifier);
}
+
+// Tests that the client correctly loads a valid HTTP URL and records the
+// appropriate histograms.
+TEST_F(ContentNotificationClientTest, HandleNotificationInteractionValidURL) {
+ base::HistogramTester histogram_tester;
+ id mock_scene_commands = OCMProtocolMock(@protocol(SceneCommands));
+ [browser_->GetCommandDispatcher()
+ startDispatchingToTarget:mock_scene_commands
+ forProtocol:@protocol(SceneCommands)];
+
+ OCMExpect([mock_scene_commands
+ openURLInNewTab:[OCMArg checkWithBlock:^BOOL(OpenNewTabCommand* command) {
+ return command.URL == GURL("http://www.example.com/");
+ }]]);
+
+ NSDictionary<NSString*, id>* payload =
+ @{@"destination_url" : @"http://www.example.com/"};
+ id mock_response =
+ CreateMockResponse(UNNotificationDefaultActionIdentifier, payload);
+
+ EXPECT_TRUE(client_->HandleNotificationInteraction(mock_response));
+ EXPECT_OCMOCK_VERIFY(mock_scene_commands);
+
+ histogram_tester.ExpectUniqueSample(
+ "ContentNotifications.OpenURLAction.HasURL", true, 1);
+}
+
+// Tests that the client filters out invalid/non-HTTP/HTTPS URLs (like
+// chrome://) and records HasURL as false.
+TEST_F(ContentNotificationClientTest, HandleNotificationInteractionChromeURL) {
+ base::HistogramTester histogram_tester;
+ id mock_scene_commands = OCMProtocolMock(@protocol(SceneCommands));
+ [browser_->GetCommandDispatcher()
+ startDispatchingToTarget:mock_scene_commands
+ forProtocol:@protocol(SceneCommands)];
+
+ OCMReject([mock_scene_commands openURLInNewTab:[OCMArg any]]);
+
+ NSDictionary<NSString*, id>* payload =
+ @{@"destination_url" : @"chrome://settings"};
+ id mock_response =
+ CreateMockResponse(UNNotificationDefaultActionIdentifier, payload);
+
+ EXPECT_TRUE(client_->HandleNotificationInteraction(mock_response));
+ EXPECT_OCMOCK_VERIFY(mock_scene_commands);
+
+ histogram_tester.ExpectUniqueSample(
+ "ContentNotifications.OpenURLAction.HasURL", false, 1);
+}
+
+// Tests that the client filters out invalid URLs and records HasURL as false.
+TEST_F(ContentNotificationClientTest, HandleNotificationInteractionInvalidURL) {
+ base::HistogramTester histogram_tester;
Regression Test / PoC
diff --git a/ios/chrome/browser/content_notification/model/content_notification_client_unittest.mm b/ios/chrome/browser/content_notification/model/content_notification_client_unittest.mm
index 26494715..1b9ce0d 100644
--- a/ios/chrome/browser/content_notification/model/content_notification_client_unittest.mm
+++ b/ios/chrome/browser/content_notification/model/content_notification_client_unittest.mm
@@ -6,9 +6,12 @@
#import <UserNotifications/UserNotifications.h>
+#import "base/strings/sys_string_conversions.h"
#import "base/test/metrics/histogram_tester.h"
#import "base/threading/thread_restrictions.h"
#import "components/prefs/scoped_user_pref_update.h"
+#import "ios/chrome/browser/content_notification/model/content_notification_service.h"
+#import "ios/chrome/browser/content_notification/model/content_notification_service_factory.h"
#import "ios/chrome/browser/default_browser/model/promo_source.h"
#import "ios/chrome/browser/default_browser/model/utils.h"
#import "ios/chrome/browser/default_browser/model/utils_test_support.h"
@@ -24,6 +27,7 @@
#import "ios/chrome/browser/shared/model/profile/test/test_profile_manager_ios.h"
#import "ios/chrome/browser/shared/public/commands/browser_coordinator_commands.h"
#import "ios/chrome/browser/shared/public/commands/command_dispatcher.h"
+#import "ios/chrome/browser/shared/public/commands/open_new_tab_command.h"
#import "ios/chrome/browser/shared/public/commands/scene_commands.h"
#import "ios/chrome/browser/shared/public/commands/settings_commands.h"
#import "ios/chrome/test/ios_chrome_scoped_testing_local_state.h"
@@ -34,11 +38,46 @@
#import "third_party/ocmock/OCMock/OCMock.h"
#import "third_party/ocmock/gtest_support.h"
+namespace {
+
+class FakeContentNotificationService : public ContentNotificationService {
+ public:
+ FakeContentNotificationService() = default;
+ ~FakeContentNotificationService() override = default;
+
+ GURL GetDestinationUrl(NSDictionary<NSString*, id>* payload) override {
+ NSString* url_string = payload[@"destination_url"];
+ if (url_string) {
+ return GURL(base::SysNSStringToUTF8(url_string));
+ }
+ return GURL::EmptyGURL();
+ }
+
+ NSDictionary<NSString*, NSString*>* GetFeedbackPayload(
+ NSDictionary<NSString*, id>* payload) override {
+ return nil;
+ }
+
+ void SendNAUForConfiguration(
+ ContentNotificationNAUConfiguration* configuration) override {}
+};
+
+std::unique_ptr<KeyedService> CreateFakeContentNotificationService(
+ ProfileIOS* profile) {
+ return std::make_unique<FakeContentNotificationService>();
+}
+
+} // namespace
+
class ContentNotificationClientTest : public PlatformTest {
protected:
ContentNotificationClientTest() {
+ TestProfileIOS::Builder builder;
+ builder.AddTestingFactory(
+ ContentNotificationServiceFactory::GetInstance(),
+ base::BindRepeating(&CreateFakeContentNotificationService));
ProfileIOS* profile =
- profile_manager_.AddProfileWithBuilder(TestProfileIOS::Builder());
+ profile_manager_.AddProfileWithBuilder(std::move(builder));
BrowserList* list = BrowserListFactory::GetForProfile(profile);
mock_scene_state_ = OCMClassMock([SceneState class]);
OCMStub([mock_scene_state_ activationLevel])
@@ -82,6 +121,24 @@
return request;
}
+ id CreateMockResponse(NSString* action_identifier,
+ NSDictionary<NSString*, id>* payload) {
+ id mock_response = OCMClassMock([UNNotificationResponse class]);
+ id mock_notification = OCMClassMock([UNNotification class]);
+ id mock_request = OCMClassMock([UNNotificationRequest class]);
+ id mock_content = OCMClassMock([UNNotificationContent class]);
+
+ OCMStub([mock_response notification]).andReturn(mock_notification);
+ OCMStub([mock_notification request]).andReturn(mock_request);
+ OCMStub([mock_request content]).andReturn(mock_content);
+ OCMStub([mock_response actionIdentifier]).andReturn(action_identifier);
+ OCMStub([mock_content categoryIdentifier])
+ .andReturn(kContentNotificationFeedbackCategoryIdentifier);
+ OCMStub([mock_content userInfo]).andReturn(payload);
+
+ return mock_response;
+ }
+
web::WebTaskEnvironment task_environment_;
IOSChromeScopedTestingLocalState scoped_testing_local_state_;
TestProfileManagerIOS profile_manager_;
@@ -108,3 +165,73 @@
EXPECT_EQ(secondaryActions.firstObject.identifier,
kContentNotificationFeedbackCategoryIdentifier);
}
+
+// Tests that the client correctly loads a valid HTTP URL and records the
+// appropriate histograms.
+TEST_F(ContentNotificationClientTest, HandleNotificationInteractionValidURL) {
+ base::HistogramTester histogram_tester;
+ id mock_scene_commands = OCMProtocolMock(@protocol(SceneCommands));
+ [browser_->GetCommandDispatcher()
+ startDispatchingToTarget:mock_scene_commands
+ forProtocol:@protocol(SceneCommands)];
+
+ OCMExpect([mock_scene_commands
+ openURLInNewTab:[OCMArg checkWithBlock:^BOOL(OpenNewTabCommand* command) {
+ return command.URL == GURL("http://www.example.com/");
+ }]]);
+
+ NSDictionary<NSString*, id>* payload =
+ @{@"destination_url" : @"http://www.example.com/"};
+ id mock_response =
+ CreateMockResponse(UNNotificationDefaultActionIdentifier, payload);
+
+ EXPECT_TRUE(client_->HandleNotificationInteraction(mock_response));
+ EXPECT_OCMOCK_VERIFY(mock_scene_commands);
+
+ histogram_tester.ExpectUniqueSample(
+ "ContentNotifications.OpenURLAction.HasURL", true, 1);
+}
+
+// Tests that the client filters out invalid/non-HTTP/HTTPS URLs (like
+// chrome://) and records HasURL as false.
+TEST_F(ContentNotificationClientTest, HandleNotificationInteractionChromeURL) {
+ base::HistogramTester histogram_tester;
+ id mock_scene_commands = OCMProtocolMock(@protocol(SceneCommands));
+ [browser_->GetCommandDispatcher()
+ startDispatchingToTarget:mock_scene_commands
+ forProtocol:@protocol(SceneCommands)];
+
+ OCMReject([mock_scene_commands openURLInNewTab:[OCMArg any]]);
+
+ NSDictionary<NSString*, id>* payload =
+ @{@"destination_url" : @"chrome://settings"};
+ id mock_response =
+ CreateMockResponse(UNNotificationDefaultActionIdentifier, payload);
+
+ EXPECT_TRUE(client_->HandleNotificationInteraction(mock_response));
+ EXPECT_OCMOCK_VERIFY(mock_scene_commands);
+
+ histogram_tester.ExpectUniqueSample(
+ "ContentNotifications.OpenURLAction.HasURL", false, 1);
+}
+
+// Tests that the client filters out invalid URLs and records HasURL as false.
+TEST_F(ContentNotificationClientTest, HandleNotificationInteractionInvalidURL) {
+ base::HistogramTester histogram_tester;
+ id mock_scene_commands = OCMProtocolMock(@protocol(SceneCommands));
+ [browser_->GetCommandDispatcher()
+ startDispatchingToTarget:mock_scene_commands
+ forProtocol:@protocol(SceneCommands)];
+
+ OCMReject([mock_scene_commands openURLInNewTab:[OCMArg any]]);
+
+ NSDictionary<NSString*, id>* payload = @{@"destination_url" : @"invalid_url"};
+ id mock_response =
+ CreateMockResponse(UNNotificationDefaultActionIdentifier, payload);
+
+ EXPECT_TRUE(client_->HandleNotificationInteraction(mock_response));
+ EXPECT_OCMOCK_VERIFY(mock_scene_commands);
+
+ histogram_tester.ExpectUniqueSample(
+ "ContentNotifications.OpenURLAction.HasURL", false, 1);
+}
Original Bug Report
Potential arbitrary privileged URL navigation via Content Notification interaction on iOS
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: The Content Notification system on iOS fails to validate the scheme of URLs extracted from push payloads before initiating a navigation. This could allow an attacker to trigger browser-initiated navigations to privileged pages like chrome://.
Affected files:
ios/chrome/browser/content_notification/model/content_notification_client.mm
Estimated timestamp from git blame: 2024-04-03
A potential security bypass exists in the ContentNotificationClient component on iOS. When a user interacts with a content notification (e.g., tapping the notification body), the browser extracts a destination URL from the push payload and opens it in a new tab without sufficient scheme validation.
Technical Details
In ios/chrome/browser/content_notification/model/content_notification_client.mm, the method HandleNotificationInteraction processes notification responses. It retrieves a destination URL from the notification’s userInfo dictionary via the ContentNotificationService:
// ios/chrome/browser/content_notification/model/content_notification_client.mm:102
const GURL& url = contentNotificationService->GetDestinationUrl(payload);
if (url.is_empty()) {
// ...
return true;
}
// ...
LoadUrlInNewTab(url);
The implementation only checks if the URL is empty but fails to verify that the scheme is http or https.
The subsequent call to LoadUrlInNewTab (inherited from PushNotificationClient) utilizes the [OpenNewTabCommand commandWithURLFromChrome:url] initializer. This constructor sets an internal fromChrome flag to YES and leads to a navigation with the ui::PAGE_TRANSITION_TYPED transition type.
In the iOS web layer, specifically within CRWWebRequestController and CRWWKNavigationHandler, navigations that are browser-initiated (no opener) and use the TYPED transition are considered trusted. This trust allows them to bypass the security policies that normally prevent arbitrary web content from navigating to app-specific privileged URLs, such as chrome://settings or chrome://inspect.
Potential Attack Scenario
While our analysis is based on code review and we have not executed a live proof-of-concept, the following steps describe a potential attack path:
- An attacker crafts a malicious push notification payload containing a privileged URL (e.g.,
chrome://settings) in the destination field. - The attacker sets the notification’s category to
FEEDBACK_IDENTIFIERto ensure it is routed to theContentNotificationClient. - If the push notification is delivered to a user’s device and the user interacts with it, the browser would invoke the vulnerable code path.
- The browser would then initiate a privileged navigation, potentially exposing sensitive internal interfaces or settings to the context of the new tab.
Suggested Fix
Validate the URL scheme in ios/chrome/browser/content_notification/model/content_notification_client.mm before proceeding with the navigation. Access should be restricted to standard web schemes unless a specific internal use case is documented and safe.
if (url.is_empty() || !url.is_valid() || !url.SchemeIsHTTPOrHTTPS()) {
return true;
}
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.