command-validation.ts 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. import { parse } from 'shell-quote'
  2. type ShellToken = string | { op: string } | { command: string }
  3. /**
  4. * Split a command string into individual sub-commands by
  5. * chaining operators (&&, ||, ;, or |).
  6. *
  7. * Uses shell-quote to properly handle:
  8. * - Quoted strings (preserves quotes)
  9. * - Subshell commands ($(cmd) or `cmd`)
  10. * - PowerShell redirections (2>&1)
  11. * - Chain operators (&&, ||, ;, |)
  12. */
  13. export function parseCommand(command: string): string[] {
  14. if (!command?.trim()) return []
  15. // First handle PowerShell redirections by temporarily replacing them
  16. const redirections: string[] = []
  17. let processedCommand = command.replace(/\d*>&\d*/g, (match) => {
  18. redirections.push(match)
  19. return `__REDIR_${redirections.length - 1}__`
  20. })
  21. // Then handle subshell commands
  22. const subshells: string[] = []
  23. processedCommand = processedCommand
  24. .replace(/\$\((.*?)\)/g, (_, inner) => {
  25. subshells.push(inner.trim())
  26. return `__SUBSH_${subshells.length - 1}__`
  27. })
  28. .replace(/`(.*?)`/g, (_, inner) => {
  29. subshells.push(inner.trim())
  30. return `__SUBSH_${subshells.length - 1}__`
  31. })
  32. // Then handle quoted strings
  33. const quotes: string[] = []
  34. processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => {
  35. quotes.push(match)
  36. return `__QUOTE_${quotes.length - 1}__`
  37. })
  38. const tokens = parse(processedCommand) as ShellToken[]
  39. const commands: string[] = []
  40. let currentCommand: string[] = []
  41. for (const token of tokens) {
  42. if (typeof token === 'object' && 'op' in token) {
  43. // Chain operator - split command
  44. if (['&&', '||', ';', '|'].includes(token.op)) {
  45. if (currentCommand.length > 0) {
  46. commands.push(currentCommand.join(' '))
  47. currentCommand = []
  48. }
  49. } else {
  50. // Other operators (>, &) are part of the command
  51. currentCommand.push(token.op)
  52. }
  53. } else if (typeof token === 'string') {
  54. // Check if it's a subshell placeholder
  55. const subshellMatch = token.match(/__SUBSH_(\d+)__/)
  56. if (subshellMatch) {
  57. if (currentCommand.length > 0) {
  58. commands.push(currentCommand.join(' '))
  59. currentCommand = []
  60. }
  61. commands.push(subshells[parseInt(subshellMatch[1])])
  62. } else {
  63. currentCommand.push(token)
  64. }
  65. }
  66. }
  67. // Add any remaining command
  68. if (currentCommand.length > 0) {
  69. commands.push(currentCommand.join(' '))
  70. }
  71. // Restore quotes and redirections
  72. return commands.map(cmd => {
  73. let result = cmd
  74. // Restore quotes
  75. result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
  76. // Restore redirections
  77. result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
  78. return result
  79. })
  80. }
  81. /**
  82. * Check if a single command is allowed based on prefix matching.
  83. */
  84. export function isAllowedSingleCommand(
  85. command: string,
  86. allowedCommands: string[]
  87. ): boolean {
  88. if (!command || !allowedCommands?.length) return false
  89. const trimmedCommand = command.trim().toLowerCase()
  90. return allowedCommands.some(prefix =>
  91. trimmedCommand.startsWith(prefix.toLowerCase())
  92. )
  93. }
  94. /**
  95. * Check if a command string is allowed based on the allowed command prefixes.
  96. * This version also blocks subshell attempts by checking for `$(` or `` ` ``.
  97. */
  98. export function validateCommand(command: string, allowedCommands: string[]): boolean {
  99. if (!command?.trim()) return true
  100. // Block subshell execution attempts
  101. if (command.includes('$(') || command.includes('`')) {
  102. return false
  103. }
  104. // Parse into sub-commands (split by &&, ||, ;, |)
  105. const subCommands = parseCommand(command)
  106. // Then ensure every sub-command starts with an allowed prefix
  107. return subCommands.every(cmd => {
  108. // Remove simple PowerShell-like redirections (e.g. 2>&1) before checking
  109. const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, '').trim()
  110. return isAllowedSingleCommand(cmdWithoutRedirection, allowedCommands)
  111. })
  112. }