CVE-2026-16377
Overview
Background
- `PdfStreamConverter.sys.mjs`
- The chrome-privileged stream converter that hosts Firefox’s built-in
pdf.jsviewer and exposes aChromeActionsmessage surface to the (unprivileged) viewer content. - `ChromeActions`
- A privileged handler class whose methods (like
download) servicepdf.js.messagerequests dispatched from the viewer document. - `SANDBOXED_DOWNLOADS`
- The
nsSandboxFlags.hbit (0x10000) that, when set on a browsing context, forbids that context from initiating downloads (the inverse of theallow-downloadssandbox token). - CSP sandbox
- A sandboxing policy applied to a document via the
sandboxiframe attribute or aContent-Security-Policy: sandboxdirective, expressed assandboxFlagson thebrowsingContext.
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.
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
- Embed a sandboxed PDF
An attacker page embeds a PDF in an iframe (or serves it under a CSP
sandbox) whose sandbox flags setSANDBOXED_DOWNLOADSbut omitallow-downloads. - Reach the privileged handler
Content script in the sandboxed frame dispatches a
pdf.js.messageCustomEventwithaction: "download", invokingChromeActions.downloaddirectly rather than the gated viewer UI. - Bypass the mitigation
Because
downloadperformed no sandbox check, it accepted the request and initiated the file download despite the context being forbidden from doing so. - Deliver the file
The download proceeds using the attacker-controlled
blobUrl/filename, defeating the sandbox restriction the embedder intended.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
downloadtoolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs |
modified | |
supportsDownloadingtoolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs |
modified | |
iftoolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs |
modified | |
onDownloadChangedtoolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js |
modified | |
iftoolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js |
modified | |
add_setuptoolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js |
modified | |
registerCleanupFunctiontoolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js |
modified | |
add_tasktoolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js |
modified | |
fortoolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js |
modified |
Files Changed
toolkit/components/pdfjs/content/PdfStreamConverter.sys.mjstoolkit/components/pdfjs/test/browser.tomltoolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.jstoolkit/components/pdfjs/test/file_pdfjs_csp.sjstoolkit/components/pdfjs/test/file_pdfjs_csp_sandbox_opener.htmltoolkit/components/pdfjs/test/file_pdfjs_csp_sandbox_opener.html^headers^
Audit Directions
- Privileged message sinksAudit every
ChromeActionsmethod reachable viapdf.js.messagefor missing origin/sandbox/capability checks, since content can forge these events independently of the viewer UI. - UI-only mitigationsWherever 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` consistencySearch 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.
Patch
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)
Regression Test / PoC
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)