Moderate firefox Logic Error 🔧 Commit mapped

Overview

Moderate
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionSpoofing issue in the Downloads component in Firefox for Android
ComponentCore
Bug ClassLogic Error
Tracker1822968
Fix commit98bdeb951abe (firefox) +82/-7
CISA KEVNot listed
Creditedhaxatron1
Disclosed2026-10-27

Changed Functions

FunctionChangeNotes
StreamListener
mobile/android/components/geckoview/GeckoViewExternalAppService.cpp
modified
if
mobile/android/components/geckoview/GeckoViewExternalAppService.cpp
modified

Files Changed

  • mobile/android/android-components/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/GeckoEngineSession.kt
  • mobile/android/android-components/docs/changelog.md
  • mobile/android/components/geckoview/GeckoViewExternalAppService.cpp
  • mobile/android/components/geckoview/GeckoViewStreamListener.cpp
  • mobile/android/components/geckoview/GeckoViewStreamListener.h
  • mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
  • mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
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 370f54f78e5..4d66c2f5018 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
@@ -68,7 +68,6 @@ import mozilla.components.support.ktx.kotlin.isEmail
 import mozilla.components.support.ktx.kotlin.isExtensionUrl
 import mozilla.components.support.ktx.kotlin.isGeoLocation
 import mozilla.components.support.ktx.kotlin.isPhone
-import mozilla.components.support.ktx.kotlin.sanitizeFileName
 import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
 import mozilla.components.support.utils.CertificateUtils
 import mozilla.components.support.utils.DownloadUtils
@@ -1500,7 +1499,7 @@ class GeckoEngineSession(
                             url = url,
                             contentLength = contentLength,
                             contentType = DownloadUtils.sanitizeMimeType(contentType),
-                            fileName = fileName?.sanitizeFileName(),
+                            fileName = fileName,
                             response = response,
                             isPrivate = privateMode,
                             openInApp = webResponse.requestExternalApp,
diff --git a/mobile/android/android-components/docs/changelog.md b/mobile/android/android-components/docs/changelog.md
index b57fa4862c6..8a25a30ead2 100644
--- a/mobile/android/android-components/docs/changelog.md
+++ b/mobile/android/android-components/docs/changelog.md
@@ -7,6 +7,8 @@ permalink: /changelog/
 # 160.0 (In Development)
 * **ui-richtext**
     * 🆕 Added `StreamingParser`, which parses a markdown document as it arrives in chunks, reparsing only the part of it that can still change. [Bug 2059255](https://bugzilla.mozilla.org/show_bug.cgi?id=2059255)
+* **browser-engine-gecko**
+    * Download filenames from `onExternalResponse` are now validated by Gecko with the same rules as desktop, so `GeckoEngineSession` no longer applies the `String.sanitizeFileName` helper from **support-ktx** to them. [Bug 1822968](https://bugzilla.mozilla.org/show_bug.cgi?id=1822968)
 
 # 159.0
 * **feature-readerview**
diff --git a/mobile/android/components/geckoview/GeckoViewExternalAppService.cpp b/mobile/android/components/geckoview/GeckoViewExternalAppService.cpp
index cd614ddb25a..95a42d25bbc 100644
--- a/mobile/android/components/geckoview/GeckoViewExternalAppService.cpp
+++ b/mobile/android/components/geckoview/GeckoViewExternalAppService.cpp
@@ -8,6 +8,9 @@
 #include "mozilla/dom/CanonicalBrowsingContext.h"
 #include "mozilla/dom/WindowGlobalParent.h"
 #include "nsIChannel.h"
+#include "nsIMIMEService.h"
+#include "nsMimeTypes.h"
+#include "nsServiceManagerUtils.h"
 
 #include "mozilla/widget/EventDispatcher.h"
 #include "mozilla/widget/nsWindow.h"
@@ -17,16 +20,28 @@
 
 class StreamListener final : public mozilla::GeckoViewStreamListener {
  public:
-  explicit StreamListener(nsWindow* aWindow)
-      : GeckoViewStreamListener(), mWindow(aWindow) {
+  StreamListener(nsWindow* aWindow, const nsAString& aFileName)
+      : GeckoViewStreamListener(), mWindow(aWindow), mFileName(aFileName) {
     MOZ_ASSERT(aWindow);
   }
 
-  void SendWebResponse(mozilla::java::WebResponse::Param aResponse) {
+  void AmendWebResponse(
+      mozilla::java::WebResponse::Builder::Param aBuilder) override {
+    if (mFileName.IsEmpty()) {
+      return;
+    }
+    nsAutoString disposition(u"attachment; filename=\""_ns);
+    disposition.Append(mFileName);
+    disposition.Append(u'"');
+    aBuilder->Header(mozilla::jni::StringParam(u"content-disposition"_ns),
+                     mozilla::jni::StringParam(disposition));
+  }
+
+  void SendWebResponse(mozilla::java::WebResponse::Param aResponse) override {
     mWindow->PassExternalResponse(aResponse);
   }
 
-  void CompleteWithError(nsresult aStatus, nsIChannel* aChannel) {
+  void CompleteWithError(nsresult aStatus, nsIChannel* aChannel) override {
     // Currently we don't do anything about errors here
   }
 
@@ -34,6 +49,8 @@ class StreamListener final : public mozilla::GeckoViewStreamListener {
 
  private:
   RefPtr<nsWindow> mWindow;
+  // Filename validated by the MIME service, or empty if none was found.
+  nsString mFileName;
 };
 
 mozilla::StaticRefPtr<GeckoViewExternalAppService>
@@ -82,7 +99,19 @@ NS_IMETHODIMP GeckoViewExternalAppService::CreateListener(
     return NS_ERROR_ABORT;
   }
 
-  RefPtr<StreamListener> listener = new StreamListener(window);
+  nsAutoString fileName;
+  nsCOMPtr<nsIMIMEService> mimeService = do_GetService("@mozilla.org/mime;1");
+  if (mimeService) {
+    uint32_t flags = nsIMIMEService::VALIDATE_ALLOW_EMPTY;
+    if (aMimeContentType.Equals(APPLICATION_GUESS_FROM_EXT,
+                                nsCaseInsensitiveCStringComparator)) {
+      flags |= nsIMIMEService::VALIDATE_GUESS_FROM_EXTENSION;
+    }
+    mimeService->GetValidFileName(aChannel, aMimeContentType, nullptr, flags,
+                                  fileName);
+  }
+
+  RefPtr<StreamListener> listener = new StreamListener(window, fileName);
 
   nsresult rv;
   rv = aChannel->SetNotificationCallbacks(listener);
diff --git a/mobile/android/components/geckoview/GeckoViewStreamListener.cpp b/mobile/android/components/geckoview/GeckoViewStreamListener.cpp
index 2b5e3278e3a..78c0fdc7346 100644
--- a/mobile/android/components/geckoview/GeckoViewStreamListener.cpp
+++ b/mobile/android/components/geckoview/GeckoViewStreamListener.cpp
@@ -260,6 +260,8 @@ nsresult GeckoViewStreamListener::HandleWebResponse(nsIRequest* aRequest) {
     }
   }
 
+  AmendWebResponse(builder);
+
   java::WebResponse::GlobalRef response = builder->Build();
 
   SendWebResponse(response);
diff --git a/mobile/android/components/geckoview/GeckoViewStreamListener.h b/mobile/android/components/geckoview/GeckoViewStreamListener.h
index d89350051a0..59f77dfa721 100644
--- a/mobile/android/components/geckoview/GeckoViewStreamListener.h
+++ b/mobile/android/components/geckoview/GeckoViewStreamListener.h
@@ -46,6 +46,9 @@ class GeckoViewStreamListener : public nsIStreamListener,
 
   virtual nsresult HandleWebResponse(nsIRequest* aRequest);
 
+  // Called after all headers are set and before the response is built.
+  virtual void AmendWebResponse(java::WebResponse::Builder::Param aBuilder) {}
+
   virtual void SendWebResponse(java::WebResponse::Param aResponse) = 0;
 
   virtual void CompleteWithError(nsresult aStatus, nsIChannel* aChannel) = 0;
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
index ca747e6f138..2d3e476f4ef 100644
--- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
@@ -13,6 +13,14 @@ exclude: true
 
 ⚠️  breaking change and deprecation notices
 
+## v160
+- The `content-disposition` header of the [`WebResponse`][65.15] passed to
+    [`ContentDelegate.onExternalResponse`][160.1] now contains a filename validated with the same
+    rules desktop applies when saving a download, instead of the header sent by the server.
+    ([bug 1822968]({{bugzilla}}1822968))
+
+[160.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onExternalResponse(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.WebResponse)
+
 ## v157
 - Added [`GeckoSession.getPdfViewerEditor`][157.1] and
     [`PdfViewerController.SessionEditor.addSignature`][157.2] to place a signature on the PDF the
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
index c1109f5c98e..8dee5979ff8 100644
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
@@ -319,6 +319,38 @@ class ContentDelegateTest : BaseSessionTest() {
         )
     }
 
+    private fun assertDownloadFileName(contentDisposition: String, expected: String) {
+        mainSession.loadTestPath(withContentDisposition(HELLO_HTML_PATH, contentDisposition))
+
+        sessionRule.waitUntilCalled(
+            object : ContentDelegate {
+                @AssertCalled(count = 1)
+                override fun onExternalResponse(session: GeckoSession, response: WebResponse) {
+                    assertThat(
+                        "Content-Disposition filename is validated by Gecko",
+                        response.headers["content-disposition"],
+                        equalTo("attachment; filename=\"$expected\""),
+                    )
+                }
+            }
+        )
+    }
+
+    @Test
+    fun downloadFileNameReplacesBidiOverride() {
+        assertDownloadFileName("attachment; filename*=UTF-8''file%E2%80%AEtxt.html", "file_txt.html")
+    }
+
+    @Test
+    fun downloadFileNameReplacesPathSeparators() {
+        assertDownloadFileName("attachment; filename=\"dir/sub\\\\name.html\"", "dir_sub_name.html")
+    }
+
+    @Test
+    fun downloadFileNameReplacesIllegalCharacters() {
+        assertDownloadFileName("attachment; filename=\"a:b*c?d.html\"", "a_b_c_d.html")
+    }
+
     @IgnoreCrash
     @Test
     fun crashContent() {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
index c1109f5c98e..8dee5979ff8 100644
--- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt
@@ -319,6 +319,38 @@ class ContentDelegateTest : BaseSessionTest() {
         )
     }
 
+    private fun assertDownloadFileName(contentDisposition: String, expected: String) {
+        mainSession.loadTestPath(withContentDisposition(HELLO_HTML_PATH, contentDisposition))
+
+        sessionRule.waitUntilCalled(
+            object : ContentDelegate {
+                @AssertCalled(count = 1)
+                override fun onExternalResponse(session: GeckoSession, response: WebResponse) {
+                    assertThat(
+                        "Content-Disposition filename is validated by Gecko",
+                        response.headers["content-disposition"],
+                        equalTo("attachment; filename=\"$expected\""),
+                    )
+                }
+            }
+        )
+    }
+
+    @Test
+    fun downloadFileNameReplacesBidiOverride() {
+        assertDownloadFileName("attachment; filename*=UTF-8''file%E2%80%AEtxt.html", "file_txt.html")
+    }
+
+    @Test
+    fun downloadFileNameReplacesPathSeparators() {
+        assertDownloadFileName("attachment; filename=\"dir/sub\\\\name.html\"", "dir_sub_name.html")
+    }
+
+    @Test
+    fun downloadFileNameReplacesIllegalCharacters() {
+        assertDownloadFileName("attachment; filename=\"a:b*c?d.html\"", "a_b_c_d.html")
+    }
+
     @IgnoreCrash
     @Test
     fun crashContent() {
Loading diff…