Skip to content

fix(private-tools): resolve executables against PATH not cwd on Windows - #1905

Open
iankhou wants to merge 12 commits into
mainfrom
iankhou-windows-exec-resolution
Open

fix(private-tools): resolve executables against PATH not cwd on Windows#1905
iankhou wants to merge 12 commits into
mainfrom
iankhou-windows-exec-resolution

Conversation

@iankhou

@iankhou iankhou commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #1919.

Follow-up to #1763 / #1849. Windows-specific hardening of executable resolution on the no-shell run() path.

Problem

resolveExecutable() — on Windows a bare program name spawned without a shell is searched for in the working directory before PATH, so a file planted in a handed-over cloud assembly (e.g. docker.bat) shadows the real binary. This can result in unexpected behavior.

run() spawns via cross-spawn (cross-spawn@7.0.6), whose resolver is which@2.0.2, and its getPathInfo builds the Windows search path cwd-firstwhich.js#L17-L24 (cwd prepended at L20-L21):

  const pathEnv = cmd.match(/\//) || isWindows && cmd.match(/\\/) ? ['']
    : (
      [
        // windows always checks the cwd first
        ...(isWindows ? [process.cwd()] : []),
        ...(opt.path || process.env.PATH ||
          /* istanbul ignore next: very unusual */ '').split(colon),
      ]
    )

Fix

run()/runSync() now resolve the executable to an absolute PATH hit up front and refuse a name that is not on PATH, rather than let the cwd satisfy it. The resolver only ever returns an absolute candidate: relative PATH entries (e.g. .) are skipped, quoted PATH entries are unwrapped, and a name containing a dot is probed exactly (matching which). We honor explicit paths (absolute or containing a separator).

Testing

  • Unit tests for resolveExecutable (cross-platform via a platform override; run on the Ubuntu build job).

Tested on a Windows machine on Node v24.18.0, since there's no Windows unit-test lane in CI (it may or may not be worthwhile to introduce one at a later time, depending on how often we need these types of changes). Installed a cross-built tarball of @aws-cdk/private-toolsand introduced a hostile docker.bat in the cwd. All three checks ran in the same session.

Step 1 - Setup

On Windows, install the tarball and run the following to create the bad batfile:

@'
@echo off
echo shadow-ran
'@ | Out-File -Encoding ascii .\docker.bat

Step 2 - Negative Control

Run the following to check the negative control:

node -e "const r=require('cross-spawn').sync('docker',['--version'],{stdio:'inherit',encoding:'utf-8'}); console.log('exit:', r.status)"

Expected: shadow-ran + CONTROL: VULNERABLE.

Step 3 - Fixed code

Then run the fixed code:

node -e "require('@aws-cdk/private-tools/lib/subprocess').run(['docker','--version']).then(r=>console.log('RAN:', JSON.stringify(r.stdout.trim()))).catch(e=>console.log('REFUSED:', e.message))"

My Windows machine had Docker installed, so it showed:

RAN: "Docker version 29.6.2, build dfc4efb"

However, if your machine doesn't have Docker installed, it should show something like:

REFUSED: ... ENOENT

In either case, we showed that shadow-ran did not run. If our verification failed, we would have seen something like:

RAN: "shadow-ran"

Step 4 - env/PATH fallback

node -e "require('@aws-cdk/private-tools/lib/subprocess').run(['node','--version'], { env: { FOO: 'bar' } }).then(r=>console.log('FALLBACK PASS:', r.stdout.trim())).catch(e=>console.log('FALLBACK FAIL:', e.message))"

It shows:

FALLBACK PASS: v24.18.0
Test Path exercised cwd docker.bat ran? Result
Negative control (reproduces the vuln) cross-spawn.sync('docker', ...) directly (base-equivalent) YES — printed shadow-ran, wrote pwned.txt
Fix patched run(['docker','--version']) NO — real docker v29.6.2 ran, no pwned.txt ✅ PASS
env/PATH fallback run(['node','--version'], { env: { FOO: 'bar' } }) (PATH-less custom env) n/a v24.18.0

Takeaways

  • Confirms on Windows that a bare name resolves the cwd shim first (the control), and that the fix shadows it out (resolves to the real on-PATH binary).
  • The env/PATH fallback (8a0a0d7) resolves a PATH-less custom env via process.env.PATH instead of a synthetic ENOENT.

Checklist

  • Unit tests added/updated
  • Integration tests
  • No manual edits to generated files

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license.

@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

…s; reject %VAR% paths

Two Windows-specific hardening fixes to the shared subprocess module, plus a
test that actually exercises the .cmd path.

1. resolveExecutable(): on Windows a bare program name spawned without a shell
   is searched for in the cwd *before* PATH, so a file planted in a handed-over
   cloud assembly (e.g. docker.bat) could shadow the real binary. run()/runSync()
   now resolve the executable to an absolute PATH hit up front and refuse a name
   that is not on PATH rather than let the cwd satisfy it. POSIX is unchanged
   (execvp already searches PATH only); explicit paths are honored verbatim.

2. quoteShellPart() (toolkit-lib): cmd.exe expands %VAR% even inside double
   quotes and a `cmd /c` line cannot reliably escape a percent, so a discovered
   path carrying a %...% reference is now refused loudly instead of being
   silently rewritten.

3. Adds a Windows-only test that runs a real .cmd shim with hostile arguments —
   the one path where cross-spawn's cmd.exe escaping is exercised (a plain .exe
   never is).

NOTE: items 1 and 3 change Windows spawn behavior and must be validated by the
Windows integ tests; the resolveExecutable logic is unit-tested cross-platform
via a `platform` parameter.
@iankhou
iankhou force-pushed the iankhou-windows-exec-resolution branch from b334c08 to 9f440cb Compare August 26, 2026 20:27
@iankhou
iankhou deployed to no-approval August 26, 2026 20:28 — with GitHub Actions Active
@iankhou iankhou changed the title fix(subprocess): resolve executables against PATH not cwd on Windows; reject %VAR% paths fix(subprocess): resolve executables against PATH not cwd on Windows; reject bad paths Aug 28, 2026
@iankhou
iankhou marked this pull request as ready for review August 28, 2026 15:19
@iankhou iankhou changed the title fix(subprocess): resolve executables against PATH not cwd on Windows; reject bad paths fix: resolve executables against PATH not cwd on Windows; reject %VAR% paths Aug 28, 2026
@iankhou
iankhou deployed to no-approval August 28, 2026 15:21 — with GitHub Actions Active

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens Windows subprocess execution against executable shadowing and unsafe %VAR% path expansion.

Changes:

  • Resolves bare Windows executables through PATH before spawning.
  • Rejects shell-bound Windows paths containing %...%.
  • Adds Windows subprocess and unit coverage.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
packages/@aws-cdk/private-tools/lib/subprocess/index.ts Adds executable resolution and ENOENT handling.
packages/@aws-cdk/private-tools/test/subprocess/subprocess.test.ts Tests PATH resolution and .cmd escaping.
packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts Rejects unsafe percent-containing paths.
packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts Tests percent-path rejection.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/@aws-cdk/private-tools/lib/subprocess/index.ts Outdated
Comment thread packages/@aws-cdk/private-tools/lib/subprocess/index.ts
…able

Harden the Windows executable resolver against three defects found in review:

- Relative PATH entries (e.g. '.') were joined into non-absolute candidates
  that cross-spawn then re-resolves against the child cwd, reopening the cwd
  shadowing hole. Skip relative entries so the resolver only ever returns an
  absolute path and the cwd is never consulted.
- Quoted PATH entries ('"C:\Program Files\..."', legal on Windows) were not
  unwrapped, so an installed tool was reported as not found. Unwrap them, as
  which does.
- A name containing a dot was only probed exactly when it ended in a PATHEXT
  entry, so 'tool.exe' under a custom PATHEXT or a 'my.tool'-style name got a
  false ENOENT. Probe the exact name first when the name contains a dot.

Also correct the resolver docstring and the module trust-boundary note, and
drop the inaccurate 'runs on Windows CI' claim on the .cmd escaping test (CI
has no Windows unit-test lane today).
The quoteShellPart %VAR% guard is an independent fix on the shell path and now
lives in its own PR (#1924). This PR is scoped to the resolveExecutable
(PATH-vs-cwd) hardening on the no-shell run() path.
Simplify comment regarding executable resolution against PATH.
…resolution

# Conflicts:
#	packages/@aws-cdk/private-tools/lib/subprocess/index.ts
…he threat basis

Substantiate the vulnerability premise in-code: run() resolves through
cross-spawn -> node-which, whose getPathInfo searches [process.cwd(), ...PATH]
on Windows (cwd first, per which's own source comment). Also note that runSync
resolves and spawns against process.env so the two stay in sync.
…ks PATH

resolveExecutable resolved PATH strictly from the caller-provided env, so
run(argv, { env: { ...noPath } }) on Windows returned a synthetic ENOENT even
when the tool was on the system PATH. cross-spawn/which fall back to
process.env.PATH when the spawn env has no PATH key; mirror that. An explicitly
empty PATH is still treated as 'no dirs' (uses ?? not ||), so the off-PATH
refusal is unchanged. Same fallback applied to PATHEXT.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: resolve executable against PATH instead of cwd on Windows

3 participants