CVE-2026-95276
Overview
Background
- New Tab Page (NTP) background service
- The
NtpBackgroundServicefetches collections of wallpaper images (and their previews/thumbnails) from a Google-hosted endpoint and exposes them to the NTP UI so the user can pick a background. - `AddOptionsToImageURL`
- A helper in
components/themes/ntp_background_data.ccthat appends image-resize query options (e.g.=w100) to a server-provided image URL and returns it as aGURL. - `GURL::SchemeIsHTTPOrHTTPS`
- A
GURLpredicate that returns true only when the parsed URL uses thehttp:orhttps:scheme, and false for schemes such asdata:,chrome:,file:, orjavascript:. - Collections/images proto responses
GetCollectionsResponseandGetImagesInCollectionResponseare protobuf messages returned by the backend whoseimage_url/previewfields are strings fed intoAddOptionsToImageURL.
Root Cause Analysis
Before the patch, AddOptionsToImageURL took the server-supplied image_url string, optionally appended image_options, and returned GURL(...) of the concatenation with no scheme validation whatsoever. Because the string came directly from the parsed GetCollectionsResponse/GetImagesInCollectionResponse fields, any scheme the backend (or anything able to influence that response) placed in the field — data:, chrome:, file:, javascript: — was accepted verbatim and stored as an NTP image_url, preview_image_url, or thumbnail_image_url. The violated invariant is that a background/preview image URL should only ever be a remote HTTP(S) resource; the function trusted its input to already satisfy this.
The fix constructs the GURL and then calls url.SchemeIsHTTPOrHTTPS(), returning an empty GURL() on the early-exit path whenever the scheme is anything other than HTTP or HTTPS, so non-web-schemed URLs are dropped before they reach any consumer. This tightens validation: the pre-patch condition accepted all schemes, the post-patch condition accepts only HTTP(S).
GURL from it without checking its scheme; the fix adds an explicit SchemeIsHTTPOrHTTPS gate that collapses any non-HTTP(S) URL to an empty GURL.Attack Path
- Poison the collections response
An attacker who can control or tamper with the NTP background service response (e.g. a compromised/abused backend or a network position) sets a collection preview or image
image_urlto a non-web scheme such asdata:image/png;base64,AAAA=,chrome://favicon2/?pageUrl=x, orfile:///etc/passwd. - URL passes through unvalidated
FetchCollectionInfo/FetchCollectionImageInfoparse the proto and hand the raw string toAddOptionsToImageURL, which pre-patch returned aGURLof that string unchanged. - Non-HTTP URL is stored as image source
The tainted
GURLis retained as apreview_image_url,thumbnail_image_url, orimage_urlin the collection/image info exposed to the NTP. - NTP consumes the attacker-controlled scheme
The NTP surface then uses that URL as a background/preview/thumbnail image source, causing the browser to act on a
data:/chrome:/file:/javascript:URL in that context. (inferred) - Escalation via privileged surface Depending on how the URL is loaded, this could enable local-resource reference, exfiltration, or abuse of the privileged NTP/WebUI context. (inferred)
Impact Assessment
data:, chrome:, file:, javascript:) to be accepted as NTP image sources instead of only remote web images, with the precondition of controlling or tampering with that server response. The concrete downstream effect — information disclosure, local-file reference, or abuse of the privileged NTP context — depends on the image consumer and is not demonstrated by the patch itself (inferred). The advisory classifies this as improper input validation / logic error in Themes; it states no exploited-in-the-wild status.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcomponents/themes/ntp_background_service_unittest.cc |
modified |
Files Changed
components/themes/ntp_background_data.cccomponents/themes/ntp_background_service_unittest.cccomponents/themes/ntp_background_data.h
Audit Directions
- Unvalidated server-string-to-URL conversionsLook for other spots that build a
GURLfrom backend/proto/JSON string fields and use it as a resource source without aSchemeIsHTTPOrHTTPS(or allowlist) check. - NTP/WebUI-facing resource fieldsAudit the remaining
image_url,preview, andthumbnailhandling inNtpBackgroundServiceand related theme/customization code to confirm every externally-sourced URL is scheme-validated before storage or rendering. - Trust boundary on "trusted" backendsReview code that assumes a Google-hosted response is inherently safe; treat all response-derived URLs and scheme fields as attacker-influenced input requiring explicit validation.
Patch
From e4e2d27bf745949b28ab4fb7964ae855c263db85 Mon Sep 17 00:00:00 2001 From: Dhruv Kathpalia <[email protected]> Date: Wed, 26 Aug 2026 19:03:43 -0700 Subject: [PATCH] [ntp] Require HTTP or HTTPS scheme for NTP background image URLs. This change updates AddOptionsToImageURL to validate that the generated URL is valid and uses an HTTP or HTTPS scheme. If the URL is invalid or uses an unsupported scheme (such as data:, chrome:, or file:), it now returns an empty GURL. Unit tests have been added to verify this behavior. Fixed: b:517730821 Change-Id: Ibb3a81949bb96ecc594ddc721c186730d5380ee9 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8295680 Auto-Submit: Dhruv Kathpalia <[email protected]> Commit-Queue: Dhruv Kathpalia <[email protected]> Commit-Queue: Tibor Goldschwendt <[email protected]> Reviewed-by: Tibor Goldschwendt <[email protected]> Cr-Commit-Position: refs/heads/main@{#1686861} --- diff --git a/components/themes/ntp_background_data.cc b/components/themes/ntp_background_data.cc index 6dcdfd9..a201d0e 100644 --- a/components/themes/ntp_background_data.cc +++ b/components/themes/ntp_background_data.cc @@ -56,9 +56,13 @@ GURL AddOptionsToImageURL(const std::string& image_url, const std::string& image_options) { - return GURL(image_url + ((image_url.find('=') == std::string::npos) - ? image_options - : std::string(""))); + GURL url(image_url + ((image_url.find('=') == std::string::npos) + ? image_options + : std::string(""))); + if (!url.SchemeIsHTTPOrHTTPS()) { + return GURL(); + } + return url; } GURL RemoveOptionsFromImageURL(const std::string& image_url) { diff --git a/components/themes/ntp_background_data.h b/components/themes/ntp_background_data.h index 9f6821ea..f148d0fa 100644 --- a/components/themes/ntp_background_data.h +++ b/components/themes/ntp_background_data.h @@ -41,6 +41,7 @@ // Adds options for resizing an image to its url. // Without options added to the image, it is 512x512. +// Returns an empty GURL if `image_url` is not a valid HTTP(S) URL. // TODO(crbug.com/41408116): Request resolution from service, instead of // setting it here. GURL AddOptionsToImageURL(const std::string& image_url, diff --git a/components/themes/ntp_background_service_unittest.cc b/components/themes/ntp_background_service_unittest.cc index f8b2770..c92795b 100644 --- a/components/themes/ntp_background_service_unittest.cc +++ b/components/themes/ntp_background_service_unittest.cc @@ -258,6 +258,72 @@ EXPECT_EQ(service()->collection_error_info().error_type, ErrorType::NONE); } +TEST_F(NtpBackgroundServiceTest, CollectionsResponseRejectsNonHttpPreviewUrl) { + ntp::background::GetCollectionsResponse response; + for (const std::string& url : + {"data:image/png;base64,AAAA=", "chrome://favicon2/?pageUrl=x", + "file:///etc/passwd", "javascript:alert(1)"}) { + ntp::background::Collection* collection = response.add_collections(); + collection->set_collection_id("id"); + collection->set_collection_name("name"); + collection->add_preview()->set_image_url(url); + } + std::string response_string; + response.SerializeToString(&response_string); + + SetUpResponseWithData(service()->GetCollectionsLoadURLForTesting(), + response_string); + + EXPECT_CALL(observer_, OnCollectionInfoAvailable).Times(1); + service()->FetchCollectionInfo(); + base::RunLoop().RunUntilIdle(); + + ASSERT_EQ(4u, service()->collection_info().size()); + for (const auto& info : service()->collection_info()) { + EXPECT_TRUE(info.preview_image_url.is_empty()) + << info.preview_image_url.possibly_invalid_spec(); + } +} + +TEST_F(NtpBackgroundServiceTest, + CollectionImagesResponseRejectsNonHttpImageUrl) { + ntp::background::Image image; + image.set_asset_id(12345); + image.set_image_url("data:image/png;base64,AAAA="); + ntp::background::GetImagesInCollectionResponse response; + *response.add_images() = image; + std::string response_string; + response.SerializeToString(&response_string); + + SetUpResponseWithData(service()->GetImagesURLForTesting(), response_string); + + EXPECT_CALL(observer_, OnCollectionImagesAvailable).Times(1); + service()->FetchCollectionImageInfo("shapes"); + base::RunLoop().RunUntilIdle(); + + ASSERT_EQ(1u, service()->collection_images().size()); + EXPECT_TRUE(service()->collection_images().at(0).image_url.is_empty()); + EXPECT_TRUE( + service()->collection_images().at(0).thumbnail_image_url.is_empty()); +} + +TEST_F(NtpBackgroundServiceTest, AddOptionsToImageURLRequiresHttpScheme) { + EXPECT_EQ(GURL("https://wallpapers.co/img=w100"), + AddOptionsToImageURL("https://wallpapers.co/img", "=w100")); + EXPECT_EQ(GURL("http://wallpapers.co/img=w100"), + AddOptionsToImageURL("http://wallpapers.co/img", "=w100")); + EXPECT_EQ(GURL("https://wallpapers.co/img=s"), + AddOptionsToImageURL("https://wallpapers.co/img=s", "=w100")); + + EXPECT_TRUE(AddOptionsToImageURL("", "=w100").is_empty()); + EXPECT_TRUE(AddOptionsToImageURL("not a url", "=w100").is_empty()); + EXPECT_TRUE( + AddOptionsToImageURL("data:image/png;base64,AAAA=", "=w100").is_empty()); + EXPECT_TRUE(AddOptionsToImageURL("chrome://favicon2/?pageUrl=x", "=w100") + .is_empty()); + EXPECT_TRUE(AddOptionsToImageURL("file:///etc/passwd", "=w100").is_empty()); +} + TEST_F(NtpBackgroundServiceTest, BrokenCollectionPreviewImageHasNoReplacement) { ntp::background::Collection collection; collection.set_collection_id("shapes");
Regression Test / PoC
diff --git a/components/themes/ntp_background_service_unittest.cc b/components/themes/ntp_background_service_unittest.cc
index f8b2770..c92795b 100644
--- a/components/themes/ntp_background_service_unittest.cc
+++ b/components/themes/ntp_background_service_unittest.cc
@@ -258,6 +258,72 @@
EXPECT_EQ(service()->collection_error_info().error_type, ErrorType::NONE);
}
+TEST_F(NtpBackgroundServiceTest, CollectionsResponseRejectsNonHttpPreviewUrl) {
+ ntp::background::GetCollectionsResponse response;
+ for (const std::string& url :
+ {"data:image/png;base64,AAAA=", "chrome://favicon2/?pageUrl=x",
+ "file:///etc/passwd", "javascript:alert(1)"}) {
+ ntp::background::Collection* collection = response.add_collections();
+ collection->set_collection_id("id");
+ collection->set_collection_name("name");
+ collection->add_preview()->set_image_url(url);
+ }
+ std::string response_string;
+ response.SerializeToString(&response_string);
+
+ SetUpResponseWithData(service()->GetCollectionsLoadURLForTesting(),
+ response_string);
+
+ EXPECT_CALL(observer_, OnCollectionInfoAvailable).Times(1);
+ service()->FetchCollectionInfo();
+ base::RunLoop().RunUntilIdle();
+
+ ASSERT_EQ(4u, service()->collection_info().size());
+ for (const auto& info : service()->collection_info()) {
+ EXPECT_TRUE(info.preview_image_url.is_empty())
+ << info.preview_image_url.possibly_invalid_spec();
+ }
+}
+
+TEST_F(NtpBackgroundServiceTest,
+ CollectionImagesResponseRejectsNonHttpImageUrl) {
+ ntp::background::Image image;
+ image.set_asset_id(12345);
+ image.set_image_url("data:image/png;base64,AAAA=");
+ ntp::background::GetImagesInCollectionResponse response;
+ *response.add_images() = image;
+ std::string response_string;
+ response.SerializeToString(&response_string);
+
+ SetUpResponseWithData(service()->GetImagesURLForTesting(), response_string);
+
+ EXPECT_CALL(observer_, OnCollectionImagesAvailable).Times(1);
+ service()->FetchCollectionImageInfo("shapes");
+ base::RunLoop().RunUntilIdle();
+
+ ASSERT_EQ(1u, service()->collection_images().size());
+ EXPECT_TRUE(service()->collection_images().at(0).image_url.is_empty());
+ EXPECT_TRUE(
+ service()->collection_images().at(0).thumbnail_image_url.is_empty());
+}
+
+TEST_F(NtpBackgroundServiceTest, AddOptionsToImageURLRequiresHttpScheme) {
+ EXPECT_EQ(GURL("https://wallpapers.co/img=w100"),
+ AddOptionsToImageURL("https://wallpapers.co/img", "=w100"));
+ EXPECT_EQ(GURL("http://wallpapers.co/img=w100"),
+ AddOptionsToImageURL("http://wallpapers.co/img", "=w100"));
+ EXPECT_EQ(GURL("https://wallpapers.co/img=s"),
+ AddOptionsToImageURL("https://wallpapers.co/img=s", "=w100"));
+
+ EXPECT_TRUE(AddOptionsToImageURL("", "=w100").is_empty());
+ EXPECT_TRUE(AddOptionsToImageURL("not a url", "=w100").is_empty());
+ EXPECT_TRUE(
+ AddOptionsToImageURL("data:image/png;base64,AAAA=", "=w100").is_empty());
+ EXPECT_TRUE(AddOptionsToImageURL("chrome://favicon2/?pageUrl=x", "=w100")
+ .is_empty());
+ EXPECT_TRUE(AddOptionsToImageURL("file:///etc/passwd", "=w100").is_empty());
+}
+
TEST_F(NtpBackgroundServiceTest, BrokenCollectionPreviewImageHasNoReplacement) {
ntp::background::Collection collection;
collection.set_collection_id("shapes");
Original Bug Report
Bypass of SanitizedImageSource in Customize Chrome WebUI via unvalidated NTP image URLs
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: A potential vulnerability exists where NtpBackgroundService does not validate the scheme of server-provided background image URLs. This allows an attacker capable of spoofing network responses to provide data: or chrome: URLs, which are forwarded to the privileged ‘Customize Chrome’ WebUI. When rendered via the <img is="cr-auto-img"> custom element, these schemes bypass the secure SanitizedImageSource out-of-process decoding mechanism and decode directly in the privileged WebUI renderer.
Affected files:
components/themes/ntp_background_data.cccomponents/themes/ntp_background_service.ccchrome/browser/ui/webui/side_panel/customize_chrome/customize_chrome_page_handler.cc
Estimated timestamp from git blame: 2022-12-07
Summary
A potential security validation bypass exists where NtpBackgroundService fails to validate the protocol scheme of server-supplied background image URLs. If an attacker is able to manipulate network responses from the Backdrop wallpaper service, they can inject malicious data: or chrome: URLs. When the “Customize Chrome” side panel is opened, these unvalidated URLs are forwarded via Mojo to the privileged WebUI (chrome://customize-chrome-side-panel.top-chrome/) and rendered using the <img is="cr-auto-img"> custom element. This bypasses the secure SanitizedImageSource out-of-process decoding layer, forcing the privileged WebUI renderer process to decode arbitrary image data directly.
Root Cause & Code Analysis
In components/themes/ntp_background_data.cc, AddOptionsToImageURL performs string concatenation on incoming image URL strings without verifying the scheme:
GURL AddOptionsToImageURL(const std::string& image_url,
const std::string& image_options) {
return GURL(image_url + ((image_url.find('=') == std::string::npos)
? image_options
: std::string("")));
}
If the server response contains a data: URI (e.g. data:image/png;base64,...) or a parameterized chrome: URL (e.g. chrome://favicon2/?pageUrl=...), the raw string is parsed into a GURL directly without validation.
The NtpBackgroundService parses collections and images from Google’s Backdrop service via SimpleURLLoader and populates the preview_image_url fields utilizing this function (components/themes/ntp_background_service.cc lines 211, 272). These GURLs are later passed directly to the CustomizeChromePageHandler which maps them to Mojo objects without additional validation (chrome/browser/ui/webui/side_panel/customize_chrome/customize_chrome_page_handler.cc lines 799, 838):
collection->preview_image_url = GURL(info.preview_image_url);
...
image->preview_image_url = info.thumbnail_image_url;
In the WebUI, the previewImageUrl is bound as an attribute to the custom <img is="cr-auto-img"> element (chrome/browser/resources/side_panel/customize_chrome/categories.html.ts lines 64-68).
In ui/webui/resources/cr_elements/cr_auto_img/cr_auto_img.ts (lines 93-96), the custom element explicitly allows data: and chrome: URLs to be set directly to the underlying this.src attribute to support internal resources:
if (url.protocol === 'data:' || url.protocol === 'chrome:') {
this.src = url.href;
return;
}
By doing so, it exits early and bypasses the secure fallback routing that would prepend chrome://image?... to direct the download and decoding to the isolated SanitizedImageSource mechanism (which executes image decoding in a highly sandboxed, low-privilege data_decoder utility process).
Potential Exploitation Path
The following outlines a potential scenario of how an attacker might trigger this path (noting that this sequence has not been executed dynamically and our testing tools do not currently have the capability to run code):
- The attacker gains control over network responses (e.g. via a compromised network process).
- The attacker intercepts the outbound request to the Backdrop wallpaper API and returns a malicious protobuf response.
- In the response, the attacker sets a collection’s or image’s preview URL to a
data:URL containing a malformed base64-encoded image. - When the user opens the “Customize Chrome” side panel, the WebUI frontend requests and receives the collections via Mojo.
- The WebUI binds the
data:URL to a<img is="cr-auto-img">element. - The
cr-auto-imgelement assigns thedata:URL directly tothis.src, bypassing thechrome://imageproxy. - Blink’s image decoder parses and decodes the malformed image directly within the privileged WebUI renderer process context instead of the isolated utility process. Any memory-corruption bug in the decoder would trigger in the privileged WebUI context.
Suggested Fix
We suggest adding strict scheme validation on the C++ side. NtpBackgroundService should only accept image URLs that use cryptographic schemes (e.g. SchemeIsCryptographic() or SchemeIsHTTPOrHTTPS()). Any incoming URLs that violate this check should be discarded or replaced with empty URLs.
Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379
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.