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

Background

`PdfStreamConverter.sys.mjs`
The chrome-privileged stream converter that hosts Firefox’s built-in pdf.js viewer and exposes a ChromeActions message surface to the (unprivileged) viewer content.
`ChromeActions`
A privileged handler class whose methods (like download) service pdf.js.message requests dispatched from the viewer document.
`SANDBOXED_DOWNLOADS`
The nsSandboxFlags.h bit (0x10000) that, when set on a browsing context, forbids that context from initiating downloads (the inverse of the allow-downloads sandbox token).
CSP sandbox
A sandboxing policy applied to a document via the sandbox iframe attribute or a Content-Security-Policy: sandbox directive, expressed as sandboxFlags on the browsingContext.

Root Cause Analysis

When the pdf.js viewer requested a file save, it dispatched a download action that landed in ChromeActions.download, which unconditionally created a download from the supplied blobUrl/originalUrl. The method never consulted the hosting document’s browsingContext.sandboxFlags, so a PDF rendered inside a sandboxed iframe (or a document under a CSP sandbox directive) that lacked allow-downloads could still force a download by driving the privileged download handler directly. This violated the invariant that a browsing context carrying SANDBOXED_DOWNLOADS must not be able to initiate downloads — the sandbox mitigation was enforced by the viewer UI but not by the chrome-side action.

The fix adds supportsDownloading(), which returns true for a top-level document (no embedder to gate it) and otherwise checks (context.sandboxFlags & SANDBOXED_DOWNLOADS) === 0; download now early-returns and logs a warning when that check fails, and the flag is also surfaced to the viewer via getBrowserPrefs. This works because the authoritative sandbox state now gates the privileged sink itself, so bypassing the UI no longer bypasses the restriction.

Key insight
The single mistake was enforcing the download-sandbox restriction only in the viewer’s UI while leaving the privileged ChromeActions.download sink unguarded; the fix re-checks SANDBOXED_DOWNLOADS on the hosting browsingContext at the sink, so the mitigation holds regardless of how the request is forged.

Attack Path

  1. Embed a sandboxed PDF An attacker page embeds a PDF in an iframe (or serves it under a CSP sandbox) whose sandbox flags set SANDBOXED_DOWNLOADS but omit allow-downloads.
  2. Reach the privileged handler Content script in the sandboxed frame dispatches a pdf.js.message CustomEvent with action: "download", invoking ChromeActions.download directly rather than the gated viewer UI.
  3. Bypass the mitigation Because download performed no sandbox check, it accepted the request and initiated the file download despite the context being forbidden from doing so.
  4. Deliver the file The download proceeds using the attacker-controlled blobUrl/filename, defeating the sandbox restriction the embedder intended.

Impact Assessment

An attacker gains the ability to initiate downloads from a browsing context that was explicitly sandboxed to prevent them, defeating an embedder’s allow-downloads restriction (a mitigation bypass, rated moderate). This occurs in the PDF viewer’s privileged ChromeActions surface acting on behalf of embedded content; the precondition is that a PDF is rendered inside a context bearing SANDBOXED_DOWNLOADS, and the impact is limited to unwanted download initiation rather than code execution or data disclosure.

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
add_setup
toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js
modified
registerCleanupFunction
toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js
modified
add_task
toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js
modified
for
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^

Audit Directions

  • Privileged message sinks
    Audit every ChromeActions method reachable via pdf.js.message for missing origin/sandbox/capability checks, since content can forge these events independently of the viewer UI.
  • UI-only mitigations
    Wherever a security restriction is enforced only by hiding UI, verify the underlying privileged action re-checks the invariant, because the UI is not a trust boundary.
  • `sandboxFlags` consistency
    Search for privileged code paths (downloads, navigation, forms, popups) that act on behalf of embedded content without consulting browsingContext.sandboxFlags, especially where top-level versus embedded contexts need different handling.
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) {
+          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.
+ */
+add_task(
+  async function test_top_level_blob_with_csp_sandbox_permits_download() {
+    await BrowserTestUtils.withNewTab(
+      { gBrowser, url: CSP_OPENER_URL },
+      async function (browser) {
+        const newTabPromise = BrowserTestUtils.waitForNewTab(
+          gBrowser,
+          url => url.startsWith("blob:"),
+          false
+        );
+        await SpecialPowers.spawn(browser, [], async () => {
+          content.document.getElementById("go").click();
+        });
+        const newTab = await newTabPromise;
+        const newBrowser = newTab.linkedBrowser;
+        await waitForPdfJSLoaded(newBrowser);
+
+        Assert.strictEqual(
+          newBrowser.browsingContext.sandboxFlags & SANDBOXED_DOWNLOADS,
+          SANDBOXED_DOWNLOADS,
+          "the blob popup BC inherited SANDBOXED_DOWNLOADS from the opener CSP"
+        );
+
+        const { filePickerShown, downloadFinished, cleanup } =
+          await awaitNextDownload();
+
+        const spy = sinon.spy(PdfjsParent.prototype, "_saveURL");
+        try {
+          info(
+            "Triggering a download in a top-level blob PDF whose policy was " +
+              "inherited from a CSP-sandboxed opener..."
+          );
+          await SpecialPowers.spawn(newBrowser, [], async () => {
+            await content.wrappedJSObject.PDFViewerApplication.downloadOrSave();
+          });
+
+          await filePickerShown;
+          const download = await downloadFinished;
+          ok(
+            download.succeeded,
+            "The download succeeded at top-level despite the inherited sandbox flag"
+          );
+          is(
+            spy.callCount,
+            1,
+            "ChromeActions.download must forward saveURL at top-level even when " +
+              "SANDBOXED_DOWNLOADS is inherited into a blob: navigation"
+          );
+        } finally {
+          spy.restore();
+          cleanup();
+        }
+
... (file diff truncated)
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…