Firefox · Toolkit
CVE-2026-16377
Logic Error in Toolkit
Overview
Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
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 |
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^
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) {
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…
References
On This Page