Skip to content

Commit 49ee523

Browse files
srousseyclaude
andcommitted
fix(web-search): remove quadratic backtracking from trailing-slash trim
CodeQL flagged two high-severity polynomial-ReDoS alerts. `/\/+$/` is quadratic on a string holding a long run of slashes that is not at the end: the engine starts `\/+` at every slash position, consumes the whole run, fails `$`, and restarts one character along. Both call sites take untrusted text — a search domain from task input, and a configured base URL. Measured on a 200k-slash input: 49.7s before, under 1ms after. The regression test asserts a 1000ms budget, so only a reintroduction of the pattern can trip it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4wM782LCRMPFPKeGzrzu9
1 parent 68e30fc commit 49ee523

5 files changed

Lines changed: 78 additions & 2 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, expect, it } from "vitest";
8+
import { applyDomainOperators } from "../queryOperators";
9+
import { trimTrailingSlashes } from "../urlText";
10+
11+
describe("trimTrailingSlashes", () => {
12+
it("removes one or many trailing slashes", () => {
13+
expect(trimTrailingSlashes("https://a.example/")).toBe("https://a.example");
14+
expect(trimTrailingSlashes("https://a.example///")).toBe("https://a.example");
15+
});
16+
17+
it("leaves a string with no trailing slash untouched", () => {
18+
expect(trimTrailingSlashes("https://a.example")).toBe("https://a.example");
19+
});
20+
21+
it("keeps interior slashes", () => {
22+
expect(trimTrailingSlashes("https://a.example/b/c/")).toBe("https://a.example/b/c");
23+
});
24+
25+
it("handles empty and all-slash input", () => {
26+
expect(trimTrailingSlashes("")).toBe("");
27+
expect(trimTrailingSlashes("////")).toBe("");
28+
});
29+
30+
/**
31+
* A long run of slashes that is NOT at the end is the shape that makes
32+
* `/\/+$/` quadratic. Under that pattern this input takes tens of seconds;
33+
* the linear scan is sub-millisecond, so the budget is generous enough that
34+
* only a genuine reintroduction of the regex can trip it.
35+
*/
36+
it("stays linear on a pathological slash run", () => {
37+
const evil = `a${"/".repeat(200_000)}b`;
38+
const started = performance.now();
39+
expect(trimTrailingSlashes(evil)).toBe(evil);
40+
expect(performance.now() - started).toBeLessThan(1000);
41+
});
42+
43+
it("keeps domain normalization linear on the same input", () => {
44+
const evil = `a${"/".repeat(200_000)}b`;
45+
const started = performance.now();
46+
applyDomainOperators("cats", [evil], undefined);
47+
expect(performance.now() - started).toBeLessThan(1000);
48+
});
49+
});

packages/web-search/src/common.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export * from "./providers/httpSearch";
2121
export * from "./providers/SearxngWebSearchProvider";
2222
export * from "./providers/TavilyWebSearchProvider";
2323
export * from "./queryOperators";
24+
export * from "./urlText";
2425
export * from "./WebSearchProviderRegistry";
2526
export * from "./WebSearchTask";
2627

packages/web-search/src/providers/SearxngWebSearchProvider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
WebSearchRequest,
1414
WebSearchResponse,
1515
} from "../IWebSearchProvider";
16+
import { trimTrailingSlashes } from "../urlText";
1617
import { fetchSearchJson } from "./httpSearch";
1718

1819
/** Env var naming the self-hosted instance to search. */
@@ -41,7 +42,7 @@ export class SearxngWebSearchProvider implements IWebSearchProvider {
4142
};
4243

4344
constructor(baseUrl: string) {
44-
const trimmed = baseUrl.replace(/\/+$/, "");
45+
const trimmed = trimTrailingSlashes(baseUrl);
4546
let parsed: URL;
4647
try {
4748
parsed = new URL(trimmed);

packages/web-search/src/queryOperators.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import { trimTrailingSlashes } from "./urlText";
8+
79
/**
810
* Reduces a caller-supplied domain to the bare host (plus any path prefix) that
911
* a `site:` operator accepts. A scheme, a `www.` prefix, or a trailing slash
@@ -13,7 +15,7 @@ function normalizeDomain(domain: string): string {
1315
let value = domain.trim().toLowerCase();
1416
value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
1517
value = value.replace(/^www\./, "");
16-
value = value.replace(/\/+$/, "");
18+
value = trimTrailingSlashes(value);
1719
return value;
1820
}
1921

packages/web-search/src/urlText.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
const SLASH = 0x2f;
8+
9+
/**
10+
* Removes trailing `/` characters.
11+
*
12+
* Deliberately not `replace(/\/+$/, "")`. That pattern is quadratic on a string
13+
* holding a long run of slashes that is NOT at the end: the engine starts `\/+`
14+
* at every slash position, consumes the whole run, then fails `$` and restarts
15+
* one character along. Both call sites take caller-supplied text — a search
16+
* domain and a configured base URL — so the input is untrusted. A single
17+
* reverse scan is linear and allocates one string.
18+
*/
19+
export function trimTrailingSlashes(value: string): string {
20+
let end = value.length;
21+
while (end > 0 && value.charCodeAt(end - 1) === SLASH) end--;
22+
return end === value.length ? value : value.slice(0, end);
23+
}

0 commit comments

Comments
 (0)