source dump of claude code
at main 304 lines 11 kB view raw
1/** 2 * Safe wrappers for shell-quote library functions that handle errors gracefully 3 * These are drop-in replacements for the original functions 4 */ 5 6import { 7 type ParseEntry, 8 parse as shellQuoteParse, 9 quote as shellQuoteQuote, 10} from 'shell-quote' 11import { logError } from '../log.js' 12import { jsonStringify } from '../slowOperations.js' 13 14export type { ParseEntry } from 'shell-quote' 15 16export type ShellParseResult = 17 | { success: true; tokens: ParseEntry[] } 18 | { success: false; error: string } 19 20export type ShellQuoteResult = 21 | { success: true; quoted: string } 22 | { success: false; error: string } 23 24export function tryParseShellCommand( 25 cmd: string, 26 env?: 27 | Record<string, string | undefined> 28 | ((key: string) => string | undefined), 29): ShellParseResult { 30 try { 31 const tokens = 32 typeof env === 'function' 33 ? shellQuoteParse(cmd, env) 34 : shellQuoteParse(cmd, env) 35 return { success: true, tokens } 36 } catch (error) { 37 if (error instanceof Error) { 38 logError(error) 39 } 40 return { 41 success: false, 42 error: error instanceof Error ? error.message : 'Unknown parse error', 43 } 44 } 45} 46 47export function tryQuoteShellArgs(args: unknown[]): ShellQuoteResult { 48 try { 49 const validated: string[] = args.map((arg, index) => { 50 if (arg === null || arg === undefined) { 51 return String(arg) 52 } 53 54 const type = typeof arg 55 56 if (type === 'string') { 57 return arg as string 58 } 59 if (type === 'number' || type === 'boolean') { 60 return String(arg) 61 } 62 63 if (type === 'object') { 64 throw new Error( 65 `Cannot quote argument at index ${index}: object values are not supported`, 66 ) 67 } 68 if (type === 'symbol') { 69 throw new Error( 70 `Cannot quote argument at index ${index}: symbol values are not supported`, 71 ) 72 } 73 if (type === 'function') { 74 throw new Error( 75 `Cannot quote argument at index ${index}: function values are not supported`, 76 ) 77 } 78 79 throw new Error( 80 `Cannot quote argument at index ${index}: unsupported type ${type}`, 81 ) 82 }) 83 84 const quoted = shellQuoteQuote(validated) 85 return { success: true, quoted } 86 } catch (error) { 87 if (error instanceof Error) { 88 logError(error) 89 } 90 return { 91 success: false, 92 error: error instanceof Error ? error.message : 'Unknown quote error', 93 } 94 } 95} 96 97/** 98 * Checks if parsed tokens contain malformed entries that suggest shell-quote 99 * misinterpreted the command. This happens when input contains ambiguous 100 * patterns (like JSON-like strings with semicolons) that shell-quote parses 101 * according to shell rules, producing token fragments. 102 * 103 * For example, `echo {"hi":"hi;evil"}` gets parsed with `;` as an operator, 104 * producing tokens like `{hi:"hi` (unbalanced brace). Legitimate commands 105 * produce complete, balanced tokens. 106 * 107 * Also detects unterminated quotes in the original command: shell-quote 108 * silently drops an unmatched `"` or `'` and parses the rest as unquoted, 109 * leaving no trace in the tokens. `echo "hi;evil | cat` (one unmatched `"`) 110 * is a bash syntax error, but shell-quote yields clean tokens with `;` as 111 * an operator. The token-level checks below can't catch this, so we walk 112 * the original command with bash quote semantics and flag odd parity. 113 * 114 * Security: This prevents command injection via HackerOne #3482049 where 115 * shell-quote's correct parsing of ambiguous input can be exploited. 116 */ 117export function hasMalformedTokens( 118 command: string, 119 parsed: ParseEntry[], 120): boolean { 121 // Check for unterminated quotes in the original command. shell-quote drops 122 // an unmatched quote without leaving any trace in the tokens, so this must 123 // inspect the raw string. Walk with bash semantics: backslash escapes the 124 // next char outside single-quotes; no escapes inside single-quotes. 125 let inSingle = false 126 let inDouble = false 127 let doubleCount = 0 128 let singleCount = 0 129 for (let i = 0; i < command.length; i++) { 130 const c = command[i] 131 if (c === '\\' && !inSingle) { 132 i++ 133 continue 134 } 135 if (c === '"' && !inSingle) { 136 doubleCount++ 137 inDouble = !inDouble 138 } else if (c === "'" && !inDouble) { 139 singleCount++ 140 inSingle = !inSingle 141 } 142 } 143 if (doubleCount % 2 !== 0 || singleCount % 2 !== 0) return true 144 145 for (const entry of parsed) { 146 if (typeof entry !== 'string') continue 147 148 // Check for unbalanced curly braces 149 const openBraces = (entry.match(/{/g) || []).length 150 const closeBraces = (entry.match(/}/g) || []).length 151 if (openBraces !== closeBraces) return true 152 153 // Check for unbalanced parentheses 154 const openParens = (entry.match(/\(/g) || []).length 155 const closeParens = (entry.match(/\)/g) || []).length 156 if (openParens !== closeParens) return true 157 158 // Check for unbalanced square brackets 159 const openBrackets = (entry.match(/\[/g) || []).length 160 const closeBrackets = (entry.match(/\]/g) || []).length 161 if (openBrackets !== closeBrackets) return true 162 163 // Check for unbalanced double quotes 164 // Count quotes that aren't escaped (preceded by backslash) 165 // A token with an odd number of unescaped quotes is malformed 166 // eslint-disable-next-line custom-rules/no-lookbehind-regex -- gated by hasCommandSeparator check at caller, runs on short per-token strings 167 const doubleQuotes = entry.match(/(?<!\\)"/g) || [] 168 if (doubleQuotes.length % 2 !== 0) return true 169 170 // Check for unbalanced single quotes 171 // eslint-disable-next-line custom-rules/no-lookbehind-regex -- same as above 172 const singleQuotes = entry.match(/(?<!\\)'/g) || [] 173 if (singleQuotes.length % 2 !== 0) return true 174 } 175 return false 176} 177 178/** 179 * Detects commands containing '\' patterns that exploit the shell-quote library's 180 * incorrect handling of backslashes inside single quotes. 181 * 182 * In bash, single quotes preserve ALL characters literally - backslash has no 183 * special meaning. So '\' is just the string \ (the quote opens, contains \, 184 * and the next ' closes it). But shell-quote incorrectly treats \ as an escape 185 * character inside single quotes, causing '\' to NOT close the quoted string. 186 * 187 * This means the pattern '\' <payload> '\' hides <payload> from security checks 188 * because shell-quote thinks it's all one single-quoted string. 189 */ 190export function hasShellQuoteSingleQuoteBug(command: string): boolean { 191 // Walk the command with correct bash single-quote semantics 192 let inSingleQuote = false 193 let inDoubleQuote = false 194 195 for (let i = 0; i < command.length; i++) { 196 const char = command[i] 197 198 // Handle backslash escaping outside of single quotes 199 if (char === '\\' && !inSingleQuote) { 200 // Skip the next character (it's escaped) 201 i++ 202 continue 203 } 204 205 if (char === '"' && !inSingleQuote) { 206 inDoubleQuote = !inDoubleQuote 207 continue 208 } 209 210 if (char === "'" && !inDoubleQuote) { 211 inSingleQuote = !inSingleQuote 212 213 // Check if we just closed a single quote and the content ends with 214 // trailing backslashes. shell-quote's chunker regex '((\\'|[^'])*?)' 215 // incorrectly treats \' as an escape sequence inside single quotes, 216 // while bash treats backslash as literal. This creates a differential 217 // where shell-quote merges tokens that bash treats as separate. 218 // 219 // Odd trailing \'s = always a bug: 220 // '\' -> shell-quote: \' = literal ', still open. bash: \, closed. 221 // 'abc\' -> shell-quote: abc then \' = literal ', still open. bash: abc\, closed. 222 // '\\\' -> shell-quote: \\ + \', still open. bash: \\\, closed. 223 // 224 // Even trailing \'s = bug ONLY when a later ' exists in the command: 225 // '\\' alone -> shell-quote backtracks, both parsers agree string closes. OK. 226 // '\\' 'next' -> shell-quote: \' consumes the closing ', finds next ' as 227 // false close, merges tokens. bash: two separate tokens. 228 // 229 // Detail: the regex alternation tries \' before [^']. For '\\', it matches 230 // the first \ via [^'] (next char is \, not '), then the second \ via \' 231 // (next char IS '). This consumes the closing '. The regex continues reading 232 // until it finds another ' to close the match. If none exists, it backtracks 233 // to [^'] for the second \ and closes correctly. If a later ' exists (e.g., 234 // the opener of the next single-quoted arg), no backtracking occurs and 235 // tokens merge. See H1 report: git ls-remote 'safe\\' '--upload-pack=evil' 'repo' 236 // shell-quote: ["git","ls-remote","safe\\\\ --upload-pack=evil repo"] 237 // bash: ["git","ls-remote","safe\\\\","--upload-pack=evil","repo"] 238 if (!inSingleQuote) { 239 let backslashCount = 0 240 let j = i - 1 241 while (j >= 0 && command[j] === '\\') { 242 backslashCount++ 243 j-- 244 } 245 if (backslashCount > 0 && backslashCount % 2 === 1) { 246 return true 247 } 248 // Even trailing backslashes: only a bug when a later ' exists that 249 // the chunker regex can use as a false closing quote. We check for 250 // ANY later ' because the regex doesn't respect bash quote state 251 // (e.g., a ' inside double quotes is also consumable). 252 if ( 253 backslashCount > 0 && 254 backslashCount % 2 === 0 && 255 command.indexOf("'", i + 1) !== -1 256 ) { 257 return true 258 } 259 } 260 continue 261 } 262 } 263 264 return false 265} 266 267export function quote(args: ReadonlyArray<unknown>): string { 268 // First try the strict validation 269 const result = tryQuoteShellArgs([...args]) 270 271 if (result.success) { 272 return result.quoted 273 } 274 275 // If strict validation failed, use lenient fallback 276 // This handles objects, symbols, functions, etc. by converting them to strings 277 try { 278 const stringArgs = args.map(arg => { 279 if (arg === null || arg === undefined) { 280 return String(arg) 281 } 282 283 const type = typeof arg 284 285 if (type === 'string' || type === 'number' || type === 'boolean') { 286 return String(arg) 287 } 288 289 // For unsupported types, use JSON.stringify as a safe fallback 290 // This ensures we don't crash but still get a meaningful representation 291 return jsonStringify(arg) 292 }) 293 294 return shellQuoteQuote(stringArgs) 295 } catch (error) { 296 // SECURITY: Never use JSON.stringify as a fallback for shell quoting. 297 // JSON.stringify uses double quotes which don't prevent shell command execution. 298 // For example, jsonStringify(['echo', '$(whoami)']) produces "echo" "$(whoami)" 299 if (error instanceof Error) { 300 logError(error) 301 } 302 throw new Error('Failed to quote shell arguments safely') 303 } 304}