Loading toolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs +22 −0 Original line number Diff line number Diff line Loading @@ -393,6 +393,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; Loading Loading @@ -423,6 +428,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 Loading @@ -440,6 +461,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: Loading toolkit/components/pdfjs/content/web/viewer-geckoview.mjs +21 −1 Original line number Diff line number Diff line Loading @@ -604,6 +604,10 @@ const defaultOptions = { value: true, kind: OptionKind.BROWSER }, supportsDownloading: { value: true, kind: OptionKind.BROWSER }, supportsIntegratedFind: { value: false, kind: OptionKind.BROWSER Loading Loading @@ -8958,7 +8962,14 @@ const PDFViewerApplication = { externalLinkRel: AppOptions.get("externalLinkRel"), ignoreDestinationZoom: AppOptions.get("ignoreDestinationZoom") }); const downloadManager = this.downloadManager = new DownloadManager(); const supportsDownloading = AppOptions.get("supportsDownloading"); const downloadManager = this.downloadManager = supportsDownloading ? new DownloadManager() : null; if (appConfig.secondaryToolbar?.downloadButton) { appConfig.secondaryToolbar.downloadButton.hidden = !supportsDownloading; } if (appConfig.toolbar?.download) { appConfig.toolbar.download.hidden = !supportsDownloading; } const findController = this.findController = new PDFFindController({ linkService, eventBus, Loading Loading @@ -9388,6 +9399,9 @@ const PDFViewerApplication = { }); }, async download() { if (!this.downloadManager) { return; } let data; try { data = await (this.pdfDocument ? this.pdfDocument.getData() : this.pdfLoadingTask.getData()); Loading @@ -9395,6 +9409,9 @@ const PDFViewerApplication = { this.downloadManager.download(data, this._downloadUrl, this._docFilename); }, async save() { if (!this.downloadManager) { return; } if (this._saveInProgress) { return; } Loading @@ -9421,6 +9438,9 @@ const PDFViewerApplication = { } }, async downloadOrSave() { if (!this.downloadManager) { return; } const { classList } = this.appConfig.appContainer; Loading toolkit/components/pdfjs/content/web/viewer.mjs +23 −3 Original line number Diff line number Diff line Loading @@ -604,6 +604,10 @@ const defaultOptions = { value: true, kind: OptionKind.BROWSER }, supportsDownloading: { value: true, kind: OptionKind.BROWSER }, supportsIntegratedFind: { value: false, kind: OptionKind.BROWSER Loading Loading @@ -3583,7 +3587,7 @@ class PDFAttachmentViewer extends BaseTreeViewer { element.title = description; } element.onclick = () => { this.downloadManager.openOrDownloadData(content, filename); this.downloadManager?.openOrDownloadData(content, filename); return false; }; } Loading Loading @@ -5751,7 +5755,7 @@ class PDFOutlineViewer extends BaseTreeViewer { if (attachment) { element.href = linkService.getAnchorUrl(""); element.onclick = () => { this.downloadManager.openOrDownloadData(attachment.content, attachment.filename); this.downloadManager?.openOrDownloadData(attachment.content, attachment.filename); return false; }; return; Loading Loading @@ -13570,7 +13574,14 @@ const PDFViewerApplication = { externalLinkRel: AppOptions.get("externalLinkRel"), ignoreDestinationZoom: AppOptions.get("ignoreDestinationZoom") }); const downloadManager = this.downloadManager = new DownloadManager(); const supportsDownloading = AppOptions.get("supportsDownloading"); const downloadManager = this.downloadManager = supportsDownloading ? new DownloadManager() : null; if (appConfig.secondaryToolbar?.downloadButton) { appConfig.secondaryToolbar.downloadButton.hidden = !supportsDownloading; } if (appConfig.toolbar?.download) { appConfig.toolbar.download.hidden = !supportsDownloading; } const findController = this.findController = new PDFFindController({ linkService, eventBus, Loading Loading @@ -13999,6 +14010,9 @@ const PDFViewerApplication = { }); }, async download() { if (!this.downloadManager) { return; } let data; try { data = await (this.pdfDocument ? this.pdfDocument.getData() : this.pdfLoadingTask.getData()); Loading @@ -14006,6 +14020,9 @@ const PDFViewerApplication = { this.downloadManager.download(data, this._downloadUrl, this._docFilename); }, async save() { if (!this.downloadManager) { return; } if (this._saveInProgress) { return; } Loading @@ -14032,6 +14049,9 @@ const PDFViewerApplication = { } }, async downloadOrSave() { if (!this.downloadManager) { return; } const { classList } = this.appConfig.appContainer; Loading toolkit/components/pdfjs/test/browser.toml +7 −0 Original line number Diff line number Diff line Loading @@ -86,6 +86,13 @@ support-files = [ ["browser_pdfjs_response_link.js"] support-files = ["pdf_response_link.sjs"] ["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", Loading toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js 0 → 100644 +381 −0 Original line number Diff line number Diff line /* 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(window.browsingContext); 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(); } await waitForPdfJSClose(newBrowser, true); } ); } ); /** * The `sandbox` directive carried by a PDF response's HTTP * `Content-Security-Policy` header is ignored, so it never sets * SANDBOXED_DOWNLOADS on the viewer's browsing context -- unlike the inherited * blob: case in the previous test. Either way a top-level PDF is downloadable, * so the download must succeed. */ add_task(async function test_top_level_pdf_supports_download() { await BrowserTestUtils.withNewTab( { gBrowser, url: "about:blank" }, async function (browser) { await waitForPdfJS(browser, CSP_PDF_URL); Assert.strictEqual( browser.browsingContext.sandboxFlags & SANDBOXED_DOWNLOADS, 0, "the PDF response's CSP sandbox directive must not set SANDBOXED_DOWNLOADS" ); const { filePickerShown, downloadFinished, cleanup } = await awaitNextDownload(); const spy = sinon.spy(PdfjsParent.prototype, "_saveURL"); try { info("Clicking the download button at top-level..."); await SpecialPowers.spawn(browser, [], () => { content.document.getElementById("downloadButton").click(); }); await filePickerShown; const download = await downloadFinished; ok(download.succeeded, "The top-level download succeeded"); is( spy.callCount, 1, "ChromeActions.download must forward saveURL at top-level" ); } finally { spy.restore(); cleanup(); } await waitForPdfJSClose(browser); } ); }); Loading
toolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs +22 −0 Original line number Diff line number Diff line Loading @@ -393,6 +393,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; Loading Loading @@ -423,6 +428,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 Loading @@ -440,6 +461,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: Loading
toolkit/components/pdfjs/content/web/viewer-geckoview.mjs +21 −1 Original line number Diff line number Diff line Loading @@ -604,6 +604,10 @@ const defaultOptions = { value: true, kind: OptionKind.BROWSER }, supportsDownloading: { value: true, kind: OptionKind.BROWSER }, supportsIntegratedFind: { value: false, kind: OptionKind.BROWSER Loading Loading @@ -8958,7 +8962,14 @@ const PDFViewerApplication = { externalLinkRel: AppOptions.get("externalLinkRel"), ignoreDestinationZoom: AppOptions.get("ignoreDestinationZoom") }); const downloadManager = this.downloadManager = new DownloadManager(); const supportsDownloading = AppOptions.get("supportsDownloading"); const downloadManager = this.downloadManager = supportsDownloading ? new DownloadManager() : null; if (appConfig.secondaryToolbar?.downloadButton) { appConfig.secondaryToolbar.downloadButton.hidden = !supportsDownloading; } if (appConfig.toolbar?.download) { appConfig.toolbar.download.hidden = !supportsDownloading; } const findController = this.findController = new PDFFindController({ linkService, eventBus, Loading Loading @@ -9388,6 +9399,9 @@ const PDFViewerApplication = { }); }, async download() { if (!this.downloadManager) { return; } let data; try { data = await (this.pdfDocument ? this.pdfDocument.getData() : this.pdfLoadingTask.getData()); Loading @@ -9395,6 +9409,9 @@ const PDFViewerApplication = { this.downloadManager.download(data, this._downloadUrl, this._docFilename); }, async save() { if (!this.downloadManager) { return; } if (this._saveInProgress) { return; } Loading @@ -9421,6 +9438,9 @@ const PDFViewerApplication = { } }, async downloadOrSave() { if (!this.downloadManager) { return; } const { classList } = this.appConfig.appContainer; Loading
toolkit/components/pdfjs/content/web/viewer.mjs +23 −3 Original line number Diff line number Diff line Loading @@ -604,6 +604,10 @@ const defaultOptions = { value: true, kind: OptionKind.BROWSER }, supportsDownloading: { value: true, kind: OptionKind.BROWSER }, supportsIntegratedFind: { value: false, kind: OptionKind.BROWSER Loading Loading @@ -3583,7 +3587,7 @@ class PDFAttachmentViewer extends BaseTreeViewer { element.title = description; } element.onclick = () => { this.downloadManager.openOrDownloadData(content, filename); this.downloadManager?.openOrDownloadData(content, filename); return false; }; } Loading Loading @@ -5751,7 +5755,7 @@ class PDFOutlineViewer extends BaseTreeViewer { if (attachment) { element.href = linkService.getAnchorUrl(""); element.onclick = () => { this.downloadManager.openOrDownloadData(attachment.content, attachment.filename); this.downloadManager?.openOrDownloadData(attachment.content, attachment.filename); return false; }; return; Loading Loading @@ -13570,7 +13574,14 @@ const PDFViewerApplication = { externalLinkRel: AppOptions.get("externalLinkRel"), ignoreDestinationZoom: AppOptions.get("ignoreDestinationZoom") }); const downloadManager = this.downloadManager = new DownloadManager(); const supportsDownloading = AppOptions.get("supportsDownloading"); const downloadManager = this.downloadManager = supportsDownloading ? new DownloadManager() : null; if (appConfig.secondaryToolbar?.downloadButton) { appConfig.secondaryToolbar.downloadButton.hidden = !supportsDownloading; } if (appConfig.toolbar?.download) { appConfig.toolbar.download.hidden = !supportsDownloading; } const findController = this.findController = new PDFFindController({ linkService, eventBus, Loading Loading @@ -13999,6 +14010,9 @@ const PDFViewerApplication = { }); }, async download() { if (!this.downloadManager) { return; } let data; try { data = await (this.pdfDocument ? this.pdfDocument.getData() : this.pdfLoadingTask.getData()); Loading @@ -14006,6 +14020,9 @@ const PDFViewerApplication = { this.downloadManager.download(data, this._downloadUrl, this._docFilename); }, async save() { if (!this.downloadManager) { return; } if (this._saveInProgress) { return; } Loading @@ -14032,6 +14049,9 @@ const PDFViewerApplication = { } }, async downloadOrSave() { if (!this.downloadManager) { return; } const { classList } = this.appConfig.appContainer; Loading
toolkit/components/pdfjs/test/browser.toml +7 −0 Original line number Diff line number Diff line Loading @@ -86,6 +86,13 @@ support-files = [ ["browser_pdfjs_response_link.js"] support-files = ["pdf_response_link.sjs"] ["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", Loading
toolkit/components/pdfjs/test/browser_pdfjs_sandboxed_iframe.js 0 → 100644 +381 −0 Original line number Diff line number Diff line /* 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(window.browsingContext); 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(); } await waitForPdfJSClose(newBrowser, true); } ); } ); /** * The `sandbox` directive carried by a PDF response's HTTP * `Content-Security-Policy` header is ignored, so it never sets * SANDBOXED_DOWNLOADS on the viewer's browsing context -- unlike the inherited * blob: case in the previous test. Either way a top-level PDF is downloadable, * so the download must succeed. */ add_task(async function test_top_level_pdf_supports_download() { await BrowserTestUtils.withNewTab( { gBrowser, url: "about:blank" }, async function (browser) { await waitForPdfJS(browser, CSP_PDF_URL); Assert.strictEqual( browser.browsingContext.sandboxFlags & SANDBOXED_DOWNLOADS, 0, "the PDF response's CSP sandbox directive must not set SANDBOXED_DOWNLOADS" ); const { filePickerShown, downloadFinished, cleanup } = await awaitNextDownload(); const spy = sinon.spy(PdfjsParent.prototype, "_saveURL"); try { info("Clicking the download button at top-level..."); await SpecialPowers.spawn(browser, [], () => { content.document.getElementById("downloadButton").click(); }); await filePickerShown; const download = await downloadFinished; ok(download.succeeded, "The top-level download succeeded"); is( spy.callCount, 1, "ChromeActions.download must forward saveURL at top-level" ); } finally { spy.restore(); cleanup(); } await waitForPdfJSClose(browser); } ); });