fix(compression): eliminate ReDoS in math_inline preservation pattern (#4795) (#4838)

Integrated into release/v3.8.36 (fixes #4795)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-24 12:39:32 -03:00
committed by GitHub
parent 4a1ae868ce
commit 950fc6e60e
2 changed files with 67 additions and 1 deletions

View File

@@ -92,7 +92,14 @@ export function extractPreservedBlocks(
const builtIns: CompiledPattern[] = [
{ pattern: /\$\$[\s\S]*?\$\$/g, kind: "math_block" },
{ pattern: /\\\[[\s\S]*?\\\]/g, kind: "math_block" },
{ pattern: /(?<!\$)\$(?![\s$\d])(?:\\.|[^$\n]){1,160}?(?<!\s)\$(?!\$)/g, kind: "math_inline" },
// #4795: exclude `\` from the catch-all branch so a backslash is only ever
// consumed by the escape branch `\\.`. The previous `[^$\n]` overlapped with
// `\\.`, making a run of consecutive backslashes (e.g. unmatched `$` + a
// Windows path) exponentially ambiguous → catastrophic backtracking (ReDoS).
{
pattern: /(?<!\$)\$(?![\s$\d])(?:\\.|[^$\n\\]){1,160}?(?<!\s)\$(?!\$)/g,
kind: "math_inline",
},
{ pattern: /\\begin\{[A-Za-z*]+\}[\s\S]*?\\end\{[A-Za-z*]+\}/g, kind: "latex_block" },
{ pattern: /^#{1,6}\s+.+$/gm, kind: "markdown_heading" },
{ pattern: /^\s*\|.*\|\s*$/gm, kind: "markdown_table" },

View File

@@ -0,0 +1,59 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
extractPreservedBlocks,
restorePreservedBlocks,
} from "../../../open-sse/services/compression/preservation.ts";
// Regression guard for #4795: a single unmatched `$` followed by a run of
// consecutive backslashes (e.g. Windows paths pasted into a prompt) triggered
// catastrophic backtracking in the `math_inline` built-in pattern, pinning one
// CPU core at 100% and freezing the event loop. The alternation
// `(?:\\.|[^$\n])` was ambiguous: a backslash could be consumed either by the
// escape branch `\\.` or by the catch-all `[^$\n]`, so a run of N backslashes
// could be tiled exponentially many ways before the trailing `$` anchor failed.
describe("preservation ReDoS guard (#4795)", () => {
it("does not hang on a `$` followed by many consecutive backslashes (no closing `$`)", () => {
// Without the fix this blows up exponentially (N=38 already exceeds 8s).
const evil = "$x" + "\\".repeat(200) + "y";
const start = Date.now();
const { text } = extractPreservedBlocks(evil);
const elapsed = Date.now() - start;
assert.ok(
elapsed < 1000,
`extractPreservedBlocks must not backtrack catastrophically (took ${elapsed}ms)`
);
// The pathological input is not valid inline math, so it stays untouched.
assert.equal(text, evil, "non-math text must be returned unchanged");
});
it("does not hang on Windows-path-style payloads with backslashes after a `$`", () => {
const evil =
"$C:\\Users\\Alpha\\Net\\DESKTOP\\" + "sub\\".repeat(120) + "no-closing-dollar";
const start = Date.now();
extractPreservedBlocks(evil);
assert.ok(Date.now() - start < 1000, "Windows-path payload must resolve quickly");
});
it("still preserves legitimate inline math", () => {
const text = "The identity $E=mc^2$ and $\\alpha + \\beta$ are classics.";
const { text: extracted, blocks } = extractPreservedBlocks(text);
const mathBlocks = blocks.filter((b) => b.kind === "math_inline");
assert.equal(mathBlocks.length, 2, "both inline-math spans must be preserved");
assert.ok(!extracted.includes("E=mc^2"), "math content must be replaced by a placeholder");
assert.equal(
restorePreservedBlocks(extracted, blocks),
text,
"round-trip must reproduce the original text"
);
});
it("preserves inline math that contains an escaped dollar", () => {
const text = "Price math: $a \\$ b$ end.";
const { blocks } = extractPreservedBlocks(text);
assert.ok(
blocks.some((b) => b.kind === "math_inline" && b.content === "$a \\$ b$"),
"inline math with an escaped dollar must still be captured via the `\\.` branch"
);
});
});