Trim the response headers with a backward scan

Removing the trailing whitespace with a `$`-anchored regex is
quadratic in the length of the run, which a server controls.
The helper lives in network_utils.js, to be unit testable.
This commit is contained in:
calixteman 2026-08-01 16:05:30 +02:00
parent 7fc7072f9c
commit cba911df86
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
3 changed files with 49 additions and 3 deletions

View File

@ -25,6 +25,7 @@ import {
ensureResponseOrigin, ensureResponseOrigin,
extractFilenameFromHeader, extractFilenameFromHeader,
getResponseOrigin, getResponseOrigin,
trimHeadersEnd,
validateRangeRequestCapabilities, validateRangeRequestCapabilities,
} from "./network_utils.js"; } from "./network_utils.js";
import { endRequests } from "./transport_stream.js"; import { endRequests } from "./transport_stream.js";
@ -208,9 +209,7 @@ class PDFNetworkStreamReader extends BasePDFStreamReader {
const rawResponseHeaders = fullRequestXhr.getAllResponseHeaders(); const rawResponseHeaders = fullRequestXhr.getAllResponseHeaders();
const responseHeaders = new Headers( const responseHeaders = new Headers(
rawResponseHeaders rawResponseHeaders
? rawResponseHeaders ? trimHeadersEnd(rawResponseHeaders.trimStart())
.trimStart()
.replace(/[^\S ]+$/, "") // Not `trimEnd`, to keep regular spaces.
.split(/[\r\n]+/) .split(/[\r\n]+/)
.map(x => { .map(x => {
const [key, ...val] = x.split(": "); const [key, ...val] = x.split(": ");

View File

@ -32,6 +32,17 @@ function createHeaders(isHttp, httpHeaders) {
return headers; return headers;
} }
// Trim the trailing whitespace of the raw response headers, but keep the
// regular spaces (hence no `trimEnd`). Scanning backwards keeps this linear,
// whereas a `$`-anchored regex is quadratic in the length of the run.
function trimHeadersEnd(str) {
let end = str.length;
while (end > 0 && str[end - 1] !== " " && /\s/.test(str[end - 1])) {
end--;
}
return str.slice(0, end);
}
function getResponseOrigin(url) { function getResponseOrigin(url) {
// Notably, null is distinct from "null" string (e.g. from file:-URLs). // Notably, null is distinct from "null" string (e.g. from file:-URLs).
return URL.parse(url)?.origin ?? null; return URL.parse(url)?.origin ?? null;
@ -117,5 +128,6 @@ export {
ensureResponseOrigin, ensureResponseOrigin,
extractFilenameFromHeader, extractFilenameFromHeader,
getResponseOrigin, getResponseOrigin,
trimHeadersEnd,
validateRangeRequestCapabilities, validateRangeRequestCapabilities,
}; };

View File

@ -17,6 +17,7 @@ import {
createHeaders, createHeaders,
createResponseError, createResponseError,
extractFilenameFromHeader, extractFilenameFromHeader,
trimHeadersEnd,
validateRangeRequestCapabilities, validateRangeRequestCapabilities,
} from "../../src/display/network_utils.js"; } from "../../src/display/network_utils.js";
import { ResponseException } from "../../src/shared/util.js"; import { ResponseException } from "../../src/shared/util.js";
@ -386,4 +387,38 @@ describe("network_utils", function () {
testCreateResponseError(new URL("https://foo.com/bar.pdf"), 0, false); testCreateResponseError(new URL("https://foo.com/bar.pdf"), 0, false);
}); });
}); });
describe("trimHeadersEnd", function () {
it("removes the trailing whitespace", function () {
expect(trimHeadersEnd("a: 1\r\nb: 2\r\n")).toEqual("a: 1\r\nb: 2");
expect(trimHeadersEnd("a: 1\n\n")).toEqual("a: 1");
expect(trimHeadersEnd("a: 1\t\r\n")).toEqual("a: 1");
});
it("keeps the regular spaces", function () {
expect(trimHeadersEnd("a: 1 ")).toEqual("a: 1 ");
expect(trimHeadersEnd("a: 1\r\n ")).toEqual("a: 1\r\n ");
expect(trimHeadersEnd(" ")).toEqual(" ");
});
it("handles strings without trailing whitespace", function () {
expect(trimHeadersEnd("")).toEqual("");
expect(trimHeadersEnd("a: 1")).toEqual("a: 1");
expect(trimHeadersEnd("\r\na: 1")).toEqual("\r\na: 1");
});
it("handles a long run of whitespace efficiently", function () {
// Removing the run with a `$`-anchored regex is quadratic in its length,
// and a server controls how long the headers are.
const run = "\t".repeat(100000);
const startTime = performance.now();
// The run is trailing, hence removed.
expect(trimHeadersEnd(`a: 1${run}`)).toEqual("a: 1");
// The run is followed by a non-whitespace, hence kept: this is the case
// which a regex has to backtrack over.
expect(trimHeadersEnd(`a: 1${run}b`)).toEqual(`a: 1${run}b`);
expect(performance.now() - startTime).toBeLessThan(1000);
});
});
}); });