(cmd: string)
| 632 | * @returns Object containing the command without redirections and the target paths if found |
| 633 | */ |
| 634 | export function extractOutputRedirections(cmd: string): { |
| 635 | commandWithoutRedirections: string |
| 636 | redirections: Array<{ target: string; operator: '>' | '>>' }> |
| 637 | hasDangerousRedirection: boolean |
| 638 | } { |
| 639 | const redirections: Array<{ target: string; operator: '>' | '>>' }> = [] |
| 640 | let hasDangerousRedirection = false |
| 641 | |
| 642 | // SECURITY: Extract heredocs BEFORE line-continuation joining AND parsing. |
| 643 | // This matches splitCommandWithOperators (line 101). Quoted-heredoc bodies |
| 644 | // are LITERAL text in bash (`<< 'EOF'\n${}\nEOF` — ${} is NOT expanded, and |
| 645 | // `\<newline>` is NOT a continuation). But shell-quote doesn't understand |
| 646 | // heredocs; it sees `${}` on line 2 as an unquoted bad substitution and throws. |
| 647 | // |
| 648 | // ORDER MATTERS: If we join continuations first, a quoted heredoc body |
| 649 | // containing `x\<newline>DELIM` gets joined to `xDELIM` — the delimiter |
| 650 | // shifts, and `> /etc/passwd` that bash executes gets swallowed into the |
| 651 | // heredoc body and NEVER reaches path validation. |
| 652 | // |
| 653 | // Attack: `cat <<'ls'\nx\\\nls\n> /etc/passwd\nls` with Bash(cat:*) |
| 654 | // - bash: quoted heredoc → `\` is literal, body = `x\`, next `ls` closes |
| 655 | // heredoc → `> /etc/passwd` TRUNCATES the file, final `ls` runs |
| 656 | // - join-first (OLD, WRONG): `x\<NL>ls` → `xls`, delimiter search finds |
| 657 | // the LAST `ls`, body = `xls\n> /etc/passwd` → redirections:[] → |
| 658 | // /etc/passwd NEVER validated → FILE WRITE, no prompt |
| 659 | // - extract-first (NEW, matches splitCommandWithOperators): body = `x\`, |
| 660 | // `> /etc/passwd` survives → captured → path-validated |
| 661 | // |
| 662 | // Original attack (why extract-before-parse exists at all): |
| 663 | // `echo payload << 'EOF' > /etc/passwd\n${}\nEOF` with Bash(echo:*) |
| 664 | // - bash: quoted heredoc → ${} literal, echo writes "payload\n" to /etc/passwd |
| 665 | // - checkPathConstraints: calls THIS function on original → ${} crashes |
| 666 | // shell-quote → previously returned {redirections:[], dangerous:false} |
| 667 | // → /etc/passwd NEVER validated → FILE WRITE, no prompt. |
| 668 | const { processedCommand: heredocExtracted, heredocs } = extractHeredocs(cmd) |
| 669 | |
| 670 | // SECURITY: Join line continuations AFTER heredoc extraction, BEFORE parsing. |
| 671 | // Without this, `> \<newline>/etc/passwd` causes shell-quote to emit an |
| 672 | // empty-string token for `\<newline>` and a separate token for the real path. |
| 673 | // The extractor picks up `''` as the target; isSimpleTarget('') was vacuously |
| 674 | // true (now also fixed as defense-in-depth); path.resolve(cwd,'') returns cwd |
| 675 | // (always allowed). Meanwhile bash joins the continuation and writes to |
| 676 | // /etc/passwd. Even backslash count = newline is a separator (not continuation). |
| 677 | const processedCommand = heredocExtracted.replace(/\\+\n/g, match => { |
| 678 | const backslashCount = match.length - 1 |
| 679 | if (backslashCount % 2 === 1) { |
| 680 | return '\\'.repeat(backslashCount - 1) |
| 681 | } |
| 682 | return match |
| 683 | }) |
| 684 | |
| 685 | // Try to parse the heredoc-extracted command |
| 686 | const parseResult = tryParseShellCommand(processedCommand, env => `$${env}`) |
| 687 | |
| 688 | // SECURITY: FAIL-CLOSED on parse failure. Previously returned |
| 689 | // {redirections:[], hasDangerousRedirection:false} — a silent bypass. |
| 690 | // If shell-quote can't parse (even after heredoc extraction), we cannot |
| 691 | // verify what redirections exist. Any `>` in the command could write files. |
no test coverage detected