shellQuote.ts 11 KB

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