Skip to content

Commit

Permalink
Add test cases for redirected responses
Browse files Browse the repository at this point in the history
Regression tests for issue #12744 and PR #19028
  • Loading branch information
Rob--W committed Nov 25, 2024
1 parent 5ce54c7 commit b23e9a4
Show file tree
Hide file tree
Showing 6 changed files with 232 additions and 12 deletions.
12 changes: 5 additions & 7 deletions test/unit/api_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
buildGetDocumentParams,
CMAP_URL,
DefaultFileReaderFactory,
getCrossOriginHostname,
TEST_PDFS_PATH,
TestPdfsServer,
} from "./test_utils.js";
Expand Down Expand Up @@ -2977,17 +2978,14 @@ describe("api", function () {
let loadingTask;
function _checkCanLoad(expectSuccess, filename, options) {
if (isNodeJS) {
// We can simulate cross-origin requests, but since Node.js does not
// enforce the Same Origin Policy, requests are expected to be allowed
// independently of withCredentials.
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
Expand Down
90 changes: 90 additions & 0 deletions test/unit/common_pdfstream_tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/* Copyright 2024 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { AbortException, isNodeJS } from "../../src/shared/util.js";
import { getCrossOriginHostname, TestPdfsServer } from "./test_utils.js";

// Common tests to verify behavior across implementations of the IPDFStream
// interface:
// - PDFNetworkStream by network_spec.js
// - PDFFetchStream by fetch_stream_spec.js
async function testCrossOriginRedirects({
PDFStreamClass,
redirectIfRange,
testRangeReader,
}) {
const basicApiUrl = TestPdfsServer.resolveURL("basicapi.pdf").href;
const basicApiFileLength = 105779;

const rangeSize = 32768;
const stream = new PDFStreamClass({
url: getCrossOriginUrlWithRedirects(basicApiUrl, redirectIfRange),
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(true);

fullReader.cancel(new AbortException("Don't need fullReader."));

const rangeReader = stream.getRangeReader(
basicApiFileLength - rangeSize,
basicApiFileLength
);

try {
await testRangeReader(rangeReader);
} finally {
rangeReader.cancel(new AbortException("Don't need rangeReader"));
}
}

/**
* @param {string} testserverUrl - A URL handled that supports CORS and
* redirects (see crossOriginHandler and redirectHandler in webserver.mjs).
* @param {boolean} redirectIfRange - Whether Range requests should be
* redirected to a different origin compared to the initial request.
* @returns {string} A URL that will be redirected by the server.
*/
function getCrossOriginUrlWithRedirects(testserverUrl, redirectIfRange) {
const url = new URL(testserverUrl);
if (!isNodeJS) {
// The responses are going to be cross-origin. In Node.js, fetch() allows
// cross-origin requests for any request, but in browser environments we
// need to enable CORS.
// 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;
}

export { testCrossOriginRedirects };
30 changes: 30 additions & 0 deletions test/unit/fetch_stream_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import { AbortException } from "../../src/shared/util.js";
import { PDFFetchStream } from "../../src/display/fetch_stream.js";
import { testCrossOriginRedirects } from "./common_pdfstream_tests.js";
import { TestPdfsServer } from "./test_utils.js";

describe("fetch_stream", function () {
Expand Down Expand Up @@ -116,4 +117,33 @@ describe("fetch_stream", function () {
expect(result1.value).toEqual(rangeSize);
expect(result2.value).toEqual(tailSize);
});

describe("Redirects", function () {
it("redirects allowed if all responses are same-origin", async function () {
await testCrossOriginRedirects({
PDFStreamClass: PDFFetchStream,
redirectIfRange: false,
async testRangeReader(rangeReader) {
await expectAsync(rangeReader.read()).toBeResolved();
},
});
});

it("redirects blocked if any response is cross-origin", async function () {
await testCrossOriginRedirects({
PDFStreamClass: PDFFetchStream,
redirectIfRange: true,
async testRangeReader(rangeReader) {
// When read (sync), error should be reported.
await expectAsync(rangeReader.read()).toBeRejectedWithError(
/^Expected range response-origin "http:.*" to match "http:.*"\.$/
);
// When read again (async), error should be consistent.
await expectAsync(rangeReader.read()).toBeRejectedWithError(
/^Expected range response-origin "http:.*" to match "http:.*"\.$/
);
},
});
});
});
});
39 changes: 39 additions & 0 deletions test/unit/network_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

import { AbortException } from "../../src/shared/util.js";
import { PDFNetworkStream } from "../../src/display/network.js";
import { testCrossOriginRedirects } from "./common_pdfstream_tests.js";
import { TestPdfsServer } from "./test_utils.js";

describe("network", function () {
const pdf1 = new URL("../pdfs/tracemonkey.pdf", window.location).href;
Expand Down Expand Up @@ -115,4 +117,41 @@ describe("network", function () {
expect(isRangeSupported).toEqual(true);
expect(fullReaderCancelled).toEqual(true);
});

describe("Redirects", function () {
beforeAll(async function () {
await TestPdfsServer.ensureStarted();
});

afterAll(async function () {
await TestPdfsServer.ensureStopped();
});

it("redirects allowed if all responses are same-origin", async function () {
await testCrossOriginRedirects({
PDFStreamClass: PDFNetworkStream,
redirectIfRange: false,
async testRangeReader(rangeReader) {
await expectAsync(rangeReader.read()).toBeResolved();
},
});
});

it("redirects blocked if any response is cross-origin", async function () {
await testCrossOriginRedirects({
PDFStreamClass: PDFNetworkStream,
redirectIfRange: true,
async testRangeReader(rangeReader) {
// When read (sync), error should be reported.
await expectAsync(rangeReader.read()).toBeRejectedWithError(
/^Expected range response-origin "http:.*" to match "http:.*"\.$/
);
// When read again (async), error should be consistent.
await expectAsync(rangeReader.read()).toBeRejectedWithError(
/^Expected range response-origin "http:.*" to match "http:.*"\.$/
);
},
});
});
});
});
17 changes: 17 additions & 0 deletions test/unit/test_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -205,6 +221,7 @@ export {
CMAP_URL,
createIdFactory,
DefaultFileReaderFactory,
getCrossOriginHostname,
STANDARD_FONT_DATA_URL,
TEST_PDFS_PATH,
TestPdfsServer,
Expand Down
56 changes: 51 additions & 5 deletions test/webserver.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class WebServer {
this.cacheExpirationTime = cacheExpirationTime || 0;
this.disableRangeRequests = false;
this.hooks = {
GET: [crossOriginHandler],
GET: [crossOriginHandler, redirectHandler],
POST: [],
};
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -336,18 +341,59 @@ 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 and
// test/unit/fetch_stream_spec.js via test/unit/common_pdfstream_tests.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",
"Accept-Ranges,Content-Range"
);
response.setHeader("Vary", "Origin");
}
}

// This supports the "Redirects" test in test/unit/network_spec.js and
// test/unit/fetch_stream_spec.js via test/unit/common_pdfstream_tests.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 };

0 comments on commit b23e9a4

Please sign in to comment.