305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
/**
|
|
* Safe wrappers for shell-quote library functions that handle errors gracefully
|
|
* These are drop-in replacements for the original functions
|
|
*/
|
|
|
|
import {
|
|
type ParseEntry,
|
|
parse as shellQuoteParse,
|
|
quote as shellQuoteQuote,
|
|
} from 'shell-quote'
|
|
import { logError } from '../log.js'
|
|
import { jsonStringify } from '../slowOperations.js'
|
|
|
|
export type { ParseEntry } from 'shell-quote'
|
|
|
|
export type ShellParseResult =
|
|
| { success: true; tokens: ParseEntry[] }
|
|
| { success: false; error: string }
|
|
|
|
export type ShellQuoteResult =
|
|
| { success: true; quoted: string }
|
|
| { success: false; error: string }
|
|
|
|
export function tryParseShellCommand(
|
|
cmd: string,
|
|
env?:
|
|
| Record<string, string | undefined>
|
|
| ((key: string) => string | undefined),
|
|
): ShellParseResult {
|
|
try {
|
|
const tokens =
|
|
typeof env === 'function'
|
|
? shellQuoteParse(cmd, env)
|
|
: shellQuoteParse(cmd, env)
|
|
return { success: true, tokens }
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
logError(error)
|
|
}
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown parse error',
|
|
}
|
|
}
|
|
}
|
|
|
|
export function tryQuoteShellArgs(args: unknown[]): ShellQuoteResult {
|
|
try {
|
|
const validated: string[] = args.map((arg, index) => {
|
|
if (arg === null || arg === undefined) {
|
|
return String(arg)
|
|
}
|
|
|
|
const type = typeof arg
|
|
|
|
if (type === 'string') {
|
|
return arg as string
|
|
}
|
|
if (type === 'number' || type === 'boolean') {
|
|
return String(arg)
|
|
}
|
|
|
|
if (type === 'object') {
|
|
throw new Error(
|
|
`Cannot quote argument at index ${index}: object values are not supported`,
|
|
)
|
|
}
|
|
if (type === 'symbol') {
|
|
throw new Error(
|
|
`Cannot quote argument at index ${index}: symbol values are not supported`,
|
|
)
|
|
}
|
|
if (type === 'function') {
|
|
throw new Error(
|
|
`Cannot quote argument at index ${index}: function values are not supported`,
|
|
)
|
|
}
|
|
|
|
throw new Error(
|
|
`Cannot quote argument at index ${index}: unsupported type ${type}`,
|
|
)
|
|
})
|
|
|
|
const quoted = shellQuoteQuote(validated)
|
|
return { success: true, quoted }
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
logError(error)
|
|
}
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : 'Unknown quote error',
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if parsed tokens contain malformed entries that suggest shell-quote
|
|
* misinterpreted the command. This happens when input contains ambiguous
|
|
* patterns (like JSON-like strings with semicolons) that shell-quote parses
|
|
* according to shell rules, producing token fragments.
|
|
*
|
|
* For example, `echo {"hi":"hi;evil"}` gets parsed with `;` as an operator,
|
|
* producing tokens like `{hi:"hi` (unbalanced brace). Legitimate commands
|
|
* produce complete, balanced tokens.
|
|
*
|
|
* Also detects unterminated quotes in the original command: shell-quote
|
|
* silently drops an unmatched `"` or `'` and parses the rest as unquoted,
|
|
* leaving no trace in the tokens. `echo "hi;evil | cat` (one unmatched `"`)
|
|
* is a bash syntax error, but shell-quote yields clean tokens with `;` as
|
|
* an operator. The token-level checks below can't catch this, so we walk
|
|
* the original command with bash quote semantics and flag odd parity.
|
|
*
|
|
* Security: This prevents command injection via HackerOne #3482049 where
|
|
* shell-quote's correct parsing of ambiguous input can be exploited.
|
|
*/
|
|
export function hasMalformedTokens(
|
|
command: string,
|
|
parsed: ParseEntry[],
|
|
): boolean {
|
|
// Check for unterminated quotes in the original command. shell-quote drops
|
|
// an unmatched quote without leaving any trace in the tokens, so this must
|
|
// inspect the raw string. Walk with bash semantics: backslash escapes the
|
|
// next char outside single-quotes; no escapes inside single-quotes.
|
|
let inSingle = false
|
|
let inDouble = false
|
|
let doubleCount = 0
|
|
let singleCount = 0
|
|
for (let i = 0; i < command.length; i++) {
|
|
const c = command[i]
|
|
if (c === '\\' && !inSingle) {
|
|
i++
|
|
continue
|
|
}
|
|
if (c === '"' && !inSingle) {
|
|
doubleCount++
|
|
inDouble = !inDouble
|
|
} else if (c === "'" && !inDouble) {
|
|
singleCount++
|
|
inSingle = !inSingle
|
|
}
|
|
}
|
|
if (doubleCount % 2 !== 0 || singleCount % 2 !== 0) return true
|
|
|
|
for (const entry of parsed) {
|
|
if (typeof entry !== 'string') continue
|
|
|
|
// Check for unbalanced curly braces
|
|
const openBraces = (entry.match(/{/g) || []).length
|
|
const closeBraces = (entry.match(/}/g) || []).length
|
|
if (openBraces !== closeBraces) return true
|
|
|
|
// Check for unbalanced parentheses
|
|
const openParens = (entry.match(/\(/g) || []).length
|
|
const closeParens = (entry.match(/\)/g) || []).length
|
|
if (openParens !== closeParens) return true
|
|
|
|
// Check for unbalanced square brackets
|
|
const openBrackets = (entry.match(/\[/g) || []).length
|
|
const closeBrackets = (entry.match(/\]/g) || []).length
|
|
if (openBrackets !== closeBrackets) return true
|
|
|
|
// Check for unbalanced double quotes
|
|
// Count quotes that aren't escaped (preceded by backslash)
|
|
// A token with an odd number of unescaped quotes is malformed
|
|
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- gated by hasCommandSeparator check at caller, runs on short per-token strings
|
|
const doubleQuotes = entry.match(/(?<!\\)"/g) || []
|
|
if (doubleQuotes.length % 2 !== 0) return true
|
|
|
|
// Check for unbalanced single quotes
|
|
// eslint-disable-next-line custom-rules/no-lookbehind-regex -- same as above
|
|
const singleQuotes = entry.match(/(?<!\\)'/g) || []
|
|
if (singleQuotes.length % 2 !== 0) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Detects commands containing '\' patterns that exploit the shell-quote library's
|
|
* incorrect handling of backslashes inside single quotes.
|
|
*
|
|
* In bash, single quotes preserve ALL characters literally - backslash has no
|
|
* special meaning. So '\' is just the string \ (the quote opens, contains \,
|
|
* and the next ' closes it). But shell-quote incorrectly treats \ as an escape
|
|
* character inside single quotes, causing '\' to NOT close the quoted string.
|
|
*
|
|
* This means the pattern '\' <payload> '\' hides <payload> from security checks
|
|
* because shell-quote thinks it's all one single-quoted string.
|
|
*/
|
|
export function hasShellQuoteSingleQuoteBug(command: string): boolean {
|
|
// Walk the command with correct bash single-quote semantics
|
|
let inSingleQuote = false
|
|
let inDoubleQuote = false
|
|
|
|
for (let i = 0; i < command.length; i++) {
|
|
const char = command[i]
|
|
|
|
// Handle backslash escaping outside of single quotes
|
|
if (char === '\\' && !inSingleQuote) {
|
|
// Skip the next character (it's escaped)
|
|
i++
|
|
continue
|
|
}
|
|
|
|
if (char === '"' && !inSingleQuote) {
|
|
inDoubleQuote = !inDoubleQuote
|
|
continue
|
|
}
|
|
|
|
if (char === "'" && !inDoubleQuote) {
|
|
inSingleQuote = !inSingleQuote
|
|
|
|
// Check if we just closed a single quote and the content ends with
|
|
// trailing backslashes. shell-quote's chunker regex '((\\'|[^'])*?)'
|
|
// incorrectly treats \' as an escape sequence inside single quotes,
|
|
// while bash treats backslash as literal. This creates a differential
|
|
// where shell-quote merges tokens that bash treats as separate.
|
|
//
|
|
// Odd trailing \'s = always a bug:
|
|
// '\' -> shell-quote: \' = literal ', still open. bash: \, closed.
|
|
// 'abc\' -> shell-quote: abc then \' = literal ', still open. bash: abc\, closed.
|
|
// '\\\' -> shell-quote: \\ + \', still open. bash: \\\, closed.
|
|
//
|
|
// Even trailing \'s = bug ONLY when a later ' exists in the command:
|
|
// '\\' alone -> shell-quote backtracks, both parsers agree string closes. OK.
|
|
// '\\' 'next' -> shell-quote: \' consumes the closing ', finds next ' as
|
|
// false close, merges tokens. bash: two separate tokens.
|
|
//
|
|
// Detail: the regex alternation tries \' before [^']. For '\\', it matches
|
|
// the first \ via [^'] (next char is \, not '), then the second \ via \'
|
|
// (next char IS '). This consumes the closing '. The regex continues reading
|
|
// until it finds another ' to close the match. If none exists, it backtracks
|
|
// to [^'] for the second \ and closes correctly. If a later ' exists (e.g.,
|
|
// the opener of the next single-quoted arg), no backtracking occurs and
|
|
// tokens merge. See H1 report: git ls-remote 'safe\\' '--upload-pack=evil' 'repo'
|
|
// shell-quote: ["git","ls-remote","safe\\\\ --upload-pack=evil repo"]
|
|
// bash: ["git","ls-remote","safe\\\\","--upload-pack=evil","repo"]
|
|
if (!inSingleQuote) {
|
|
let backslashCount = 0
|
|
let j = i - 1
|
|
while (j >= 0 && command[j] === '\\') {
|
|
backslashCount++
|
|
j--
|
|
}
|
|
if (backslashCount > 0 && backslashCount % 2 === 1) {
|
|
return true
|
|
}
|
|
// Even trailing backslashes: only a bug when a later ' exists that
|
|
// the chunker regex can use as a false closing quote. We check for
|
|
// ANY later ' because the regex doesn't respect bash quote state
|
|
// (e.g., a ' inside double quotes is also consumable).
|
|
if (
|
|
backslashCount > 0 &&
|
|
backslashCount % 2 === 0 &&
|
|
command.indexOf("'", i + 1) !== -1
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export function quote(args: ReadonlyArray<unknown>): string {
|
|
// First try the strict validation
|
|
const result = tryQuoteShellArgs([...args])
|
|
|
|
if (result.success) {
|
|
return result.quoted
|
|
}
|
|
|
|
// If strict validation failed, use lenient fallback
|
|
// This handles objects, symbols, functions, etc. by converting them to strings
|
|
try {
|
|
const stringArgs = args.map(arg => {
|
|
if (arg === null || arg === undefined) {
|
|
return String(arg)
|
|
}
|
|
|
|
const type = typeof arg
|
|
|
|
if (type === 'string' || type === 'number' || type === 'boolean') {
|
|
return String(arg)
|
|
}
|
|
|
|
// For unsupported types, use JSON.stringify as a safe fallback
|
|
// This ensures we don't crash but still get a meaningful representation
|
|
return jsonStringify(arg)
|
|
})
|
|
|
|
return shellQuoteQuote(stringArgs)
|
|
} catch (error) {
|
|
// SECURITY: Never use JSON.stringify as a fallback for shell quoting.
|
|
// JSON.stringify uses double quotes which don't prevent shell command execution.
|
|
// For example, jsonStringify(['echo', '$(whoami)']) produces "echo" "$(whoami)"
|
|
if (error instanceof Error) {
|
|
logError(error)
|
|
}
|
|
throw new Error('Failed to quote shell arguments safely')
|
|
}
|
|
}
|