Medium CVSS 6.5 firefox Spoofing 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionA specially crafted filename containing a large number of encoded newline characters could obscure the file's extension when displayed in the download dialog.<br>*This bug only affects Firefox for Android. Other versions of Firefox are unaffected.*
ComponentCore
Bug ClassSpoofing
Tracker1945705
Fix commit6d0da711d7cb (firefox) +3/-2
CWECWE-451
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N
CISA KEVNot listed
CreditedHafiizh
Disclosed2025-04-29

Background

`GeckoEngineSession.kt`
The Android-components glue that turns a GeckoView WebResponse into a download request. It assembles a fileName that is later shown to the user in the download dialog.
`DownloadUtils.parseContentDisposition`
Parses the Content-Disposition response header to extract the server-suggested filename, via parseContentDispositionWithFileName / parseContentDispositionWithFileNameAsterisk.
`sanitizeFileName()` / `.decode()`
sanitizeFileName strips 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.

Key insight
Sanitization was applied to percent-encoded input and decoding was applied afterward, so the only step that could neutralize newline characters ran before those characters actually existed — letting an attacker smuggle display-obscuring newlines past the filter as %0A.

Attack Path

  1. Server operator Hosts a resource and sets Content-Disposition: attachment; filename="malware%0A%0A...%0A.apk" (or the filename* form) with a long run of encoded newlines before the real extension.
  2. Victim (Firefox for Android) Navigates to or is redirected to the resource and triggers a download.
  3. Parsing parseContentDisposition extracts the encoded filename; pre-patch it was handed onward still encoded.
  4. Display assembly sanitizeFileName() runs on the encoded text (no newlines to strip), then .decode() expands %0A into real newlines.
  5. 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

This is a UI-spoofing / extension-obscuring issue in the Firefox for Android download dialog (the advisory explicitly scopes it to Android). No memory-safety primitive is involved; the attacker gains the ability to misrepresent a downloaded file’s extension to the user, improving social-engineering odds for getting a dangerous file (e.g. an APK) accepted. Preconditions: the victim must initiate a download of attacker-controlled content and act on the misleading dialog. Impact matches CVSS C:L/I:L — moderate, no direct code execution from the bug itself.

Files Changed

  • mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt
  • mobile/android/android-components/components/support/utils/src/main/java/mozilla/components/support/utils/DownloadUtils.kt

Audit Directions

  • Decode-then-sanitize ordering
    Audit 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 rendering
    Review 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.
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
Loading diff…