diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index d73d23225725fd..8d9481931d68b5 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 { @@ -2438,12 +2439,12 @@ describe("api", function () { const manifesto = ` The Mozilla Manifesto Addendum Pledge for a Healthy Internet - + The open, global internet is the most powerful communication and collaboration resource we have ever seen. It embodies some of our deepest hopes for human progress. It enables new opportunities for learning, building a sense of shared humanity, and solving the pressing problems facing people everywhere. - + Over the last decade we have seen this promise fulfilled in many ways. We have also seen the power of the internet used to magnify divisiveness, incite violence, promote hatred, and intentionally manipulate fact and reality. @@ -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 9a55b4771ff0eb..649fa23f6ca829 100644 --- a/test/unit/network_spec.js +++ b/test/unit/network_spec.js @@ -14,9 +14,12 @@ */ import { AbortException } from "../../src/shared/util.js"; +import { getCrossOriginHostname } from "./test_utils.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; @@ -115,4 +118,76 @@ describe("network", function () { expect(isRangeSupported).toEqual(true); expect(fullReaderCancelled).toEqual(true); }); + + describe("Redirects", function () { + async function simulateFetchFullAndSomeRange(url, readMoreThanOnce) { + const rangeSize = 32768; + const stream = new PDFNetworkStream({ + 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:.*"\.$/ + ); + }); + }); }); diff --git a/test/unit/test_utils.js b/test/unit/test_utils.js index 5ca113989df68d..ec3ce3cbad24d6 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 e0295f3a2865e6..da3c7dd8190569 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 };