From 914df5d51af3dcbe398afb31f937b1ff5cff1a47 Mon Sep 17 00:00:00 2001 From: Rob Wu Date: Wed, 20 Nov 2024 02:42:13 +0100 Subject: [PATCH] Add test cases for redirected responses Regression tests for issue #12744 and PR #19028 --- test/unit/api_spec.js | 12 +++--- test/unit/network_spec.js | 86 +++++++++++++++++++++++++++++++++++++++ test/unit/test_utils.js | 17 ++++++++ test/webserver.mjs | 50 ++++++++++++++++++++--- 4 files changed, 153 insertions(+), 12 deletions(-) diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index d73d23225725f..d809043788d7d 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -33,6 +33,7 @@ import { CMAP_URL, createTemporaryNodeServer, DefaultFileReaderFactory, + getCrossOriginHostname, TEST_PDFS_PATH, } from "./test_utils.js"; import { @@ -2989,17 +2990,14 @@ describe("api", function () { let loadingTask; function _checkCanLoad(expectSuccess, filename, options) { if (isNodeJS) { + // On Node.js, we only support loading file:-URLs. + // Moreover, Node.js does not enforce the Same-origin policy, so + // CORS cannot be tested on Node.js. pending("Cannot simulate cross-origin requests in Node.js"); } const params = buildGetDocumentParams(filename, options); const url = new URL(params.url); - if (url.hostname === "localhost") { - url.hostname = "127.0.0.1"; - } else if (params.url.hostname === "127.0.0.1") { - url.hostname = "localhost"; - } else { - pending("Can only run cross-origin test on localhost!"); - } + url.hostname = getCrossOriginHostname(url.hostname); params.url = url.href; loadingTask = getDocument(params); return loadingTask.promise diff --git a/test/unit/network_spec.js b/test/unit/network_spec.js index e8b4b9f4c8e1e..cc89814e4ec4c 100644 --- a/test/unit/network_spec.js +++ b/test/unit/network_spec.js @@ -14,9 +14,13 @@ */ import { AbortException } from "../../src/shared/util.js"; +import { getCrossOriginHostname } from "./test_utils.js"; +import { PDFFetchStream } from "../../src/display/fetch_stream.js"; import { PDFNetworkStream } from "../../src/display/network.js"; describe("network", function () { + const basicApiUrl = new URL("../pdfs/basicapi.pdf", window.location).href; + const basicApiFileLength = 105779; const pdf1 = new URL("../pdfs/tracemonkey.pdf", window.location).href; const pdf1Length = 1016315; @@ -116,4 +120,86 @@ describe("network", function () { expect(isRangeSupported).toEqual(true); expect(fullReaderCancelled).toEqual(true); }); + + // Common tests to verify behavior across implementations of the IPDFStream + // interface (PDFNetworkStream / PDFFetchStream). + function describeRedirectTests(PDFStreamClass) { + async function simulateFetchFullAndSomeRange(url, readMoreThanOnce) { + const rangeSize = 32768; + const stream = new PDFStreamClass({ + url, + length: basicApiFileLength, + rangeChunkSize: rangeSize, + disableStream: true, + disableRange: false, + }); + + const fullReader = stream.getFullReader(); + + await fullReader.headersReady; + // Sanity check: We can only test range requests if supported: + expect(fullReader.isRangeSupported).toEqual(false); + + fullReader.cancel(new AbortException("Don't need fullReader.")); + + const rangeReader = stream.getRangeReader( + basicApiFileLength - rangeSize, + basicApiFileLength + ); + // May throw or not throw - the caller will verify it: + await rangeReader.read(); + if (readMoreThanOnce) { + await rangeReader.read(); + } + rangeReader.cancel(new AbortException("Don't need rangeReader")); + } + function getCrossOriginUrlWithRedirects({ redirectIfRange = false }) { + const url = new URL(basicApiUrl); + // The responses are going to be cross-origin, and therefore we need CORS + // to read it. This option depends on crossOriginHandler in webserver.mjs. + url.searchParams.set("cors", "withoutCredentials"); + + // This redirect options depend on redirectHandler in webserver.mjs. + + // We will change the host to a cross-origin domain so that the initial + // request will be cross-origin. Set "redirectToHost" to the original host + // to force a cross-origin redirect (relative to the initial URL). + url.searchParams.set("redirectToHost", url.hostname); + url.hostname = getCrossOriginHostname(url.hostname); + if (redirectIfRange) { + url.searchParams.set("redirectIfRange", "1"); + } + return url.href; + } + it("redirects allowed if all responses are same-origin", async function () { + const pdfUrl = getCrossOriginUrlWithRedirects({ redirectIfRange: false }); + await expectAsync(simulateFetchFullAndSomeRange(pdfUrl)).toBeResolved(); + }); + + it("redirects blocked if any response is cross-origin, read once", async function () { + const pdfUrl = getCrossOriginUrlWithRedirects({ redirectIfRange: true }); + await expectAsync( + simulateFetchFullAndSomeRange(pdfUrl) + ).toBeRejectedWithError( + /^Expected range response-origin "http:.*" to match "http:.*"\.$/ + ); + }); + + it("redirects blocked if any response is cross-origin, read again", async function () { + const pdfUrl = getCrossOriginUrlWithRedirects({ redirectIfRange: true }); + await expectAsync( + simulateFetchFullAndSomeRange(pdfUrl, true) + ).toBeRejectedWithError( + /^Expected range response-origin "http:.*" to match "http:.*"\.$/ + ); + }); + } + describe("Redirects", function () { + describe("PDFFetchStream", function () { + describeRedirectTests(PDFFetchStream); + }); + describe("PDFNetworkStream", function () { + describeRedirectTests(PDFNetworkStream); + }); + }); }); diff --git a/test/unit/test_utils.js b/test/unit/test_utils.js index 5ca113989df68..ec3ce3cbad24d 100644 --- a/test/unit/test_utils.js +++ b/test/unit/test_utils.js @@ -51,6 +51,22 @@ function buildGetDocumentParams(filename, options) { return params; } +function getCrossOriginHostname(hostname) { + if (hostname === "localhost") { + // Note: This does not work if localhost is listening on IPv6 only. + // As a work-around, visit the IPv6 version at: + // http://[::1]:8888/test/unit/unit_test.html?spec=Cross-origin + return "127.0.0.1"; + } + + if (hostname === "127.0.0.1" || hostname === "[::1]") { + return "localhost"; + } + + // FQDN are cross-origin and browsers usually resolve them to the same server. + return hostname.endsWith(".") ? hostname.slice(0, -1) : hostname + "."; +} + class XRefMock { constructor(array) { this._map = Object.create(null); @@ -174,6 +190,7 @@ export { createIdFactory, createTemporaryNodeServer, DefaultFileReaderFactory, + getCrossOriginHostname, STANDARD_FONT_DATA_URL, TEST_PDFS_PATH, XRefMock, diff --git a/test/webserver.mjs b/test/webserver.mjs index e0295f3a2865e..da3c7dd819056 100644 --- a/test/webserver.mjs +++ b/test/webserver.mjs @@ -52,7 +52,7 @@ class WebServer { this.cacheExpirationTime = cacheExpirationTime || 0; this.disableRangeRequests = false; this.hooks = { - GET: [crossOriginHandler], + GET: [crossOriginHandler, redirectHandler], POST: [], }; } @@ -308,6 +308,11 @@ class WebServer { } #serveFileRange(response, fileURL, fileSize, start, end) { + if (end > fileSize || start > end) { + response.writeHead(416); + response.end(); + return; + } const stream = fs.createReadStream(fileURL, { flags: "rs", start, @@ -336,18 +341,53 @@ class WebServer { } // This supports the "Cross-origin" test in test/unit/api_spec.js -// It is here instead of test.js so that when the test will still complete as +// and "Redirects" in test/unit/network_spec.js +// It is here instead of test.mjs so that when the test will still complete as // expected if the user does "gulp server" and then visits // http://localhost:8888/test/unit/unit_test.html?spec=Cross-origin function crossOriginHandler(url, request, response) { if (url.pathname === "/test/pdfs/basicapi.pdf") { + if (!url.searchParams.has("cors") || !request.headers.origin) { + return; + } + response.setHeader("Access-Control-Allow-Origin", request.headers.origin); if (url.searchParams.get("cors") === "withCredentials") { - response.setHeader("Access-Control-Allow-Origin", request.headers.origin); response.setHeader("Access-Control-Allow-Credentials", "true"); - } else if (url.searchParams.get("cors") === "withoutCredentials") { - response.setHeader("Access-Control-Allow-Origin", request.headers.origin); + } // withoutCredentials does not include Access-Control-Allow-Credentials. + response.setHeader("Access-Control-Expose-Headers", "Content-Range"); + } +} + +// This supports the "Redirects" test in test/unit/network_spec.js +// It is here instead of test.mjs so that when the test will still complete as +// expected if the user does "gulp server" and then visits +// http://localhost:8888/test/unit/unit_test.html?spec=Redirects +function redirectHandler(url, request, response) { + const redirectToHost = url.searchParams.get("redirectToHost"); + if (redirectToHost) { + if (url.searchParams.get("redirectIfRange") && !request.headers.range) { + return false; + } + try { + const newURL = new URL(url); + newURL.hostname = redirectToHost; + // Delete test-only query parameters to avoid infinite redirects. + newURL.searchParams.delete("redirectToHost"); + newURL.searchParams.delete("redirectIfRange"); + if (newURL.hostname !== redirectToHost) { + throw new Error(`Invalid hostname: ${redirectToHost}`); + } + response.setHeader("Location", newURL.href); + } catch { + response.writeHead(500); + response.end(); + return true; } + response.writeHead(302); + response.end(); + return true; } + return false; } export { WebServer };