Found in safe-bash deep pentest (v3). Verified with executed PoCs against current src/ on Node 22.
Severity: High — threat (d): 20.5 s fully-synchronous, uninterruptible parse per exec; defeats wall-clock budgets and abort signals
Summary
Each $(…) substitution constructs new Parser(this.budget, this.source.slice(start), …), and the nested Lexer constructor scans every newline of the whole remainder (parser.ts:131). With many newline-carrying substitutions this is quadratic: at the 1 MiB maxSourceBytes cap the parse alone takes 20 494 ms with zero event-loop ticks — wall-clock timers cannot fire, maxWallClockMs=30s never engages, and an AbortSignal.timeout(500) is honored only at 9654 ms (9.15 s late) — and then via ShellLimitError(maxParseUnits), i.e. the abort was never even observable. N concurrent execs serialize on the single thread → tenant starvation.
Location
packages/safe-bash/src/shell/parser.ts:495 — each $( constructs new Parser(this.budget, this.source.slice(start), …)
packages/safe-bash/src/shell/parser.ts:131 — nested Lexer scans every newline of the whole remainder
- Same class per exec unit via
src/shell/shell.ts:236/280
PoC (executed)
// trailing syntax error so nothing executes (exit 2, pure parse)
const src = "echo " + "$( :\n)".repeat(n) + " |)";
await shell.exec(src); // default limits
Measured:
- Parse-only scaling: 43 KB→349 ms, 87 KB→1606 ms (4.6×), 175 KB→4320 ms — clean quadratic.
- Through
exec: 128 KB→1.08 s; 256 KB→3.9 s; 512 KB→11.1 s; 1023 KB (at cap) → 20 494 ms, 0 event-loop ticks (2 ms heap sampler never ran once).
- Abort resistance:
AbortSignal.timeout(500) honored at 9654 ms (9.15 s late) via maxParseUnits.
- Control without newlines (
$( :) chain) is linear: 512 KB→179 ms — newlines are the amplifier.
- Exec-loop variant:
:\n×512k (1 MB) burns the full 30 s maxCpuMs (same script without the quadratic scan runs in ~2.7 s → 11× CPU amplification within budgets).
cloudflareWorkerLimits bounds it to 1.3 s synchronous (maxParseUnits 65536 + 256 KB source) — still uninterruptible.
Suggested fix
Share/cache newline offsets between parent and nested parsers over the same source (or build them lazily up to the lexer position), and charge the newline scan to ParseBudget (admit(newlineCount)).
Status: NEW
Found in safe-bash deep pentest (v3). Verified with executed PoCs against current
src/on Node 22.Severity: High — threat (d): 20.5 s fully-synchronous, uninterruptible parse per exec; defeats wall-clock budgets and abort signals
Summary
Each
$(…)substitution constructsnew Parser(this.budget, this.source.slice(start), …), and the nestedLexerconstructor scans every newline of the whole remainder (parser.ts:131). With many newline-carrying substitutions this is quadratic: at the 1 MiBmaxSourceBytescap the parse alone takes 20 494 ms with zero event-loop ticks — wall-clock timers cannot fire,maxWallClockMs=30snever engages, and anAbortSignal.timeout(500)is honored only at 9654 ms (9.15 s late) — and then viaShellLimitError(maxParseUnits), i.e. the abort was never even observable. N concurrent execs serialize on the single thread → tenant starvation.Location
packages/safe-bash/src/shell/parser.ts:495— each$(constructsnew Parser(this.budget, this.source.slice(start), …)packages/safe-bash/src/shell/parser.ts:131— nested Lexer scans every newline of the whole remaindersrc/shell/shell.ts:236/280PoC (executed)
Measured:
exec: 128 KB→1.08 s; 256 KB→3.9 s; 512 KB→11.1 s; 1023 KB (at cap) → 20 494 ms, 0 event-loop ticks (2 ms heap sampler never ran once).AbortSignal.timeout(500)honored at 9654 ms (9.15 s late) viamaxParseUnits.$( :)chain) is linear: 512 KB→179 ms — newlines are the amplifier.:\n×512k (1 MB) burns the full 30 smaxCpuMs(same script without the quadratic scan runs in ~2.7 s → 11× CPU amplification within budgets).cloudflareWorkerLimitsbounds it to 1.3 s synchronous (maxParseUnits 65536 + 256 KB source) — still uninterruptible.Suggested fix
Share/cache newline offsets between parent and nested parsers over the same source (or build them lazily up to the lexer position), and charge the newline scan to
ParseBudget(admit(newlineCount)).Status: NEW