CVE-2025-4086
Overview
Background
- `GeckoEngineSession.kt`
- The Android-components glue that turns a GeckoView
WebResponseinto a download request. It assembles afileNamethat is later shown to the user in the download dialog. - `DownloadUtils.parseContentDisposition`
- Parses the
Content-Dispositionresponse header to extract the server-suggested filename, viaparseContentDispositionWithFileName/parseContentDispositionWithFileNameAsterisk. - `sanitizeFileName()` / `.decode()`
sanitizeFileNamestrips path separators and other dangerous characters from a filename;.decode()percent-decodes URL-encoded sequences such as%0A(newline).- Extension spoofing (CWE-451)
- If a filename displayed to the user contains many newline characters, the real extension (e.g.
.apk) can be pushed off-screen in the download dialog, so the user sees a benign-looking leading name.
Root Cause Analysis
The ordering of decode vs. sanitize was wrong. In the vulnerable code, GeckoEngineSession built the display filename as fileName.sanitizeFileName().decode(): it sanitized the still-percent-encoded string and only percent-decoded afterwards. Because the sanitizer ran before decoding, encoded control characters like %0A/%0D survived sanitization as literal %0A text and were only turned into real newline characters by the trailing .decode() — after any newline/control-character filtering had already happened. A server could therefore supply a Content-Disposition filename packed with encoded newlines that, once decoded for display, pushed the true extension out of view in the download dialog.
The patch moves decoding to the correct place and removes the late decode. DownloadUtils.parseContentDisposition now captures the parsed filename into a local val fileName = parseContentDispositionWithFileName(...) ?: parseContentDispositionWithFileNameAsterisk(...) and returns Uri.decode(fileName), so the filename is percent-decoded at parse time. Correspondingly, the caller in GeckoEngineSession drops the trailing .decode() and uses fileName.sanitizeFileName() directly. Now the filename is decoded first and sanitized after, so the sanitizer sees — and can strip — the real newline characters instead of their encoded form.
%0A.Attack Path
- Server operator
Hosts a resource and sets
Content-Disposition: attachment; filename="malware%0A%0A...%0A.apk"(or thefilename*form) with a long run of encoded newlines before the real extension. - Victim (Firefox for Android) Navigates to or is redirected to the resource and triggers a download.
- Parsing
parseContentDispositionextracts the encoded filename; pre-patch it was handed onward still encoded. - Display assembly
sanitizeFileName()runs on the encoded text (no newlines to strip), then.decode()expands%0Ainto real newlines. - Spoof
The download dialog renders the filename with many newlines; the trailing
.apk(or other dangerous extension) is scrolled out of view, so the user approves a file believing it is benign.
Impact Assessment
Files Changed
mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.ktmobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/DownloadUtils.kt
Audit Directions
- Decode-then-sanitize orderingAudit every other place a server-supplied string (filenames, headers, URLs) is sanitized while still encoded and decoded afterward; the safe order is always decode → validate/sanitize.
- Download dialog renderingReview how the Android download confirmation UI renders long filenames and control characters; a hardening (truncation, stripping control chars at render time) would defend in depth regardless of decode order.
Patch
diff --git a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt
index 35f45fbf8fa..6889c1f8acc 100644
--- a/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt
+++ b/mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt
@@ -1264,7 +1264,7 @@ class GeckoEngineSession(
url = url,
contentLength = contentLength,
contentType = DownloadUtils.sanitizeMimeType(contentType),
- fileName = fileName.sanitizeFileName().decode(),
+ fileName = fileName.sanitizeFileName(),
response = response,
isPrivate = privateMode,
openInApp = webResponse.requestExternalApp,
diff --git a/mobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/DownloadUtils.kt b/mobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/DownloadUtils.kt
index 63dd9ca0e0b..a0441612e97 100644
--- a/mobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/DownloadUtils.kt
+++ b/mobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/DownloadUtils.kt
@@ -337,8 +337,9 @@ object DownloadUtils {
private fun parseContentDisposition(contentDisposition: String): String? {
return try {
- parseContentDispositionWithFileName(contentDisposition)
+ val fileName = parseContentDispositionWithFileName(contentDisposition)
?: parseContentDispositionWithFileNameAsterisk(contentDisposition)
+ Uri.decode(fileName)
} catch (ex: IllegalStateException) {
// This function is defined as returning null when it can't parse the header
null