From 66dae5c2f0b5581c60b4a36f41ca14abca3c7142 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 30 Sep 2026 15:50:46 -0400 Subject: [PATCH] fix(cli): split trailing punctuation off redacted URLs in linear time redactUrlsInText stripped a URL's trailing sentence punctuation with the unanchored regex /[.,;:!?)\]']+$/, which rescans a punctuation run from every start position when the run does not end the match: quadratic. The text is server-controlled (an HTTP error body lands in the error message), so a body of http://a/? plus a long run of '!' before an 'x' stalled the CLI's error path: 60k characters took 3s, against 0s on 2.8.0. CodeQL flagged it high on the v2.9.0 merge PR (#2536). Replace it with a backward scan over the same character set. Behavior is unchanged; the same probe now answers in ~180ms at 60k and at 400k. Closes #2540 Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- clients/cli/__tests__/error-handler.test.ts | 11 ++++++++ clients/cli/src/error-handler.ts | 31 ++++++++++++++++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/clients/cli/__tests__/error-handler.test.ts b/clients/cli/__tests__/error-handler.test.ts index b2491f88e..342a7b4b9 100644 --- a/clients/cli/__tests__/error-handler.test.ts +++ b/clients/cli/__tests__/error-handler.test.ts @@ -394,6 +394,17 @@ describe("envelope URL redaction", () => { expect(envelope.message).not.toContain("tok456"); }); + it("keeps a punctuation run inside the URL and splits only the trailing one", () => { + // A long run that does not end the match was quadratic under the old + // unanchored /[…]+$/ (#2540); the backward scan must still stop at `x`. + const run = "!".repeat(50_000); + const { envelope } = classifyError( + new Error(`see https://srv.example/cb?note=${run}x&code=abc123!?`), + ); + expect(envelope.message).toMatch(/x&code=%5BREDACTED%5D!\?$/); + expect(envelope.message).not.toContain("abc123"); + }); + it("redacts a URL whose scheme is upper- or mixed-case", () => { const { envelope } = classifyError( new Error( diff --git a/clients/cli/src/error-handler.ts b/clients/cli/src/error-handler.ts index 7d667084a..9f49dbecc 100644 --- a/clients/cli/src/error-handler.ts +++ b/clients/cli/src/error-handler.ts @@ -156,7 +156,31 @@ const UNREACHABLE_PATTERN = const EMBEDDED_URL_PATTERN = /\bhttps?:\/\/(?:(?!https?:\/\/)[^\s"<>])+/gi; /** Sentence punctuation (or a closing quote) a message may put right after a URL. */ -const TRAILING_PUNCTUATION = /[.,;:!?)\]']+$/; +const TRAILING_PUNCTUATION = new Set([ + ".", + ",", + ";", + ":", + "!", + "?", + ")", + "]", + "'", +]); + +/** + * Length of `match` once its trailing {@link TRAILING_PUNCTUATION} run is + * removed. A backward scan rather than an unanchored `/[…]+$/`: that regex + * rescans a punctuation run from every start position when the run does not + * end the string, which is quadratic, and the text here is server-controlled + * (an HTTP error body lands in the message), so a long `!!!…x` stalled the + * CLI's error path (#2540). + */ +function trailingPunctuationStart(match: string): number { + let end = match.length; + while (end > 0 && TRAILING_PUNCTUATION.has(match.charAt(end - 1))) end--; + return end; +} /** * Apply {@link redactUrlQuery} to every URL embedded in `text`. Trailing @@ -166,9 +190,8 @@ const TRAILING_PUNCTUATION = /[.,;:!?)\]']+$/; */ function redactUrlsInText(text: string): string { return text.replace(EMBEDDED_URL_PATTERN, (match) => { - const trailing = TRAILING_PUNCTUATION.exec(match)?.[0] ?? ""; - const url = match.slice(0, match.length - trailing.length); - return redactUrlQuery(url) + trailing; + const end = trailingPunctuationStart(match); + return redactUrlQuery(match.slice(0, end)) + match.slice(end); }); }