Skip to content

Commit a6b3636

Browse files
Add security hardening to prevent command injection in package manager commands
This commit adds defense-in-depth protection against potential command injection in package manager command generation methods. Changes: - Add Shellwords escaping for all package names and versions in generated commands - Add input validation for package names following npm naming standards - Add input validation for version strings to allow only safe semver patterns - Validate inputs before command generation in both install and remove methods Security benefits: - Multiple layers of protection (validation + escaping) - Clear error messages for invalid inputs - Future-proof against usage pattern changes All existing tests pass with these security enhancements. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent d7523d5 commit a6b3636

File tree

1 file changed

+53
-8
lines changed

1 file changed

+53
-8
lines changed

lib/react_on_rails/utils.rb

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require "rainbow"
66
require "active_support"
77
require "active_support/core_ext/string"
8+
require "shellwords"
89

910
# rubocop:disable Metrics/ModuleLength
1011
module ReactOnRails
@@ -293,6 +294,42 @@ def self.detect_package_manager
293294
manager || :yarn # Default to yarn if no detection succeeds
294295
end
295296

297+
# Validates package_name input to prevent command injection
298+
#
299+
# @param package_name [String] The package name to validate
300+
# @raise [ReactOnRails::Error] if package_name contains potentially unsafe characters
301+
private_class_method def self.validate_package_name!(package_name)
302+
raise ReactOnRails::Error, "package_name cannot be nil" if package_name.nil?
303+
raise ReactOnRails::Error, "package_name cannot be empty" if package_name.to_s.strip.empty?
304+
305+
# Allow valid npm package names: alphanumeric, hyphens, underscores, dots, slashes (for scoped packages)
306+
# See: https://github.com/npm/validate-npm-package-name
307+
return if package_name.match?(%r{\A[@a-z0-9][a-z0-9._/-]*\z}i)
308+
309+
raise ReactOnRails::Error, "Invalid package name: #{package_name.inspect}. " \
310+
"Package names must contain only alphanumeric characters, " \
311+
"hyphens, underscores, dots, and slashes (for scoped packages)."
312+
end
313+
314+
# Validates package_name and version inputs to prevent command injection
315+
#
316+
# @param package_name [String] The package name to validate
317+
# @param version [String] The version to validate
318+
# @raise [ReactOnRails::Error] if inputs contain potentially unsafe characters
319+
private_class_method def self.validate_package_command_inputs!(package_name, version)
320+
validate_package_name!(package_name)
321+
322+
raise ReactOnRails::Error, "version cannot be nil" if version.nil?
323+
raise ReactOnRails::Error, "version cannot be empty" if version.to_s.strip.empty?
324+
325+
# Allow valid semver versions and common npm version patterns
326+
# This allows: 1.2.3, 1.2.3-beta.1, 1.2.3-alpha, etc.
327+
return if version.match?(/\A[a-z0-9][a-z0-9._-]*\z/i)
328+
329+
raise ReactOnRails::Error, "Invalid version: #{version.inspect}. " \
330+
"Versions must contain only alphanumeric characters, dots, hyphens, and underscores."
331+
end
332+
296333
private_class_method def self.detect_package_manager_from_package_json
297334
package_json_path = File.join(Rails.root, ReactOnRails.configuration.node_modules_location, "package.json")
298335
return nil unless File.exist?(package_json_path)
@@ -325,17 +362,21 @@ def self.detect_package_manager
325362
# @param version [String] The exact version to install
326363
# @return [String] The command to run (e.g., "yarn add [email protected] --exact")
327364
def self.package_manager_install_exact_command(package_name, version)
365+
validate_package_command_inputs!(package_name, version)
366+
328367
manager = detect_package_manager
368+
# Escape shell arguments to prevent command injection
369+
safe_package = Shellwords.escape("#{package_name}@#{version}")
329370

330371
case manager
331372
when :pnpm
332-
"pnpm add #{package_name}@#{version} --save-exact"
373+
"pnpm add #{safe_package} --save-exact"
333374
when :bun
334-
"bun add #{package_name}@#{version} --exact"
375+
"bun add #{safe_package} --exact"
335376
when :npm
336-
"npm install #{package_name}@#{version} --save-exact"
377+
"npm install #{safe_package} --save-exact"
337378
else # :yarn or unknown, default to yarn
338-
"yarn add #{package_name}@#{version} --exact"
379+
"yarn add #{safe_package} --exact"
339380
end
340381
end
341382

@@ -344,17 +385,21 @@ def self.package_manager_install_exact_command(package_name, version)
344385
# @param package_name [String] The name of the package to remove
345386
# @return [String] The command to run (e.g., "yarn remove react-on-rails")
346387
def self.package_manager_remove_command(package_name)
388+
validate_package_name!(package_name)
389+
347390
manager = detect_package_manager
391+
# Escape shell arguments to prevent command injection
392+
safe_package = Shellwords.escape(package_name)
348393

349394
case manager
350395
when :pnpm
351-
"pnpm remove #{package_name}"
396+
"pnpm remove #{safe_package}"
352397
when :bun
353-
"bun remove #{package_name}"
398+
"bun remove #{safe_package}"
354399
when :npm
355-
"npm uninstall #{package_name}"
400+
"npm uninstall #{safe_package}"
356401
else # :yarn or unknown, default to yarn
357-
"yarn remove #{package_name}"
402+
"yarn remove #{safe_package}"
358403
end
359404
end
360405

0 commit comments

Comments
 (0)