Skip to content

Commit a0be812

Browse files
os-zhuangclaude
andcommitted
fix(email): linear placeholder matcher — close CodeQL polynomial-ReDoS
The formatter-aware regex used two overlapping `\s*` groups (inside the optional `| formatter` group and trailing), which CodeQL flagged as js/polynomial-redos (slow on `{{{{.` + many tabs). Switch to a brace-free inner capture (`[^{}]*` — single star, no nested quantifier) and parse the hole body with plain string ops, the same linear strategy the formula template engine uses. Adds a regression test on the flagged input shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 08d9ddd commit a0be812

2 files changed

Lines changed: 53 additions & 9 deletions

File tree

packages/plugins/plugin-email/src/template-engine.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ describe('template-engine', () => {
6767
it('renders a missing formatted value as empty (never "undefined")', () => {
6868
expect(renderTemplate('{{ missing | datetime }}', {})).toBe('');
6969
});
70+
71+
// Regression: the placeholder matcher must stay linear. CodeQL flagged a
72+
// polynomial-ReDoS on inputs like `{{{{.` + many tabs; the brace-free
73+
// `[^{}]*` capture removes the backtracking. Pathological input resolves
74+
// fast and is left verbatim (not a valid path[+formatter] hole).
75+
it('handles pathological brace/whitespace input without backtracking', () => {
76+
const evil = '{{{{.' + '\t'.repeat(50_000);
77+
const start = Date.now();
78+
const out = renderTemplate(evil + '}}', {});
79+
expect(Date.now() - start).toBeLessThan(1000);
80+
expect(typeof out).toBe('string');
81+
});
7082
});
7183
});
7284

packages/plugins/plugin-email/src/template-engine.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,45 @@
2525

2626
import { formatValue } from '@objectstack/formula';
2727

28-
// 1=open(`{{`|`{{{`) 2=path 3=formatter(opt) 4=arg(opt) 5=close(`}}`|`}}}`).
29-
// `[^'}]*?` is brace/quote-free, so it cannot run past the closing braces —
30-
// the matcher stays linear (no ReDoS).
31-
const PLACEHOLDER =
32-
/(\{\{\{?)\s*([\w.]+)(?:\s*\|\s*(\w+)(?:\s*:\s*'?([^'}]*?)'?)?)?\s*(\}\}\}?)/g;
28+
// Match a hole: open braces, a BRACE-FREE inner body, close braces. Capturing
29+
// the inner with `[^{}]*` (a single star over a negated class — no nested
30+
// quantifier, no overlapping `\s*` groups) keeps the matcher strictly linear:
31+
// it can never backtrack into a polynomial-ReDoS shape. The inner body is then
32+
// parsed with plain string ops (`parseHole`) instead of a complex pattern.
33+
// 1=open(`{{`|`{{{`) 2=inner 3=close(`}}`|`}}}`).
34+
const PLACEHOLDER = /(\{\{\{?)([^{}]*)(\}\}\}?)/g;
3335

3436
/** Locale + reference timezone for hole formatters (ADR-0053 Phase 2). */
3537
export interface RenderOptions {
3638
locale?: string;
3739
timeZone?: string;
3840
}
3941

42+
// A hole body is a dotted path with an optional `| formatter[:arg]`. Validated
43+
// against small, already-extracted strings (bounded input → no ReDoS).
44+
const PATH_RE = /^[\w.]+$/;
45+
const FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/;
46+
47+
interface ParsedHole {
48+
path: string;
49+
formatter?: string;
50+
arg?: string;
51+
}
52+
53+
/** Parse a hole's inner body into a path + optional formatter. Null if malformed. */
54+
function parseHole(inner: string): ParsedHole | null {
55+
const pipe = inner.indexOf('|');
56+
if (pipe === -1) {
57+
const path = inner.trim();
58+
return PATH_RE.test(path) ? { path } : null;
59+
}
60+
const path = inner.slice(0, pipe).trim();
61+
if (!PATH_RE.test(path)) return null;
62+
const m = FILTER_RE.exec(inner.slice(pipe + 1).trim());
63+
if (!m) return null;
64+
return { path, formatter: m[1], arg: m[2] };
65+
}
66+
4067
function lookup(data: Record<string, any>, path: string): unknown {
4168
if (!path) return undefined;
4269
const parts = path.split('.');
@@ -70,14 +97,19 @@ export function renderTemplate(
7097
if (!template) return '';
7198
return template.replace(
7299
PLACEHOLDER,
73-
(_match, open: string, path: string, fname: string | undefined, farg: string | undefined, close: string) => {
100+
(match: string, open: string, inner: string, close: string) => {
101+
const parsed = parseHole(inner);
102+
if (!parsed) return match; // not a path[+formatter] hole — leave verbatim
74103
const isUnescaped = open === '{{{' && close === '}}}';
75-
const raw = lookup(data, path);
104+
const raw = lookup(data, parsed.path);
76105
let str: string;
77-
if (fname) {
106+
if (parsed.formatter) {
78107
// Formatted holes render '' for a missing value (the formula formatters
79108
// treat null as empty), so they never emit "undefined".
80-
const formatted = formatValue(fname, raw, farg, { locale: opts.locale, timeZone: opts.timeZone });
109+
const formatted = formatValue(parsed.formatter, raw, parsed.arg, {
110+
locale: opts.locale,
111+
timeZone: opts.timeZone,
112+
});
81113
str = formatted !== undefined
82114
? formatted
83115
: raw == null ? '' : (typeof raw === 'string' ? raw : String(raw)); // unknown formatter → raw

0 commit comments

Comments
 (0)