From cba911df86092e4dc0ecfeb17dcb3dfbbbd134e4 Mon Sep 17 00:00:00 2001 From: calixteman Date: Sat, 1 Aug 2026 16:05:30 +0200 Subject: [PATCH] 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. --- src/display/network.js | 5 ++--- src/display/network_utils.js | 12 +++++++++++ test/unit/network_utils_spec.js | 35 +++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/display/network.js b/src/display/network.js index 471203828..9f6ed5f55 100644 --- a/src/display/network.js +++ b/src/display/network.js @@ -25,6 +25,7 @@ import { ensureResponseOrigin, extractFilenameFromHeader, getResponseOrigin, + trimHeadersEnd, validateRangeRequestCapabilities, } from "./network_utils.js"; import { endRequests } from "./transport_stream.js"; @@ -208,9 +209,7 @@ class PDFNetworkStreamReader extends BasePDFStreamReader { const rawResponseHeaders = fullRequestXhr.getAllResponseHeaders(); const responseHeaders = new Headers( rawResponseHeaders - ? rawResponseHeaders - .trimStart() - .replace(/[^\S ]+$/, "") // Not `trimEnd`, to keep regular spaces. + ? trimHeadersEnd(rawResponseHeaders.trimStart()) .split(/[\r\n]+/) .map(x => { const [key, ...val] = x.split(": "); diff --git a/src/display/network_utils.js b/src/display/network_utils.js index bd2779706..73e9123f9 100644 --- a/src/display/network_utils.js +++ b/src/display/network_utils.js @@ -32,6 +32,17 @@ function createHeaders(isHttp, httpHeaders) { 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) { // Notably, null is distinct from "null" string (e.g. from file:-URLs). return URL.parse(url)?.origin ?? null; @@ -117,5 +128,6 @@ export { ensureResponseOrigin, extractFilenameFromHeader, getResponseOrigin, + trimHeadersEnd, validateRangeRequestCapabilities, }; diff --git a/test/unit/network_utils_spec.js b/test/unit/network_utils_spec.js index d3782d4bd..20a184548 100644 --- a/test/unit/network_utils_spec.js +++ b/test/unit/network_utils_spec.js @@ -17,6 +17,7 @@ import { createHeaders, createResponseError, extractFilenameFromHeader, + trimHeadersEnd, validateRangeRequestCapabilities, } from "../../src/display/network_utils.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); }); }); + + 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); + }); + }); });