/**
* Path Security Utilities
*
* SECURITY: Path Traversal Protection (CWE-22) & Command Injection Prevention (CWE-78)
*
* This module provides utilities to prevent directory traversal attacks
* in file path parameters. Used by docker.ts buildImage() to validate
* build context and Dockerfile paths.
*
* Also provides host validation to prevent command injection attacks
* when hostnames are used in SSH commands.
*
* CVSS 7.4 (HIGH) - Prevents attackers from using paths like:
* - ../../../etc/passwd
* - /valid/../../../etc/passwd
* - /path/./to/../../sensitive
*
* @see https://cwe.mitre.org/data/definitions/22.html
* @see https://cwe.mitre.org/data/definitions/78.html
*/
/**
* Security error for invalid host format
*/
export class HostSecurityError extends Error {
constructor(
message: string,
public readonly host: string
) {
super(message);
this.name = "HostSecurityError";
}
}
// Pattern for valid hostnames: alphanumeric, dots, hyphens, underscores
const VALID_HOSTNAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
// Dangerous shell characters that could enable command injection
const DANGEROUS_HOST_CHARS = /[;|$`&<>(){}[\]'"\\!#*?]/;
/**
* Shell metacharacters that could enable command injection in SSH arguments
* More permissive than DANGEROUS_HOST_CHARS to allow valid argument values
*/
const SHELL_METACHARACTERS = /[;&|`$()<>{}[\]\\"\n\r\t]/;
/**
* Pattern for valid systemd service names
* Allows alphanumeric characters plus @ . _ -
*/
export const SYSTEMD_SERVICE_NAME_PATTERN = /^[a-zA-Z0-9@._-]+$/;
/**
* Validates hostname format to prevent command injection.
*
* Security: Prevents CWE-78 (OS Command Injection) by rejecting shell metacharacters
* that could be used to inject arbitrary commands when hostname is used in SSH commands.
* @see https://cwe.mitre.org/data/definitions/78.html
*
* @param hostname - Hostname to validate (must match pattern: alphanumeric, dots, hyphens, underscores)
* @throws {HostSecurityError} If hostname is empty, contains dangerous characters, or has invalid format
* @example
* ```typescript
* validateHostname("proxy.example.com"); // valid
* validateHostname("host-01"); // valid
* validateHostname("bad;rm -rf /"); // throws HostSecurityError
* ```
*/
export function validateHostname(hostname: string): void {
if (!hostname || hostname.length === 0) {
throw new HostSecurityError("Hostname cannot be empty", hostname);
}
if (DANGEROUS_HOST_CHARS.test(hostname)) {
throw new HostSecurityError(
`Invalid characters in hostname: ${hostname.substring(0, 50)}`,
hostname
);
}
if (!VALID_HOSTNAME_PATTERN.test(hostname)) {
throw new HostSecurityError(`Invalid hostname format: ${hostname.substring(0, 50)}`, hostname);
}
}
/**
* Security error for SSH argument validation
*/
export class SSHArgSecurityError extends Error {
constructor(
message: string,
public readonly arg: string,
public readonly paramName: string
) {
super(message);
this.name = "SSHArgSecurityError";
}
}
/**
* Validates SSH command argument to prevent command injection.
*
* Security: Prevents CWE-77 (Command Injection) and CWE-78 (OS Command Injection)
* by rejecting shell metacharacters. The SSH service joins args with spaces and executes
* as a shell command, so an attacker could inject arbitrary commands (e.g., "running; rm -rf /").
* @see https://cwe.mitre.org/data/definitions/77.html
* @see https://cwe.mitre.org/data/definitions/78.html
*
* @param arg - Argument value to validate (max 500 characters, no shell metacharacters)
* @param paramName - Name of the parameter (used in error messages for context)
* @throws {SSHArgSecurityError} If arg is empty, contains shell metacharacters, or exceeds 500 characters
* @example
* ```typescript
* validateSSHArg("running", "status"); // valid
* validateSSHArg("docker-compose.yml", "file"); // valid
* validateSSHArg("arg; rm -rf /", "input"); // throws SSHArgSecurityError
* ```
*/
export function validateSSHArg(arg: string, paramName: string): void {
if (!arg || arg.length === 0) {
throw new SSHArgSecurityError(`${paramName} cannot be empty`, arg, paramName);
}
if (SHELL_METACHARACTERS.test(arg)) {
throw new SSHArgSecurityError(
`Invalid character in ${paramName}: shell metacharacters not allowed`,
arg.substring(0, 50),
paramName
);
}
// Additional safety: reject extremely long arguments (DoS prevention)
if (arg.length > 500) {
throw new SSHArgSecurityError(
`${paramName} too long: maximum 500 characters allowed`,
arg.substring(0, 50),
paramName
);
}
}
/**
* @deprecated Use validateServiceName from validation.ts instead
* Re-exported for backwards compatibility
*/
export { validateServiceName as validateSystemdServiceName } from "./validation.js";
/**
* Escapes a string for safe use as a shell argument.
*
* Security: Prevents CWE-78 (OS Command Injection) by wrapping argument in single quotes
* and properly escaping any embedded single quotes using the '\'' sequence.
* @see https://cwe.mitre.org/data/definitions/78.html
*
* @param arg - String to escape for shell usage
* @returns Safely quoted string suitable for shell command construction
* @example
* ```typescript
* escapeShellArg("hello world"); // returns: 'hello world'
* escapeShellArg("it's a test"); // returns: 'it'\''s a test'
* escapeShellArg("$(rm -rf /)"); // returns: '$(rm -rf /)' (neutered)
* ```
*/
export function escapeShellArg(arg: string): string {
// Single quote the entire string, escaping any embedded single quotes
// by ending the quote, adding an escaped single quote, and starting a new quote
return `'${arg.replace(/'/g, "'\\''")}'`;
}
/**
* System paths that should trigger warnings when used as transfer targets
*/
const SYSTEM_PATH_PREFIXES = [
"/etc",
"/bin",
"/sbin",
"/usr/bin",
"/usr/sbin",
"/lib",
"/lib64",
"/boot",
"/root",
];
/**
* Checks if a path is a system path that should be protected from modification.
*
* Identifies paths in critical system directories (/etc, /bin, /sbin, /usr/bin, /usr/sbin,
* /lib, /lib64, /boot, /root) that typically require elevated privileges and should
* trigger warnings when used as transfer targets.
*
* @param path - Absolute path to check against system directory prefixes
* @returns true if path is in a system directory, false otherwise
* @example
* ```typescript
* isSystemPath("/etc/nginx/nginx.conf"); // true
* isSystemPath("/home/user/file.txt"); // false
* isSystemPath("/usr/bin/curl"); // true
* ```
*/
export function isSystemPath(path: string): boolean {
return SYSTEM_PATH_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}
/**
* Validates that a file path is safe from directory traversal attacks.
*
* Security: Prevents CWE-22 (Path Traversal) by enforcing strict path validation rules
* to prevent attackers from accessing files outside intended directories.
* @see https://cwe.mitre.org/data/definitions/22.html
*
* SECURITY (S-M5, CWE-22): Character Set Restrictions
* ----------------------------------------------------
* This function deliberately uses a conservative character whitelist to prioritize
* security over flexibility. The allowed character set is: [a-zA-Z0-9._\-/]
*
* **Allowed:**
* - Alphanumeric: a-z, A-Z, 0-9
* - Separators: forward slash (/)
* - Safe punctuation: dot (.), hyphen (-), underscore (_)
*
* **Explicitly Rejected:**
* - Spaces: Rejected to prevent parsing ambiguity in shell contexts and URL encoding issues
* - Colons: Rejected to prevent NTFS alternate data stream access (Windows) and URL scheme confusion
* - Backslashes: Rejected to prevent Windows path traversal and escape sequence injection
* - Shell metacharacters: &, |, ;, $, `, <, >, (, ), {, }, [, ], *, ?, ~, !, #, @
*
* **Design Rationale:**
* While spaces and colons are technically valid in POSIX paths, they create security risks:
* 1. Spaces require proper quoting in shell contexts (SSH commands, Docker exec)
* 2. Colons enable Windows alternate data streams (file.txt:hidden) and path separator confusion
* 3. Conservative whitelist prevents future attack vectors as new exploits are discovered
*
* **If you need paths with spaces or colons:**
* Use escapeShellArg() for shell command construction, but understand this function
* deliberately rejects them at the validation layer as a defense-in-depth measure.
* The security review (02-security-performance.md, S-M5) concluded: "Security over flexibility."
*
* Validation rules:
* 1. Must be absolute path (starts with /)
* 2. Cannot contain .. (parent directory traversal)
* 3. Cannot contain . as a standalone component (current directory)
* 4. Must contain only safe characters: a-zA-Z0-9._-/
* 5. Path resolution must not result in traversal
*
* @param path - The file path to validate (must be absolute)
* @param paramName - Name of the parameter (used in error messages for context)
* @throws {Error} If path is empty, contains traversal sequences, has invalid characters, or is not absolute
* @example
* ```typescript
* // Valid paths
* validateSecurePath("/data/files/doc.txt", "file"); // OK
* validateSecurePath("/app/config.json", "config"); // OK
* validateSecurePath("/srv/docker-compose.yml", "file"); // OK (hyphens allowed)
* validateSecurePath("/home/user_name/file.tar.gz", "archive"); // OK (underscores, dots allowed)
*
* // Rejected for security
* validateSecurePath("../../../etc/passwd", "file"); // throws: relative path with traversal
* validateSecurePath("/valid/../../../etc/passwd", "file"); // throws: traversal sequence
* validateSecurePath("/path with spaces/file.txt", "file"); // throws: spaces not allowed
* validateSecurePath("/path:alternate/file", "file"); // throws: colons not allowed
* validateSecurePath("/path/$(rm -rf /)", "file"); // throws: shell metacharacters not allowed
* ```
*/
export function validateSecurePath(path: string, paramName: string): void {
// 1. Check for empty path
if (!path || path.length === 0) {
throw new Error(`${paramName}: Path cannot be empty`);
}
// 2. Character validation - only allow alphanumeric, dots, hyphens, underscores, forward slashes
if (!/^[a-zA-Z0-9._\-/]+$/.test(path)) {
throw new Error(`${paramName}: Invalid characters in path: ${path}`);
}
// 3. Split path into components and check for ".." traversal first
const components = path.split("/").filter((c) => c.length > 0);
for (const component of components) {
// Reject ".." (parent directory traversal) - check this first
if (component === "..") {
throw new Error(`${paramName}: directory traversal (..) not allowed in path: ${path}`);
}
}
// 4. Must be absolute path (starts with /) - checked after .. but before .
if (!path.startsWith("/")) {
throw new Error(`${paramName}: absolute path required, got: ${path}`);
}
// 5. Check for "." as standalone component (only in absolute paths)
for (const component of components) {
// Reject "." as standalone component (current directory)
// BUT allow dots in filenames like "file.txt" or "config.prod"
if (component === ".") {
throw new Error(`${paramName}: directory traversal (.) not allowed in path: ${path}`);
}
}
// Path is secure - all ".." and "." components rejected, absolute path verified
}