Medium firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionMitigation bypass in the PDF Viewer component
ComponentToolkit
Bug ClassLogic Error
Tracker2037770
Fix commit7e78d40e52b6 (firefox) +441/-0
CISA KEVNot listed
CreditedNikola Kojic
Disclosed2026-07-21

Changed Functions

FunctionChangeNotes
download
toolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs
modified
supportsDownloading
toolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs
modified
if
toolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs
modified
onDownloadChanged
toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js
modified
if
toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js
modified

Files Changed

  • toolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs
  • toolkit/components/pdfjs/test/browser.toml
  • toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js
  • toolkit/components/pdfjs/test/file_pdfjs_csp.sjs
  • toolkit/components/pdfjs/test/file_pdfjs_csp_sandbox_opener.html
  • toolkit/components/pdfjs/test/file_pdfjs_csp_sandbox_opener.html^headers^
diff --git a/toolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs b/toolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs
index f65b7052a0a..4d76863c122 100644
--- a/toolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs
+++ b/toolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs
@@ -394,6 +394,11 @@ class ChromeActions {
   }
 
   download(data) {
+    if (!this.supportsDownloading()) {
+      console.warn("PdfStreamConverter: blocked a download request.");
+      return;
+    }
+
     const { originalUrl } = data;
     const blobUrl = data.blobUrl || originalUrl;
     let { filename } = data;
@@ -424,6 +429,22 @@ class ChromeActions {
     return this.domWindow.windowGlobalChild.browsingContext.parent === null;
   }
 
+  supportsDownloading() {
+    const context = this.domWindow.windowGlobalChild.browsingContext;
+    // A top-level document may always trigger downloads. The sandboxed-downloads
+    // flag is meant to let an embedder gate downloads from embedded content; at
+    // top level there is no embedder, and since the PDF response's CSP sandbox
+    // is already ignored for http(s) URLs, enforcing it only for the blob: edge
+    // case would be inconsistent and needlessly stop users saving a PDF they
+    // view.
+    if (context.parent === null) {
+      return true;
+    }
+    // Copied from nsSandboxFlags.h
+    const SANDBOXED_DOWNLOADS = 0x10000;
+    return (context.sandboxFlags & SANDBOXED_DOWNLOADS) === 0;
+  }
+
   async getBrowserPrefs() {
     const isMobile = this.isMobile();
     const nimbusDataStr = isMobile
@@ -441,6 +462,7 @@ class ChromeActions {
         !!Services.prefs.getIntPref("browser.display.use_document_fonts") &&
         Services.prefs.getBoolPref("gfx.downloadable_fonts.enabled"),
       supportsIntegratedFind: this.supportsIntegratedFind(),
+      supportsDownloading: this.supportsDownloading(),
       supportsMouseWheelZoomCtrlKey:
         Services.prefs.getIntPref("mousewheel.with_control.action") === 3,
       supportsMouseWheelZoomMetaKey:
diff --git a/toolkit/components/pdfjs/test/browser.toml b/toolkit/components/pdfjs/test/browser.toml
index 07bde25e551..c92567061c6 100644
--- a/toolkit/components/pdfjs/test/browser.toml
+++ b/toolkit/components/pdfjs/test/browser.toml
@@ -120,6 +120,13 @@ support-files = ["pdf_response_link.sjs"]
 
 ["browser_pdfjs_rfp_exemption.js"]
 
+["browser_pdfjs_sandboxed_iframe.js"]
+support-files = [
+  "file_pdfjs_csp_sandbox_opener.html",
+  "file_pdfjs_csp_sandbox_opener.html^headers^",
+  "file_pdfjs_csp.sjs",
+]
+
 ["browser_pdfjs_saveas.js"]
 support-files = [
   "!/toolkit/content/tests/browser/common/mockTransfer.js",
diff --git a/toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js b/toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js
new file mode 100644
index 00000000000..cbb81e1d711
--- /dev/null
+++ b/toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js
@@ -0,0 +1,381 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PdfjsParent } = ChromeUtils.importESModule(
+  "resource://pdf.js/PdfjsParent.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+  "resource://testing-common/Sinon.sys.mjs"
+);
+
+const RELATIVE_DIR = "toolkit/components/pdfjs/test/";
+const TESTROOT = "https://example.com/browser/" + RELATIVE_DIR;
+const PDF_URL = TESTROOT + "file_pdfjs_test.pdf";
+const CSP_OPENER_URL = TESTROOT + "file_pdfjs_csp_sandbox_opener.html";
+const CSP_PDF_URL = TESTROOT + "file_pdfjs_csp.sjs";
+
+// Copied from nsSandboxFlags.h
+const SANDBOXED_DOWNLOADS = 0x10000;
+
+// Substring of the warning `ChromeActions.download` logs when it drops a
+// request from a sandboxed context.
+const DOWNLOAD_BLOCKED_MARKER =
+  "PdfStreamConverter: blocked a download request.";
+
+const MockFilePicker = SpecialPowers.MockFilePicker;
+let tempDir;
+
+function makeIframeParentUrl(sandbox) {
+  return (
+    "data:text/html," +
+    encodeURIComponent(
+      `<!doctype html><html><body><iframe id="pdf" sandbox="${sandbox}" ` +
+        `src="${PDF_URL}" width="800" height="600"></iframe></body></html>`
+    )
+  );
+}
+
+function getIframeBrowsingContext(browser) {
+  return browser.browsingContext.children[0];
+}
+
+// Wait for the pdf.js viewer in `target` to initialize and load its document.
+async function waitForPdfJSLoaded(target) {
+  await SpecialPowers.spawn(target, [], async () => {
+    const { ContentTaskUtils } = ChromeUtils.importESModule(
+      "resource://testing-common/ContentTaskUtils.sys.mjs"
+    );
+    const getApp = () => content.wrappedJSObject.PDFViewerApplication;
+    await ContentTaskUtils.waitForCondition(
+      () => getApp()?.initialized,
+      "PDFViewerApplication must initialize"
+    );
+    await getApp().initializedPromise;
+    await ContentTaskUtils.waitForCondition(
+      () => getApp()?.pdfDocument,
+      "PDFViewerApplication must load a PDF document"
+    );
+  });
+  await TestUtils.waitForTick();
+}
+
+/**
+ * Dispatch a `download` request straight into `ChromeActions.download`, like
+ * the viewer's `FirefoxCom.request("download", ...)`, then wait for the warning
+ * the guard logs when it drops it.
+ *
+ * Calling `PDFViewerApplication.downloadOrSave()` instead would test the
+ * viewer's own gating (it hides download UI when sandboxed) and would still
+ * pass even if the chrome-side guard were removed.
+ */
+async function forgeDownloadAndExpectBlock(bc) {
+  await SpecialPowers.spawn(
+    bc,
+    [DOWNLOAD_BLOCKED_MARKER, PDF_URL],
+    async (marker, originalUrl) => {
+      const { TestUtils } = ChromeUtils.importESModule(
+        "resource://testing-common/TestUtils.sys.mjs"
+      );
+      const blocked = TestUtils.consoleMessageObserved(msg => {
+        const arg = msg.wrappedJSObject.arguments?.[0];
+        return typeof arg === "string" && arg.includes(marker);
+      });
+
+      const node = content.document.createTextNode("");
+      content.document.documentElement.append(node);
+      node.dispatchEvent(
+        new content.CustomEvent("pdf.js.message", {
+          bubbles: true,
+          cancelable: false,
+          detail: Cu.cloneInto(
+            {
+              action: "download",
+              data: { blobUrl: "blob:fake", originalUrl, filename: "fake.pdf" },
+              responseExpected: false,
+            },
+            content
+          ),
+        })
+      );
+
+      info("Waiting for the download request to be blocked...");
+      await blocked;
+      info("The download request was blocked by ChromeActions.download");
+    }
+  );
+}
+
+async function awaitNextDownload() {
+  const downloadList = await Downloads.getList(Downloads.PUBLIC);
+  const filePickerShown = new Promise(resolve => {
+    MockFilePicker.showCallback = fp => {
+      const destFile = tempDir.clone();
+      destFile.append(fp.defaultString);
+      if (destFile.exists()) {
+        destFile.remove(false);
+      }
+      MockFilePicker.setFiles([destFile]);
+      MockFilePicker.filterIndex = 0;
+      resolve();
+    };
+  });
+  let view;
+  const downloadFinished = new Promise(resolve => {
+    view = {
+      onDownloadChanged(download) {
+        download.launchWhenSucceeded = false;
+        if (download.succeeded || download.error) {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/toolkit/components/pdfjs/test/browser.toml b/toolkit/components/pdfjs/test/browser.toml
index 07bde25e551..c92567061c6 100644
--- a/toolkit/components/pdfjs/test/browser.toml
+++ b/toolkit/components/pdfjs/test/browser.toml
@@ -120,6 +120,13 @@ support-files = ["pdf_response_link.sjs"]
 
 ["browser_pdfjs_rfp_exemption.js"]
 
+["browser_pdfjs_sandboxed_iframe.js"]
+support-files = [
+  "file_pdfjs_csp_sandbox_opener.html",
+  "file_pdfjs_csp_sandbox_opener.html^headers^",
+  "file_pdfjs_csp.sjs",
+]
+
 ["browser_pdfjs_saveas.js"]
 support-files = [
   "!/toolkit/content/tests/browser/common/mockTransfer.js",
diff --git a/toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js b/toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js
new file mode 100644
index 00000000000..cbb81e1d711
--- /dev/null
+++ b/toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js
@@ -0,0 +1,381 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PdfjsParent } = ChromeUtils.importESModule(
+  "resource://pdf.js/PdfjsParent.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+  "resource://testing-common/Sinon.sys.mjs"
+);
+
+const RELATIVE_DIR = "toolkit/components/pdfjs/test/";
+const TESTROOT = "https://example.com/browser/" + RELATIVE_DIR;
+const PDF_URL = TESTROOT + "file_pdfjs_test.pdf";
+const CSP_OPENER_URL = TESTROOT + "file_pdfjs_csp_sandbox_opener.html";
+const CSP_PDF_URL = TESTROOT + "file_pdfjs_csp.sjs";
+
+// Copied from nsSandboxFlags.h
+const SANDBOXED_DOWNLOADS = 0x10000;
+
+// Substring of the warning `ChromeActions.download` logs when it drops a
+// request from a sandboxed context.
+const DOWNLOAD_BLOCKED_MARKER =
+  "PdfStreamConverter: blocked a download request.";
+
+const MockFilePicker = SpecialPowers.MockFilePicker;
+let tempDir;
+
+function makeIframeParentUrl(sandbox) {
+  return (
+    "data:text/html," +
+    encodeURIComponent(
+      `<!doctype html><html><body><iframe id="pdf" sandbox="${sandbox}" ` +
+        `src="${PDF_URL}" width="800" height="600"></iframe></body></html>`
+    )
+  );
+}
+
+function getIframeBrowsingContext(browser) {
+  return browser.browsingContext.children[0];
+}
+
+// Wait for the pdf.js viewer in `target` to initialize and load its document.
+async function waitForPdfJSLoaded(target) {
+  await SpecialPowers.spawn(target, [], async () => {
+    const { ContentTaskUtils } = ChromeUtils.importESModule(
+      "resource://testing-common/ContentTaskUtils.sys.mjs"
+    );
+    const getApp = () => content.wrappedJSObject.PDFViewerApplication;
+    await ContentTaskUtils.waitForCondition(
+      () => getApp()?.initialized,
+      "PDFViewerApplication must initialize"
+    );
+    await getApp().initializedPromise;
+    await ContentTaskUtils.waitForCondition(
+      () => getApp()?.pdfDocument,
+      "PDFViewerApplication must load a PDF document"
+    );
+  });
+  await TestUtils.waitForTick();
+}
+
+/**
+ * Dispatch a `download` request straight into `ChromeActions.download`, like
+ * the viewer's `FirefoxCom.request("download", ...)`, then wait for the warning
+ * the guard logs when it drops it.
+ *
+ * Calling `PDFViewerApplication.downloadOrSave()` instead would test the
+ * viewer's own gating (it hides download UI when sandboxed) and would still
+ * pass even if the chrome-side guard were removed.
+ */
+async function forgeDownloadAndExpectBlock(bc) {
+  await SpecialPowers.spawn(
+    bc,
+    [DOWNLOAD_BLOCKED_MARKER, PDF_URL],
+    async (marker, originalUrl) => {
+      const { TestUtils } = ChromeUtils.importESModule(
+        "resource://testing-common/TestUtils.sys.mjs"
+      );
+      const blocked = TestUtils.consoleMessageObserved(msg => {
+        const arg = msg.wrappedJSObject.arguments?.[0];
+        return typeof arg === "string" && arg.includes(marker);
+      });
+
+      const node = content.document.createTextNode("");
+      content.document.documentElement.append(node);
+      node.dispatchEvent(
+        new content.CustomEvent("pdf.js.message", {
+          bubbles: true,
+          cancelable: false,
+          detail: Cu.cloneInto(
+            {
+              action: "download",
+              data: { blobUrl: "blob:fake", originalUrl, filename: "fake.pdf" },
+              responseExpected: false,
+            },
+            content
+          ),
+        })
+      );
+
+      info("Waiting for the download request to be blocked...");
+      await blocked;
+      info("The download request was blocked by ChromeActions.download");
+    }
+  );
+}
+
+async function awaitNextDownload() {
+  const downloadList = await Downloads.getList(Downloads.PUBLIC);
+  const filePickerShown = new Promise(resolve => {
+    MockFilePicker.showCallback = fp => {
+      const destFile = tempDir.clone();
+      destFile.append(fp.defaultString);
+      if (destFile.exists()) {
+        destFile.remove(false);
+      }
+      MockFilePicker.setFiles([destFile]);
+      MockFilePicker.filterIndex = 0;
+      resolve();
+    };
+  });
+  let view;
+  const downloadFinished = new Promise(resolve => {
+    view = {
+      onDownloadChanged(download) {
+        download.launchWhenSucceeded = false;
+        if (download.succeeded || download.error) {
+          resolve(download);
+        }
+      },
+    };
+  });
+  downloadList.addView(view);
+
+  // Release the picker callback and the download view even if the download
+  // never starts (e.g. the trigger throws), so neither leaks into later tasks.
+  const cleanup = () => {
+    downloadList.removeView(view);
+    MockFilePicker.showCallback = null;
+  };
+  return { filePickerShown, downloadFinished, cleanup };
+}
+
+add_setup(async function () {
+  tempDir = createTemporarySaveDirectory();
+  MockFilePicker.init();
+  MockFilePicker.returnValue = MockFilePicker.returnOK;
+  MockFilePicker.displayDirectory = tempDir;
+
+  await SpecialPowers.pushPrefEnv({
+    set: [["browser.download.always_ask_before_handling_new_types", false]],
+  });
+
+  registerCleanupFunction(async function () {
+    MockFilePicker.cleanup();
+    await cleanupDownloads();
+    tempDir.remove(true);
+  });
+});
+
+/**
+ * Triggering a download from a PDF loaded in a sandboxed iframe (without
+ * `allow-downloads`) must be dropped by `ChromeActions.download` so the
+ * `PDFJS:Parent:saveURL` IPC is never sent to the parent process.
+ */
+add_task(async function test_sandboxed_iframe_blocks_download() {
+  await BrowserTestUtils.withNewTab(
+    { gBrowser, url: makeIframeParentUrl("allow-scripts") },
+    async function (browser) {
+      const iframeBC = getIframeBrowsingContext(browser);
+      await waitForPdfJSLoaded(iframeBC);
+
+      // Spy on `_saveURL` to prove the chrome-side `ChromeActions.download`
+      // guard rejected the request before it was forwarded to the parent
+      // process.
+      const spy = sinon.spy(PdfjsParent.prototype, "_saveURL");
+      try {
+        info("Forging a download request from inside the sandboxed iframe...");
+        await forgeDownloadAndExpectBlock(iframeBC);
+        await TestUtils.waitForTick();
+
+        is(
+          spy.callCount,
+          0,
+          "ChromeActions.download must not forward saveURL when the iframe is sandboxed"
+        );
+      } finally {
+        spy.restore();
+      }
+
+      await waitForPdfJSClose(iframeBC);
+    }
+  );
+});
+
+/**
+ * When the PDF is sandboxed without `allow-downloads`, the viewer must also hide
+ * its download UI (both the primary and secondary toolbar buttons), so the user
+ * is never offered an action the chrome-side guard would only reject.
+ */
+add_task(async function test_sandboxed_iframe_hides_download_button() {
+  await BrowserTestUtils.withNewTab(
+    { gBrowser, url: makeIframeParentUrl("allow-scripts") },
+    async function (browser) {
+      const iframeBC = getIframeBrowsingContext(browser);
+      await waitForPdfJSLoaded(iframeBC);
+
+      await SpecialPowers.spawn(iframeBC, [], async () => {
+        for (const id of ["downloadButton", "secondaryDownload"]) {
+          const button = content.document.getElementById(id);
+          Assert.ok(button, `#${id} must exist in the viewer`);
+          Assert.ok(
+            button.hidden,
+            `#${id} must be hidden when the iframe is sandboxed`
+          );
+        }
+      });
+
+      await waitForPdfJSClose(iframeBC);
+    }
+  );
+});
+
+/**
+ * When the iframe sandbox includes `allow-downloads`, `ChromeActions.download`
+ * must let the request through and a download must succeed end-to-end.
+ */
+add_task(async function test_sandbox_allow_downloads_permits_download() {
+  await BrowserTestUtils.withNewTab(
+    { gBrowser, url: makeIframeParentUrl("allow-scripts allow-downloads") },
+    async function (browser) {
+      const iframeBC = getIframeBrowsingContext(browser);
+      await waitForPdfJSLoaded(iframeBC);
+
+      const { filePickerShown, downloadFinished, cleanup } =
+        await awaitNextDownload();
+
+      const spy = sinon.spy(PdfjsParent.prototype, "_saveURL");
+      try {
+        info("Triggering a download from inside the allow-downloads iframe...");
+        await SpecialPowers.spawn(iframeBC, [], async () => {
+          await content.wrappedJSObject.PDFViewerApplication.downloadOrSave();
+        });
+
+        await filePickerShown;
+        const download = await downloadFinished;
+        ok(
+          download.succeeded,
+          "The download succeeded when allow-downloads is set"
+        );
+        is(
+          spy.callCount,
+          1,
+          "ChromeActions.download must forward saveURL exactly once"
+        );
+      } finally {
+        spy.restore();
+        cleanup();
+      }
+
+      await waitForPdfJSClose(iframeBC);
+    }
+  );
+});
+
+/**
+ * A top-level navigation to a `blob:` PDF inherits the opener's CSP policy
+ * (per https://w3c.github.io/webappsec-csp/#security-inherit-csp), so when the
+ * opener is served with `Content-Security-Policy: sandbox` (no
+ * `allow-downloads`), the inherited SANDBOXED_DOWNLOADS flag does reach the
+ * pdf.js viewer's browsing context. The sandboxed-downloads flag only gates
+ * embedded content, so a top-level PDF must still be downloadable even with the
+ * flag set -- `ChromeActions.download` must forward the request.
+ */
... (truncated)
Loading diff…