Skip to content

Commit 47bb347

Browse files
committed
fix(compat): harden cmd shim quoting and PATH parsing for review
Address Greptile review on #1045: - Quote cmd.exe metacharacters (& | < > ^ ! parens) in shim arguments so cmd treats them literally; a bare 'a&b' previously let cmd chain a second command (verified as injection on Windows). - Throw on arguments containing % or control characters instead of letting cmd.exe reinterpret them: % expands variables even inside quotes and has no cmd-line escape. Mirrors Node.js EINVAL after CVE-2024-27980. - Wrap the whole command line in one outer quote pair: cmd's /s handling strips the first and last quote of the /c payload, which broke spaced shim paths like "C:\Program Files\...\x.cmd". - Split PATH the way cmd.exe reads it: quoted entries may contain ';', surrounding quotes are stripped, and empty entries mean the current directory. Plain split(';') shredded the first and dropped the last.
1 parent d7b873c commit 47bb347

2 files changed

Lines changed: 169 additions & 11 deletions

File tree

src/utils/compat.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
import * as os from 'node:os';
44
import * as path from 'node:path';
55
import { runInNewContext } from 'node:vm';
6-
import { crossWrite, resolveWindowsCommand } from './compat';
6+
import {
7+
buildWindowsCommandLine,
8+
crossWrite,
9+
resolveWindowsCommand,
10+
} from './compat';
711

812
const TEST_DIR = path.join(os.tmpdir(), `compat-test-${process.pid}`);
913

@@ -149,4 +153,88 @@ describe('resolveWindowsCommand', () => {
149153
);
150154
expect(resolved).toBeUndefined();
151155
});
156+
157+
it('treats an empty PATH component as the current directory', () => {
158+
// cmd.exe semantics: a PATH entry that is empty after splitting
159+
// points at the cwd, so `dir1;;dir2` searches cwd between them.
160+
const marker = `omos-cwd-probe-${process.pid}.cmd`;
161+
writeFileSync(marker, '');
162+
try {
163+
const resolved = resolveWindowsCommand(
164+
marker.replace(/\.cmd$/, ''),
165+
['', ''].join(path.delimiter),
166+
'.CMD',
167+
);
168+
expect(resolved?.file).toBe(marker);
169+
expect(resolved?.viaCmdShell).toBe(true);
170+
} finally {
171+
rmSync(marker, { force: true });
172+
}
173+
});
174+
175+
it('keeps a quoted PATH component containing separators intact', () => {
176+
// Quoted entries may contain ';'; splitting on raw ';' would shred
177+
// the directory name and miss the shim cmd.exe finds.
178+
const dir = fixtureDir('semi;colon', ['bun.cmd']);
179+
const resolved = resolveWindowsCommand('bun', `"${dir}"`, '.CMD');
180+
expect(resolved?.file).toBe(path.join(dir, 'bun.cmd'));
181+
});
182+
183+
it('strips surrounding quotes from individual PATH components', () => {
184+
const quotedDir = fixtureDir('quoted-entry', ['bun.cmd']);
185+
const plainDir = fixtureDir('plain-entry', []);
186+
const resolved = resolveWindowsCommand(
187+
'bun',
188+
`"${quotedDir}"${path.delimiter}${plainDir}`,
189+
'.CMD',
190+
);
191+
expect(resolved?.file).toBe(path.join(quotedDir, 'bun.cmd'));
192+
});
193+
});
194+
195+
describe('buildWindowsCommandLine', () => {
196+
it('wraps the whole line in one outer quote pair for cmd /s /c', () => {
197+
// cmd's /s handling strips the first and the last quote of the /c
198+
// payload; the outer pair absorbs that so per-argument quotes keep
199+
// their meaning.
200+
expect(buildWindowsCommandLine('bun', ['install'])).toBe('"bun install"');
201+
});
202+
203+
it('quotes arguments containing spaces with Windows escaping', () => {
204+
expect(buildWindowsCommandLine('tar', ['-xf', 'C:\\my file.zip'])).toBe(
205+
'"tar -xf "C:\\my file.zip""',
206+
);
207+
});
208+
209+
it('double-escapes trailing backslashes inside quoted arguments', () => {
210+
expect(buildWindowsCommandLine('bun', ['C:\\my dir\\'])).toBe(
211+
'"bun "C:\\my dir\\\\""',
212+
);
213+
});
214+
215+
it('escapes embedded quotes in quoted arguments', () => {
216+
expect(buildWindowsCommandLine('bun', ['a"b'])).toBe('"bun "a\\"b""');
217+
});
218+
219+
it('quotes cmd metacharacters so cmd.exe treats them literally', () => {
220+
// Unquoted, `&` would let cmd chain a second command — the argument
221+
// must end up inside double quotes on the final command line.
222+
expect(buildWindowsCommandLine('bun', ['run', 'a&b'])).toBe(
223+
'"bun run "a&b""',
224+
);
225+
expect(buildWindowsCommandLine('bun', ['run', 'a|b', 'c^d'])).toBe(
226+
'"bun run "a|b" "c^d""',
227+
);
228+
});
229+
230+
it('rejects percent signs that cmd.exe would expand even quoted', () => {
231+
expect(() => buildWindowsCommandLine('bun', ['100%'])).toThrow(/'%'/);
232+
expect(() => buildWindowsCommandLine('bun', ['a%PATH%b'])).toThrow();
233+
});
234+
235+
it('rejects control characters that corrupt the cmd line', () => {
236+
expect(() => buildWindowsCommandLine('bun', ['a\nb'])).toThrow();
237+
expect(() => buildWindowsCommandLine('bun', ['a\rb'])).toThrow();
238+
expect(() => buildWindowsCommandLine('bun', ['a\u0000b'])).toThrow();
239+
});
152240
});

src/utils/compat.ts

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,33 @@ function splitList(value: string, separator: string): string[] {
6464
.filter((entry) => entry.length > 0);
6565
}
6666

67+
/**
68+
* Splits a Windows PATH the way cmd.exe reads it: a `;` inside a quoted
69+
* component does not separate entries, the surrounding quotes are
70+
* stripped, and an empty component stands for the current directory.
71+
* Plain `String.split(';')` would shred quoted entries whose directory
72+
* names contain `;` and silently drop current-directory entries.
73+
*/
74+
function splitWindowsPath(pathEnv: string): string[] {
75+
const parts: string[] = [];
76+
let current = '';
77+
let inQuotes = false;
78+
for (const char of pathEnv) {
79+
if (char === '"') {
80+
inQuotes = !inQuotes;
81+
continue;
82+
}
83+
if (char === path.delimiter && !inQuotes) {
84+
parts.push(current);
85+
current = '';
86+
continue;
87+
}
88+
current += char;
89+
}
90+
parts.push(current);
91+
return parts.map((part) => (part === '' ? '.' : part));
92+
}
93+
6794
/**
6895
* Resolve a bare command name against PATH and PATHEXT the way cmd.exe
6996
* does, so spawn() can launch it on Windows.
@@ -76,9 +103,12 @@ function splitList(value: string, separator: string): string[] {
76103
* shell, which silently breaks bun-based flows such as the auto-updater.
77104
*
78105
* Walks PATH entries in order; within each entry, tries PATHEXT
79-
* extensions in declared order. The first directory containing any match
80-
* wins, and the matched extension decides whether the file is directly
81-
* spawnable (`.exe`/`.com`) or must run through cmd.exe (`.cmd`/`.bat`).
106+
* extensions in declared order. PATH entries are split the way cmd.exe
107+
* reads them (quoted entries may contain `;`, empty entries mean the
108+
* current directory — see splitWindowsPath). The first directory
109+
* containing any match wins, and the matched extension decides whether
110+
* the file is directly spawnable (`.exe`/`.com`) or must run through
111+
* cmd.exe (`.cmd`/`.bat`).
82112
*/
83113
export function resolveWindowsCommand(
84114
command: string,
@@ -98,7 +128,7 @@ export function resolveWindowsCommand(
98128
candidate.toLowerCase(),
99129
);
100130

101-
for (const dir of splitList(pathEnv, path.delimiter)) {
131+
for (const dir of splitWindowsPath(pathEnv)) {
102132
let entries: string[];
103133
try {
104134
entries = readdirSync(dir);
@@ -125,22 +155,62 @@ export function resolveWindowsCommand(
125155
return undefined;
126156
}
127157

158+
/**
159+
* cmd.exe metacharacters neutralised by double-quoting the argument.
160+
* Inside double quotes, `& | < > ( ) ^ !` are literal to cmd; unquoted
161+
* they split or chain commands (verified on Windows: passing `a&echo x`
162+
* as a bare token makes cmd execute the second command).
163+
*/
164+
const CMD_METACHARACTERS = /[\s"&|<>()^!]/;
165+
166+
/**
167+
* Detects characters that cannot be passed through `cmd.exe /c` at
168+
* all: `%` expands environment variables even inside double quotes and
169+
* has no escape on the cmd command line, and control characters corrupt
170+
* the line. Node.js rejects the same inputs with EINVAL when spawning
171+
* .cmd/.bat files (CVE-2024-27980 hardening); we throw instead of
172+
* letting cmd.exe reinterpret the argument.
173+
*/
174+
function isCmdUnsafeArgument(arg: string): boolean {
175+
if (arg.includes('%')) return true;
176+
for (let i = 0; i < arg.length; i++) {
177+
if (arg.charCodeAt(i) <= 0x1f) return true;
178+
}
179+
return false;
180+
}
181+
128182
/**
129183
* Quotes one argument for a `cmd.exe /c` command line. Arguments that
130-
* contain no spaces or quotes are passed through untouched — cmd's /s
131-
* stripping mangles gratuitously quoted tokens. Callers must not pass
132-
* untrusted input — cmd.exe expands `%VAR%` even inside quotes.
184+
* contain no metacharacters are passed through untouched — cmd's /s
185+
* stripping mangles gratuitously quoted tokens.
186+
*
187+
* Throws on arguments that cmd.exe cannot represent faithfully (`%`,
188+
* control characters) so callers fail loudly instead of executing an
189+
* altered command line.
133190
*/
134191
function escapeWindowsArgument(arg: string): string {
135-
if (!/[\s"]/.test(arg)) {
192+
if (isCmdUnsafeArgument(arg)) {
193+
throw new Error(
194+
`cannot pass ${JSON.stringify(arg)} through a .cmd shim: cmd.exe reinterprets '%' and control characters even inside quotes`,
195+
);
196+
}
197+
if (!CMD_METACHARACTERS.test(arg)) {
136198
return arg;
137199
}
138200
const escaped = arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1');
139201
return `"${escaped}"`;
140202
}
141203

142-
function buildWindowsCommandLine(file: string, args: string[]): string {
143-
return [file, ...args].map(escapeWindowsArgument).join(' ');
204+
/**
205+
* Builds the full command line handed to `cmd.exe /d /s /c`. The whole
206+
* line is wrapped in one outer pair of quotes because cmd's /s
207+
* processing strips the first and the last quote of the /c payload:
208+
* without the outer pair, a spaced path like `"C:\Program
209+
* Files\...\bun.cmd"` loses its quotes and the spawn fails (verified on
210+
* Windows). Exported for unit tests only.
211+
*/
212+
export function buildWindowsCommandLine(file: string, args: string[]): string {
213+
return `"${[file, ...args].map(escapeWindowsArgument).join(' ')}"`;
144214
}
145215

146216
/**

0 commit comments

Comments
 (0)