Verified Commit 81d1572b authored by Calixte Denizet's avatar Calixte Denizet Committed by ma1
Browse files

Bug 2037770 - Honor iframe sandbox flags when handling pdf.js downloads a=RyanVM DONTBUILD

parent 04ded981
Loading
Loading
Loading
Loading
+22 −0
Original line number Diff line number Diff line
@@ -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;
@@ -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
@@ -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:
+21 −1
Original line number Diff line number Diff line
@@ -604,6 +604,10 @@ const defaultOptions = {
    value: true,
    kind: OptionKind.BROWSER
  },
  supportsDownloading: {
    value: true,
    kind: OptionKind.BROWSER
  },
  supportsIntegratedFind: {
    value: false,
    kind: OptionKind.BROWSER
@@ -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,
@@ -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());
@@ -9395,6 +9409,9 @@ const PDFViewerApplication = {
    this.downloadManager.download(data, this._downloadUrl, this._docFilename);
  },
  async save() {
    if (!this.downloadManager) {
      return;
    }
    if (this._saveInProgress) {
      return;
    }
@@ -9421,6 +9438,9 @@ const PDFViewerApplication = {
    }
  },
  async downloadOrSave() {
    if (!this.downloadManager) {
      return;
    }
    const {
      classList
    } = this.appConfig.appContainer;
+23 −3
Original line number Diff line number Diff line
@@ -604,6 +604,10 @@ const defaultOptions = {
    value: true,
    kind: OptionKind.BROWSER
  },
  supportsDownloading: {
    value: true,
    kind: OptionKind.BROWSER
  },
  supportsIntegratedFind: {
    value: false,
    kind: OptionKind.BROWSER
@@ -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;
    };
  }
@@ -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;
@@ -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,
@@ -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());
@@ -14006,6 +14020,9 @@ const PDFViewerApplication = {
    this.downloadManager.download(data, this._downloadUrl, this._docFilename);
  },
  async save() {
    if (!this.downloadManager) {
      return;
    }
    if (this._saveInProgress) {
      return;
    }
@@ -14032,6 +14049,9 @@ const PDFViewerApplication = {
    }
  },
  async downloadOrSave() {
    if (!this.downloadManager) {
      return;
    }
    const {
      classList
    } = this.appConfig.appContainer;
+7 −0
Original line number Diff line number Diff line
@@ -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",
+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