Add test cases for redirected responses
Regression tests for issue #12744 and PR #19028
This commit is contained in:
parent
28b0220bc2
commit
f97b4b9a66
@ -32,6 +32,7 @@ import {
|
|||||||
buildGetDocumentParams,
|
buildGetDocumentParams,
|
||||||
CMAP_URL,
|
CMAP_URL,
|
||||||
DefaultFileReaderFactory,
|
DefaultFileReaderFactory,
|
||||||
|
getCrossOriginHostname,
|
||||||
TEST_PDFS_PATH,
|
TEST_PDFS_PATH,
|
||||||
TestPdfsServer,
|
TestPdfsServer,
|
||||||
} from "./test_utils.js";
|
} from "./test_utils.js";
|
||||||
@ -2977,17 +2978,14 @@ describe("api", function () {
|
|||||||
let loadingTask;
|
let loadingTask;
|
||||||
function _checkCanLoad(expectSuccess, filename, options) {
|
function _checkCanLoad(expectSuccess, filename, options) {
|
||||||
if (isNodeJS) {
|
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");
|
pending("Cannot simulate cross-origin requests in Node.js");
|
||||||
}
|
}
|
||||||
const params = buildGetDocumentParams(filename, options);
|
const params = buildGetDocumentParams(filename, options);
|
||||||
const url = new URL(params.url);
|
const url = new URL(params.url);
|
||||||
if (url.hostname === "localhost") {
|
url.hostname = getCrossOriginHostname(url.hostname);
|
||||||
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!");
|
|
||||||
}
|
|
||||||
params.url = url.href;
|
params.url = url.href;
|
||||||
loadingTask = getDocument(params);
|
loadingTask = getDocument(params);
|
||||||
return loadingTask.promise
|
return loadingTask.promise
|
||||||
|
|||||||
90
test/unit/common_pdfstream_tests.js
Normal file
90
test/unit/common_pdfstream_tests.js
Normal file
@ -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);
|
||||||
|
// ^ When range requests are supported (and streaming is disabled), the full
|
||||||
|
// initial request is aborted and we do not need to call fullReader.cancel().
|
||||||
|
|
||||||
|
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 };
|
||||||
@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import { AbortException } from "../../src/shared/util.js";
|
import { AbortException } from "../../src/shared/util.js";
|
||||||
import { PDFFetchStream } from "../../src/display/fetch_stream.js";
|
import { PDFFetchStream } from "../../src/display/fetch_stream.js";
|
||||||
|
import { testCrossOriginRedirects } from "./common_pdfstream_tests.js";
|
||||||
import { TestPdfsServer } from "./test_utils.js";
|
import { TestPdfsServer } from "./test_utils.js";
|
||||||
|
|
||||||
describe("fetch_stream", function () {
|
describe("fetch_stream", function () {
|
||||||
@ -116,4 +117,33 @@ describe("fetch_stream", function () {
|
|||||||
expect(result1.value).toEqual(rangeSize);
|
expect(result1.value).toEqual(rangeSize);
|
||||||
expect(result2.value).toEqual(tailSize);
|
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:.*"\.$/
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
import { AbortException } from "../../src/shared/util.js";
|
import { AbortException } from "../../src/shared/util.js";
|
||||||
import { PDFNetworkStream } from "../../src/display/network.js";
|
import { PDFNetworkStream } from "../../src/display/network.js";
|
||||||
|
import { testCrossOriginRedirects } from "./common_pdfstream_tests.js";
|
||||||
|
import { TestPdfsServer } from "./test_utils.js";
|
||||||
|
|
||||||
describe("network", function () {
|
describe("network", function () {
|
||||||
const pdf1 = new URL("../pdfs/tracemonkey.pdf", window.location).href;
|
const pdf1 = new URL("../pdfs/tracemonkey.pdf", window.location).href;
|
||||||
@ -115,4 +117,41 @@ describe("network", function () {
|
|||||||
expect(isRangeSupported).toEqual(true);
|
expect(isRangeSupported).toEqual(true);
|
||||||
expect(fullReaderCancelled).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:.*"\.$/
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -51,6 +51,22 @@ function buildGetDocumentParams(filename, options) {
|
|||||||
return params;
|
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 {
|
class XRefMock {
|
||||||
constructor(array) {
|
constructor(array) {
|
||||||
this._map = Object.create(null);
|
this._map = Object.create(null);
|
||||||
@ -216,6 +232,7 @@ export {
|
|||||||
CMAP_URL,
|
CMAP_URL,
|
||||||
createIdFactory,
|
createIdFactory,
|
||||||
DefaultFileReaderFactory,
|
DefaultFileReaderFactory,
|
||||||
|
getCrossOriginHostname,
|
||||||
STANDARD_FONT_DATA_URL,
|
STANDARD_FONT_DATA_URL,
|
||||||
TEST_PDFS_PATH,
|
TEST_PDFS_PATH,
|
||||||
TestPdfsServer,
|
TestPdfsServer,
|
||||||
|
|||||||
@ -52,7 +52,7 @@ class WebServer {
|
|||||||
this.cacheExpirationTime = cacheExpirationTime || 0;
|
this.cacheExpirationTime = cacheExpirationTime || 0;
|
||||||
this.disableRangeRequests = false;
|
this.disableRangeRequests = false;
|
||||||
this.hooks = {
|
this.hooks = {
|
||||||
GET: [crossOriginHandler],
|
GET: [crossOriginHandler, redirectHandler],
|
||||||
POST: [],
|
POST: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -308,6 +308,11 @@ class WebServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#serveFileRange(response, fileURL, fileSize, start, end) {
|
#serveFileRange(response, fileURL, fileSize, start, end) {
|
||||||
|
if (end > fileSize || start > end) {
|
||||||
|
response.writeHead(416);
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const stream = fs.createReadStream(fileURL, {
|
const stream = fs.createReadStream(fileURL, {
|
||||||
flags: "rs",
|
flags: "rs",
|
||||||
start,
|
start,
|
||||||
@ -336,18 +341,65 @@ class WebServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// This supports the "Cross-origin" test in test/unit/api_spec.js
|
// 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
|
// expected if the user does "gulp server" and then visits
|
||||||
// http://localhost:8888/test/unit/unit_test.html?spec=Cross-origin
|
// http://localhost:8888/test/unit/unit_test.html?spec=Cross-origin
|
||||||
function crossOriginHandler(url, request, response) {
|
function crossOriginHandler(url, request, response) {
|
||||||
if (url.pathname === "/test/pdfs/basicapi.pdf") {
|
if (url.pathname === "/test/pdfs/basicapi.pdf") {
|
||||||
if (url.searchParams.get("cors") === "withCredentials") {
|
if (!url.searchParams.has("cors") || !request.headers.origin) {
|
||||||
response.setHeader("Access-Control-Allow-Origin", request.headers.origin);
|
return;
|
||||||
response.setHeader("Access-Control-Allow-Credentials", "true");
|
|
||||||
} else if (url.searchParams.get("cors") === "withoutCredentials") {
|
|
||||||
response.setHeader("Access-Control-Allow-Origin", request.headers.origin);
|
|
||||||
}
|
}
|
||||||
|
response.setHeader("Access-Control-Allow-Origin", request.headers.origin);
|
||||||
|
if (url.searchParams.get("cors") === "withCredentials") {
|
||||||
|
response.setHeader("Access-Control-Allow-Credentials", "true");
|
||||||
|
} // 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) {
|
||||||
|
// Chrome may serve byte range requests directly from the cache, potentially
|
||||||
|
// from a full request or a different range, without involving the server.
|
||||||
|
// To prevent this from happening, make sure that the response is never
|
||||||
|
// cached, so that Range requests are never served from the browser cache.
|
||||||
|
response.setHeader("Cache-Control", "no-store,max-age=0");
|
||||||
|
|
||||||
|
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 };
|
export { WebServer };
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user