Skip to content

fix(desktop): caret-escape and fail-close the WSL cmd.exe URL open - #134293

Closed
Yicheng-Mark wants to merge 1 commit into
NousResearch:mainfrom
Yicheng-Mark:fix/wsl-openexternal-cmd-injection
Closed

Yicheng-Mark wants to merge 1 commit into
NousResearch:mainfrom
Yicheng-Mark:fix/wsl-openexternal-cmd-injection

Conversation

@Yicheng-Mark

Copy link
Copy Markdown

Symptom

On WSL, every external URL is opened via cmd.exe /c start "" <url> with the URL passed unquoted in the argv (apps/desktop/electron/external-open.ts, openViaWsl). cmd.exe re-parses the command line Node assembles from argv, so shell metacharacters in the URL enable command concatenation, exactly as reported in #131561:

https://example.com/&calc.exe        →  calc.exe runs
https://example.com/&calc.exe&rem    →  same, with cover

The http/https/mailto scheme allowlist does not filter & | < > ^ ( ) , ; =, and %var% expansion happens before caret-escaping, so % cannot be neutralized either.

Cause

openViaWsl hands parsed.toString() straight to deps.spawn('cmd.exe', ['/c', 'start', '""', url]). Node only quotes argv values that contain spaces, so a URL without spaces reaches cmd.exe unquoted — and cmd re-parses every metacharacter in it as an operator.

Fix

Two-tier, both fail-closed, in a new exported escapeUrlForCmdStart(), as suggested in the issue:

  • reject outright the characters caret-escaping cannot neutralize: " (would need quoting start's argv cannot express), % (variable expansion runs before cmd applies ^ escapes — %name% cannot be defused on a command line at all), ! (delayed expansion), and any whitespace/control character (the URL would split across argv slots). openViaWsl refuses the open (invalid), logs, and never spawns.
  • caret-escape the escapable metacharacters & | < > ^ ( ) , ; =, so they reach start as literals instead of operators — this also keeps , ; = from splitting the URL across argv slots.
  • the scheme allowlist is unchanged.

A URL that survived new URL().toString() never carries a raw quote, space or control character (the WHATWG parser percent-encodes them), so the reject tier is defense in depth; % (legit percent-encoded URLs) is the one real-world casualty — refusing to open beats executing a command.

How to Verify

  • Unit/integration tests added to external-open.test.ts: a 20-case probe suite (8 attack URLs fully escaped, 8 unescapable rejected, 4 clean URLs unchanged), plus two openExternalUrl integration tests — the spawned argv carries the caret-escaped URL, and an unescapable URL resolves invalid with zero spawns and a log line.
  • Local verification: both files transpile clean of syntax errors (tsc), and the compiled module was run against the 20-probe matrix — all green. The suite runs in CI.

Fixes #131561

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds a functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — TS files; local verification was transpile + 20-probe matrix against the compiled module (above); suite runs in CI
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Windows 11 (the attacked platform: cmd.exe re-parsing)

Documentation & Housekeeping

  • I've updated relevant documentation — N/A, internal hardening with no user-facing surface change
  • I've updated cli-config.yaml.example — N/A
  • I've updated CONTRIBUTING.md or AGENTS.md — N/A
  • I've considered cross-platform impact — the change is confined to the deps.isWsl branch (WSL-only route); non-WSL platforms take the identical path as before
  • I've updated tool descriptions/schemas — N/A

openViaWsl passed the URL unquoted into `cmd.exe /c start "" <url>`;
cmd re-parses the command line, so metacharacters in the URL enabled
command concatenation (& calc.exe) and %var% expansion (131561).

escapeUrlForCmdStart(): reject outright what caret-escaping cannot
neutralize (", %, !, whitespace/controls) -- refusing to open beats
executing a command -- and caret-escape the escapable metacharacters
(& | < > ^ ( ) , ; =). openViaWsl resolves invalid without spawning
on the reject tier. Scheme allowlist unchanged.

Fixes NousResearch#131561.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/desktop Electron desktop app (apps/desktop/*) platform/windows sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows needs-decision Awaiting maintainer decision before any implementation labels Oct 7, 2026
@Yicheng-Mark

Copy link
Copy Markdown
Author

Heads-up on something odd: I have a follow-up fix ready for #134283 (the New Project dialog pathless create — branch and regression test are pushed to my fork), but every attempt to open that PR against this repo from my account is rejected (REST returns 404, GraphQL returns a permission error). It started on Oct 7, shortly after this PR was opened. Everything else still works from my side — commenting, reacting, editing, and PR creation on my own fork all succeed — and other external contributors are opening PRs normally.

Could an automated restriction have been applied to my account on this repo? If it can be lifted, I'll open the #134283 PR right away.

@Yicheng-Mark

Copy link
Copy Markdown
Author

Follow-up on my earlier question about the PR-creation 404s: mystery solved — it was this repo's open-PR limit (three per author; the API surfaces it as a bare 404). Drafts don't count, so the follow-up fixes are up as drafts now: #135689 (ready), #135692, #135693, #135694.

@Yicheng-Mark

Copy link
Copy Markdown
Author

Closing as superseded: #126968 (opened 09-28) closes the same cmd.exe re-parsing hole on the same two files with the same fail-closed approach, and it got there first.

Keeping both open only adds duplicate weight to the triage of #131561. The patch stays on the fork (fix/wsl-openexternal-cmd-injection) should the shape of the eventual fix change and the caret-escaping tier turn out to be wanted after all.

@alt-glitch alt-glitch added os/windows Native Windows behaviour or breakage: PowerShell install, Windows paths, cmd.exe, WSL-vs-native diff and removed platform/windows labels Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) needs-decision Awaiting maintainer decision before any implementation os/windows Native Windows behaviour or breakage: PowerShell install, Windows paths, cmd.exe, WSL-vs-native diff P2 Medium — degraded but workaround exists sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Security] WSL openExternal: unquoted URL enables cmd.exe command injection

2 participants