Skip to content

safe-bash: quadratic newline scan in nested $( ) parsing → 20 s uninterruptible synchronous parse #615

Description

@kamilio

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions