From 0ed47703ebe07f9adb2dce167051bc992052570f Mon Sep 17 00:00:00 2001 From: Ian Hou <45278651+iankhou@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:43:11 +0000 Subject: [PATCH] fix(toolkit-lib): reject discovered Windows paths containing %VAR% cmd.exe expands %VAR% even inside double quotes, and a cmd /c command line cannot reliably escape a percent (doubling only works in batch files). A filesystem-discovered path carrying a %...% reference spliced into the user's app command line would therefore be silently rewritten (an env var expanded into the path). quoteShellPart now refuses such a path loudly instead of executing something other than what is on disk. Split out of #1905, which now carries only the resolveExecutable (PATH-vs-cwd) hardening. Follow-up to #1763 / #1849. --- .../lib/api/cloud-assembly/environment.ts | 14 ++++++++++++++ .../test/api/cloud-assembly/environment.test.ts | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts index 54e5010b2..b7641d15e 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import * as cxapi from '@aws-cdk/cx-api'; import * as fs from 'fs-extra'; +import { ToolkitError } from '../../toolkit/toolkit-error'; import type { SdkProvider } from '../aws-auth/private'; import type { Settings } from '../settings'; @@ -260,6 +261,19 @@ function quoteShellPart(part: string) { return part; } if (isWindows) { + // cmd.exe expands `%VAR%` even inside double quotes, and a `cmd /c` command + // line — which is how `runUserCommandLine` reaches the shell on Windows — + // has no reliable way to escape a percent (doubling only works in batch + // files). A discovered path carrying a `%...%` reference would therefore be + // silently rewritten (an env var spliced into the path). Refuse it loudly + // rather than execute something other than what is on disk. + if (/%[^%]*%/.test(part)) { + throw new ToolkitError( + 'UnsafeWindowsPath', + `Cannot safely run a path containing a '%...%' substring through the Windows shell: '${part}'. ` + + 'Rename the file or directory to remove the percent signs.', + ); + } return `"${part}"`; } return `"${part.replace(/([\\"$`])/g, '\\$1')}"`; diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts index f007c3725..f82f19fe5 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/environment.test.ts @@ -57,6 +57,22 @@ test.each([ expect(actual).toEqual(expected); }); +test('refuses a discovered Windows path containing a %VAR% reference (cmd.exe would expand it)', async () => { + // GIVEN + const appPath = 'C:\\proj\\%USERNAME%\\app'; + Object.defineProperty(process, 'platform', { value: 'win32' }); + jest.spyOn(fs, 'stat').mockImplementation((p) => { + if (p !== appPath) { + throw new Error(`Expected a stat() call on '${appPath}' but got '${p}'`); + } + return Promise.resolve({ mode: 0 }) as any; + }); + + // THEN + await expect(guessExecutable(appPath, (_) => Promise.resolve())) + .rejects.toThrow(/Cannot safely run a path containing a '%\.\.\.%' substring/); +}); + /** * Explode all 'both's in a test array to both false and true */