Skip to content

Commit 25ed2d6

Browse files
fix(review): replace ReDoS-prone markdown separator regex (#2280)
Split table separator validation into per-cell checks so PR bodies with long whitespace runs cannot catastrophically backtrack the review worker. Mirrors the fix in both engine and app copies of screenshot-table-gate. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6618153 commit 25ed2d6

3 files changed

Lines changed: 38 additions & 4 deletions

File tree

packages/gittensory-engine/src/review/screenshot-table-gate.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,20 @@ export function normalizeScreenshotTableGateConfig(input: unknown, warnings: str
8888
};
8989
}
9090

91+
/** Linear-time markdown table separator check. The previous single-regex form nested unbounded `\\s*` inside a
92+
* repeated group and could catastrophically backtrack on attacker-controlled PR bodies; this splits on `|` and
93+
* validates each cell independently instead. */
94+
const TABLE_SEPARATOR_CELL = /^\s*:?-{3,}:?\s*$/;
95+
96+
function isMarkdownTableSeparatorRow(line: string): boolean {
97+
const trimmed = line.trim();
98+
if (!trimmed || !/-{3,}/.test(trimmed)) return false;
99+
const withoutEdgePipes = trimmed.replace(/^\|/, "").replace(/\|$/, "").trim();
100+
if (!withoutEdgePipes) return false;
101+
const cells = withoutEdgePipes.split("|");
102+
return cells.length > 0 && cells.every((cell) => TABLE_SEPARATOR_CELL.test(cell));
103+
}
104+
91105
/** True when `body` contains at least one markdown TABLE region (`| ... |` header + separator row) whose cells
92106
* embed image markup — either `![alt](url)` or an `<img ...>` tag — inside the table. A screenshot pasted as a
93107
* bare inline image OUTSIDE any table does not count (the contract requires captioned thumbnails INSIDE a
@@ -99,7 +113,6 @@ export function hasImageBearingMarkdownTable(body: string | null | undefined): b
99113
if (!body) return false;
100114
const lines = body.split(/\r?\n/);
101115
const tableRowPattern = /^\s*\|.*\|\s*$/;
102-
const separatorRowPattern = /^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/;
103116
const imagePattern = /!\[[^\]]*\]\([^)]+\)|<img\b[^>]*>/i;
104117
for (let i = 0; i < lines.length - 1; i += 1) {
105118
// `i < lines.length - 1` guarantees both indices are in bounds; the `?? ""` fallbacks only exist to
@@ -108,7 +121,7 @@ export function hasImageBearingMarkdownTable(body: string | null | undefined): b
108121
const header = lines[i] ?? "";
109122
/* v8 ignore next -- defensive: the loop bound above guarantees lines[i + 1] always exists here. */
110123
const separator = lines[i + 1] ?? "";
111-
if (!tableRowPattern.test(header) || !separatorRowPattern.test(separator)) continue;
124+
if (!tableRowPattern.test(header) || !isMarkdownTableSeparatorRow(separator)) continue;
112125
// Found a table (header + separator). Scan its body rows (until a blank line or a non-table line) for
113126
// image markup in any cell.
114127
let j = i + 2;

src/review/screenshot-table-gate.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,20 @@ export function normalizeScreenshotTableGateConfig(input: unknown, warnings: str
8888
};
8989
}
9090

91+
/** Linear-time markdown table separator check. The previous single-regex form nested unbounded `\\s*` inside a
92+
* repeated group and could catastrophically backtrack on attacker-controlled PR bodies; this splits on `|` and
93+
* validates each cell independently instead. */
94+
const TABLE_SEPARATOR_CELL = /^\s*:?-{3,}:?\s*$/;
95+
96+
function isMarkdownTableSeparatorRow(line: string): boolean {
97+
const trimmed = line.trim();
98+
if (!trimmed || !/-{3,}/.test(trimmed)) return false;
99+
const withoutEdgePipes = trimmed.replace(/^\|/, "").replace(/\|$/, "").trim();
100+
if (!withoutEdgePipes) return false;
101+
const cells = withoutEdgePipes.split("|");
102+
return cells.length > 0 && cells.every((cell) => TABLE_SEPARATOR_CELL.test(cell));
103+
}
104+
91105
/** True when `body` contains at least one markdown TABLE region (`| ... |` header + separator row) whose cells
92106
* embed image markup — either `![alt](url)` or an `<img ...>` tag — inside the table. A screenshot pasted as a
93107
* bare inline image OUTSIDE any table does not count (the contract requires captioned thumbnails INSIDE a
@@ -99,7 +113,6 @@ export function hasImageBearingMarkdownTable(body: string | null | undefined): b
99113
if (!body) return false;
100114
const lines = body.split(/\r?\n/);
101115
const tableRowPattern = /^\s*\|.*\|\s*$/;
102-
const separatorRowPattern = /^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/;
103116
const imagePattern = /!\[[^\]]*\]\([^)]+\)|<img\b[^>]*>/i;
104117
for (let i = 0; i < lines.length - 1; i += 1) {
105118
// `i < lines.length - 1` guarantees both indices are in bounds; the `?? ""` fallbacks only exist to
@@ -108,7 +121,7 @@ export function hasImageBearingMarkdownTable(body: string | null | undefined): b
108121
const header = lines[i] ?? "";
109122
/* v8 ignore next -- defensive: the loop bound above guarantees lines[i + 1] always exists here. */
110123
const separator = lines[i + 1] ?? "";
111-
if (!tableRowPattern.test(header) || !separatorRowPattern.test(separator)) continue;
124+
if (!tableRowPattern.test(header) || !isMarkdownTableSeparatorRow(separator)) continue;
112125
// Found a table (header + separator). Scan its body rows (until a blank line or a non-table line) for
113126
// image markup in any cell.
114127
let j = i + 2;

test/unit/screenshot-table-gate.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ describe("hasImageBearingMarkdownTable", () => {
6666
const body = ["| Before | After |", "|:---:|:---:|", "| ![a](x.png) | ![b](y.png) |"].join("\n");
6767
expect(hasImageBearingMarkdownTable(body)).toBe(true);
6868
});
69+
70+
it("rejects long whitespace-only separator candidates without hanging", () => {
71+
const whitespace = " ".repeat(8_000);
72+
const body = ["| Before | After |", whitespace, "| ![a](x.png) | ![b](y.png) |"].join("\n");
73+
const started = performance.now();
74+
expect(hasImageBearingMarkdownTable(body)).toBe(false);
75+
expect(performance.now() - started).toBeLessThan(50);
76+
});
6977
});
7078

7179
describe("hasImageOutsideTable", () => {

0 commit comments

Comments
 (0)