|
| 1 | +import {openSync, readSync, closeSync} from 'node:fs'; |
| 2 | +import {Buffer} from 'node:buffer'; |
| 3 | +import path from 'node:path'; |
| 4 | +import process from 'node:process'; |
| 5 | +import which from 'which'; |
| 6 | +import pathKey from 'path-key'; |
| 7 | + |
| 8 | +/* |
| 9 | +On Windows, `node:child_process` cannot natively run many kinds of files (`.cmd`, `.bat`, shebang scripts, ...): without a shell it resolves neither `PATHEXT` nor shebangs, and it does not escape arguments. We resolve the command to its full file path and escape its arguments ourselves, so those work without an explicit shell, just like on Unix, where the OS handles all of this for us. |
| 10 | +*/ |
| 11 | +export const parseCommandFile = (file, commandArguments, options) => { |
| 12 | + // The arguments are cloned since a shebang interpreter might be prepended below, which must not mutate the caller's array |
| 13 | + const parsed = {file, commandArguments: [...commandArguments], options}; |
| 14 | + |
| 15 | + // Under a shell, or on Unix, the OS resolves the file and escapes the arguments itself |
| 16 | + if (options.shell || process.platform !== 'win32') { |
| 17 | + return parsed; |
| 18 | + } |
| 19 | + |
| 20 | + return escapeWindowsCommand(parsed); |
| 21 | +}; |
| 22 | + |
| 23 | +// Only `.exe` and `.com` files can be spawned directly; anything else needs `cmd.exe` |
| 24 | +const directlyExecutableRegExp = /\.(?:com|exe)$/i; |
| 25 | + |
| 26 | +// `.cmd` and `.bat` files re-expand their own arguments via `%*`/`%1`, so metacharacters must survive being interpreted by `cmd.exe` twice: once when the batch file is invoked and once when it forwards the arguments |
| 27 | +const batchFileRegExp = /\.(?:bat|cmd)$/i; |
| 28 | + |
| 29 | +const escapeWindowsCommand = parsed => { |
| 30 | + // Resolve the file to an absolute path, following its shebang to the interpreter if any |
| 31 | + const resolvedFile = resolveWithShebang(parsed); |
| 32 | + |
| 33 | + // A directly executable file is spawned as-is, bypassing `cmd.exe` and its escaping |
| 34 | + if (resolvedFile !== undefined && directlyExecutableRegExp.test(resolvedFile)) { |
| 35 | + return parsed; |
| 36 | + } |
| 37 | + |
| 38 | + /* |
| 39 | + `cmd.exe` treats CR and LF as command separators and offers no way to escape them, so allowing either would enable command injection. |
| 40 | + Reject them instead. |
| 41 | + */ |
| 42 | + for (const value of [parsed.file, ...parsed.commandArguments]) { |
| 43 | + assertNoLineBreak(value); |
| 44 | + } |
| 45 | + |
| 46 | + const doubleEscape = resolvedFile !== undefined && batchFileRegExp.test(resolvedFile); |
| 47 | + // POSIX separators must become Windows ones (`foo/bar` -> `foo\bar`), otherwise resolution always fails with ENOENT |
| 48 | + const escapedFile = escapeMetaChars(path.normalize(parsed.file)); |
| 49 | + const escapedArguments = parsed.commandArguments.map(argument => escapeArgument(argument, doubleEscape)); |
| 50 | + const commandLine = `"${[escapedFile, ...escapedArguments].join(' ')}"`; |
| 51 | + |
| 52 | + // Let `node:child_process` pass the already-escaped command line through untouched |
| 53 | + parsed.options.windowsVerbatimArguments = true; |
| 54 | + return { |
| 55 | + file: process.env.comspec || 'cmd.exe', |
| 56 | + commandArguments: ['/d', '/s', '/c', commandLine], |
| 57 | + options: parsed.options, |
| 58 | + }; |
| 59 | +}; |
| 60 | + |
| 61 | +// Resolve the command's absolute path, then, if it is a shebang script, resolve its interpreter instead, since Windows cannot run shebangs natively |
| 62 | +const resolveWithShebang = parsed => { |
| 63 | + const resolvedFile = resolvePath(parsed); |
| 64 | + const interpreter = resolvedFile !== undefined && readShebang(resolvedFile); |
| 65 | + if (!interpreter) { |
| 66 | + return resolvedFile; |
| 67 | + } |
| 68 | + |
| 69 | + // Run the interpreter with the script as its first argument, then resolve the interpreter's own path |
| 70 | + parsed.commandArguments.unshift(resolvedFile); |
| 71 | + parsed.file = interpreter; |
| 72 | + return resolvePath(parsed); |
| 73 | +}; |
| 74 | + |
| 75 | +// Search `PATH` for the command, first honoring `PATHEXT`, then ignoring it as a fallback |
| 76 | +const resolvePath = parsed => resolveInCwd(parsed, false) || resolveInCwd(parsed, true); |
| 77 | + |
| 78 | +const resolveInCwd = (parsed, ignorePathExtension) => { |
| 79 | + const environment = parsed.options.env || process.env; |
| 80 | + |
| 81 | + /* |
| 82 | + `which` runs `stat` relative to the current directory but has no `cwd` option, so we temporarily switch into the target directory and restore it afterwards. |
| 83 | +
|
| 84 | + `process.chdir()` is disabled in worker threads and might be absent in non-Node runtimes. |
| 85 | + */ |
| 86 | + const canChangeDirectory = process.chdir !== undefined && !process.chdir.disabled; |
| 87 | + const originalDirectory = process.cwd(); |
| 88 | + if (canChangeDirectory) { |
| 89 | + try { |
| 90 | + process.chdir(parsed.options.cwd); |
| 91 | + } catch {} |
| 92 | + } |
| 93 | + |
| 94 | + try { |
| 95 | + const resolved = which.sync(parsed.file, { |
| 96 | + path: environment[pathKey({env: environment})], |
| 97 | + pathExt: ignorePathExtension ? path.delimiter : undefined, |
| 98 | + }); |
| 99 | + // `cwd` is already an absolute path, so this returns an absolute path too |
| 100 | + return path.resolve(parsed.options.cwd, resolved); |
| 101 | + } catch { |
| 102 | + return undefined; |
| 103 | + } finally { |
| 104 | + if (canChangeDirectory) { |
| 105 | + process.chdir(originalDirectory); |
| 106 | + } |
| 107 | + } |
| 108 | +}; |
| 109 | + |
| 110 | +const SHEBANG_BYTE_LENGTH = 150; |
| 111 | + |
| 112 | +// Read the file's first bytes to find its shebang interpreter, if it has one |
| 113 | +const readShebang = file => { |
| 114 | + const buffer = Buffer.alloc(SHEBANG_BYTE_LENGTH); |
| 115 | + |
| 116 | + try { |
| 117 | + const fileDescriptor = openSync(file, 'r'); |
| 118 | + try { |
| 119 | + readSync(fileDescriptor, buffer, 0, SHEBANG_BYTE_LENGTH, 0); |
| 120 | + } finally { |
| 121 | + closeSync(fileDescriptor); |
| 122 | + } |
| 123 | + } catch { |
| 124 | + return undefined; |
| 125 | + } |
| 126 | + |
| 127 | + return parseShebang(buffer.toString()); |
| 128 | +}; |
| 129 | + |
| 130 | +const shebangRegExp = /^#!(?<line>.*)/; |
| 131 | + |
| 132 | +/* |
| 133 | +Extract the interpreter from a shebang line, e.g. `#!/usr/bin/env node` -> `node`. |
| 134 | +*/ |
| 135 | +const parseShebang = contents => { |
| 136 | + const shebangLine = contents.match(shebangRegExp)?.groups.line.trim(); |
| 137 | + if (!shebangLine) { |
| 138 | + return undefined; |
| 139 | + } |
| 140 | + |
| 141 | + const [interpreterPath, argument] = shebangLine.split(' '); |
| 142 | + const interpreter = interpreterPath.split('/').at(-1); |
| 143 | + if (interpreter === 'env') { |
| 144 | + return argument; |
| 145 | + } |
| 146 | + |
| 147 | + return argument ? `${interpreter} ${argument}` : interpreter; |
| 148 | +}; |
| 149 | + |
| 150 | +const lineBreakRegExp = /[\n\r]/; |
| 151 | + |
| 152 | +const assertNoLineBreak = value => { |
| 153 | + if (lineBreakRegExp.test(value)) { |
| 154 | + throw new TypeError(`The command and its arguments cannot contain a line break on Windows without a shell.\nThis would allow a command injection with \`cmd.exe\`.\nInvalid value: ${JSON.stringify(`${value}`)}`); |
| 155 | + } |
| 156 | +}; |
| 157 | + |
| 158 | +// See https://web.archive.org/web/20241220221102/https://www.robvanderwoude.com/escapechars.php |
| 159 | +// eslint-disable-next-line regexp/sort-character-class-elements |
| 160 | +const metaCharsRegExp = /[()\][%!^"`<>&|;, *?]/g; |
| 161 | + |
| 162 | +// Prefix every `cmd.exe` metacharacter with a caret to neutralize it |
| 163 | +const escapeMetaChars = value => value.replaceAll(metaCharsRegExp, '^$&'); |
| 164 | + |
| 165 | +const backslashRunRegExp = /\\+/g; |
| 166 | + |
| 167 | +const escapeArgument = (rawArgument, doubleEscape) => { |
| 168 | + /* |
| 169 | + Escape backslashes and double quotes for `cmd.exe`, following the algorithm at https://web.archive.org/web/20240930203505/https://qntm.org/cmd. |
| 170 | +
|
| 171 | + A run of backslashes only needs doubling when it precedes a double quote, or the end of the argument since that becomes a double quote once the argument is wrapped below. Otherwise the backslashes would be taken as escaping that quote. Every double quote is then escaped in turn. |
| 172 | +
|
| 173 | + Each backslash run is matched exactly once and consumed, so a long run cannot trigger the quadratic backtracking a naive pattern would, which would be a denial-of-service risk. |
| 174 | + */ |
| 175 | + const argument = `${rawArgument}` |
| 176 | + .replaceAll(backslashRunRegExp, (backslashes, offset, string) => { |
| 177 | + const nextCharacter = string[offset + backslashes.length]; |
| 178 | + const precedesDoubleQuote = nextCharacter === '"' || nextCharacter === undefined; |
| 179 | + return precedesDoubleQuote ? backslashes.repeat(2) : backslashes; |
| 180 | + }) |
| 181 | + .replaceAll('"', '\\"'); |
| 182 | + |
| 183 | + // Wrap the whole argument in double quotes, then caret-escape the metacharacters, a second time when targeting a cmd-shim |
| 184 | + const escapedArgument = escapeMetaChars(`"${argument}"`); |
| 185 | + return doubleEscape ? escapeMetaChars(escapedArgument) : escapedArgument; |
| 186 | +}; |
0 commit comments