Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {getPathFromEnv} from './env.js';
const metaCharsRegExp = /([()\][%!^"`<>&|;, *?])/g;
const shebangRegExp = /^#!\s*(.+)/;
const isWindowsExecutableRegExp = /\.(?:com|exe)$/i;
const isNodeModulesCmdRegExp = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i;
const isBatchFileRegExp = /\.(?:cmd|bat)$/i;
const isWindows = process.platform === 'win32';
const defaultPathExt = ['.EXE', '.CMD', '.BAT', '.COM'];
const noPathExt = [''];
Expand Down Expand Up @@ -84,12 +84,13 @@ export function normalizeSpawnCommand(

// We don't need a shell if the command filename is resolved and an executable
if (file === null || !isWindowsExecutableRegExp.test(file)) {
// Need to double escape meta chars if the command is a cmd-shim located in `node_modules/.bin/`
// The cmd-shim simply calls execute the package bin file with NodeJS, proxying any argument
// Because the escape of metachars with ^ gets interpreted when the cmd.exe is first called,
// we need to double escape them
// Meta chars need double escaping if the command is a batch file. cmd.exe
// basically consumes the first layer of escaping.
// 1. `cmd.exe /c "command.cmd arg1 arg2"`
// 2. `command.cmd` sees `arg1` and `arg2` as `%1` and `%2`
// 3. `command.cmd` subs those in as actual commands
const needsDoubleEscapeMetaChars =
file !== null && isNodeModulesCmdRegExp.test(file);
file !== null && isBatchFileRegExp.test(file);

// Normalize posix paths into OS compatible paths (e.g.: foo/bar -> foo\bar)
// This is necessary otherwise it will always fail with ENOENT in those cases
Expand Down
11 changes: 11 additions & 0 deletions src/test/main_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,17 @@ if (isWindows) {
);
expect(result.stdout).toBe('');
});

test('double escapes for batch files', async () => {
const result = await x(path.join(fixturesDir, 'echo_args.cmd'), [
'"&echo INJECTED'
]);

// the batch file echoes `%*`, so the arg text itself is expected in
// stdout. a line of its own means `&` was parsed as a separator
expect(result.stdout).toContain('&echo INJECTED');
expect(result.stdout.split(/\r?\n/)).not.toContain('INJECTED');
});
});

describe('exec (windows) (async)', () => {
Expand Down
17 changes: 17 additions & 0 deletions src/test/normalize_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,23 @@ describe('normalizeSpawnCommand', () => {
expect(normalized.args).toEqual([scriptPath]);
});

test('double escapes meta chars in args to a batch file', () => {
const cmdPath = path.join(fixturesPath, 'echo_args.cmd');
const normalized = normalizeSpawnCommand(cmdPath, ['"&injected']);

// the batch interpreter consumes a second layer of `^` escapes when it
// expands `%*`, so `&` must still be escaped at that point
expect(normalized.args[3]).toBe(
`"${cmdPath} ^^^"\\^^^"^^^&injected^^^""`
);
});

test('single escapes meta chars in args to a non-batch command', () => {
const normalized = normalizeSpawnCommand('nonexistent', ['"&injected']);

expect(normalized.args[3]).toBe('"nonexistent ^"\\^"^&injected^""');
});

test('handles relative commands without extension', () => {
const relativePath = path.relative(
cwd,
Expand Down
2 changes: 2 additions & 0 deletions test/fixtures/echo_args.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@echo off
echo %*