coolify/app/Support/ValidationPatterns.php
Andras Bacsai 817128c5af refactor(validation): tokenize shell-safe command pattern
Replace the flat character-class regex for SHELL_SAFE_COMMAND_PATTERN with
a token-aware alternation. The parser now recognizes explicit tokens
(`&&`, `||`, balanced single/double quotes, whitespace, and an unquoted
safe-char run) instead of a bag of characters, which lets us extend the
accepted grammar without loosening the guarantees.

New surface area, with tests:
- logical OR chaining (`make build || make clean`)
- shell globs and bang (`rm *.tmp`, `cp src/?.js dist/`, `! grep -q foo`)
- single-quoted arguments are now treated as balanced runs rather than
  rejected per-character

Preserved surface area:
- && chaining, balanced "..." and '...' quotes, the previous safe path /
  argument characters, and the existing error-path contract in
  ApplicationDeploymentJob::validateShellSafeCommand().

Also refreshes the user-facing validation messages in General.php so the
allow/deny list shown on failure matches the new grammar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 22:00:41 +02:00

426 lines
14 KiB
PHP

<?php
namespace App\Support;
/**
* Shared validation patterns for consistent use across the application
*/
class ValidationPatterns
{
/**
* Pattern for names excluding all dangerous characters
*/
public const NAME_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.@\/&()#,:+]+$/u';
/**
* Pattern for descriptions excluding all dangerous characters with some additional allowed characters
*/
public const DESCRIPTION_PATTERN = '/^[\p{L}\p{M}\p{N}\s\-_.,!?()\'\"+=*@\/&]+$/u';
/**
* Pattern for file paths (dockerfile location, docker compose location, etc.)
* Allows alphanumeric, dots, hyphens, underscores, slashes, @, ~, and +
*/
public const FILE_PATH_PATTERN = '/^\/[a-zA-Z0-9._\-\/~@+]+$/';
/**
* Pattern for directory paths (base_directory, publish_directory, etc.)
* Like FILE_PATH_PATTERN but also allows bare "/" (root directory)
*/
public const DIRECTORY_PATH_PATTERN = '/^\/([a-zA-Z0-9._\-\/~@+]*)?$/';
/**
* Pattern for Docker build target names (multi-stage build stage names)
* Allows alphanumeric, dots, hyphens, and underscores
*/
public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Token-aware pattern for shell-safe command strings (docker compose commands, docker run options).
*
* Accepts a sequence of the following tokens only:
* [ \t]+ — whitespace (space / tab)
* && — logical AND (matched before bare & can match anything)
* || — logical OR (matched before bare | can match anything)
* "[^"$`\\\n\r]*" — balanced double-quoted string; blocks $, backtick, \, newlines inside
* '[^'\n\r]*' — balanced single-quoted string; blocks newlines inside (all else literal)
* [safe-chars]+ — unquoted alphanumerics + safe path/arg chars (includes glob *, ?, and !)
*
* Blocked everywhere (outside and inside unquoted tokens):
* bare & (background op), bare |, ;, $, `, (, ), <, >, \, newline, CR
*
* Blocked inside double-quoted spans specifically:
* $ (variable/command expansion), ` (command substitution), \ (escape)
*
* Legitimate use cases preserved:
* docker compose build && docker tag x && docker push y
* make build || make clean
* rm *.tmp cp src/?.js dist/
* ! grep -q foo && echo missing
* docker compose up -d --build-arg VERSION="1.0.0"
* --entrypoint "sh -c 'npm start'"
*/
public const SHELL_SAFE_COMMAND_PATTERN = '/^(?:[ \t]+|&&|\|\||"[^"$`\\\\\n\r]*"|\'[^\'\n\r]*\'|[a-zA-Z0-9._\-\/=:@,+\[\]{}#%^~*?!]+)+$/';
/**
* Pattern for Docker volume names
* Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores
* Matches Docker's volume naming rules
*/
public const VOLUME_NAME_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for Docker container names
* Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores
*/
public const CONTAINER_NAME_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for Docker network names
* Must start with alphanumeric, followed by alphanumeric, dots, hyphens, or underscores
* Matches Docker's network naming rules and prevents shell injection
*/
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for SQL-safe unquoted database identifiers (usernames, database names).
* Allows letters, digits, underscore; first char must be letter or underscore.
* Excludes all shell metacharacters. Max 63 chars (Postgres identifier limit).
*/
public const DB_IDENTIFIER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]{0,62}$/';
/**
* Pattern for database passwords.
* Excludes shell-dangerous characters: backtick, $, ;, |, &, <, >, \, ', ", space, newline, CR, tab, null.
* Allows a broad set of printable characters so passwords remain strong.
*/
public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/';
/**
* Get validation rules for database identifier fields (username, database name).
*
* Set $enforcePattern to false to skip the regex check (for example when
* re-validating a legacy value on an existing record that has not been
* changed by the user). The length and type rules are always applied.
*/
public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63, bool $enforcePattern = true): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "min:$minLength";
$rules[] = "max:$maxLength";
if ($enforcePattern) {
$rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN;
}
return $rules;
}
/**
* Get validation messages for database identifier fields.
*/
public static function databaseIdentifierMessages(string $field, string $label = ''): array
{
$label = $label ?: $field;
return [
"{$field}.regex" => "The {$label} may only contain letters, digits, and underscores, and must start with a letter or underscore.",
"{$field}.min" => "The {$label} must be at least :min character.",
"{$field}.max" => "The {$label} may not be greater than :max characters.",
];
}
/**
* Get validation rules for database password fields.
*
* Set $enforcePattern to false to skip the regex check (for example when
* re-validating a legacy value on an existing record that has not been
* changed by the user). The length and type rules are always applied.
*/
public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128, bool $enforcePattern = true): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "min:$minLength";
$rules[] = "max:$maxLength";
if ($enforcePattern) {
$rules[] = 'regex:'.self::DB_PASSWORD_PATTERN;
}
return $rules;
}
/**
* Get validation messages for database password fields.
*/
public static function databasePasswordMessages(string $field, string $label = ''): array
{
$label = $label ?: $field;
return [
"{$field}.regex" => "The {$label} may not contain shell-unsafe characters (backtick, \$, ;, |, &, <, >, \\, quotes, spaces, or control characters).",
"{$field}.min" => "The {$label} must be at least :min character.",
"{$field}.max" => "The {$label} may not be greater than :max characters.",
];
}
/**
* Check if a string is a valid database identifier.
*/
public static function isValidDatabaseIdentifier(string $value): bool
{
return preg_match(self::DB_IDENTIFIER_PATTERN, $value) === 1;
}
/**
* Get validation rules for name fields
*/
public static function nameRules(bool $required = true, int $minLength = 3, int $maxLength = 255): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "min:$minLength";
$rules[] = "max:$maxLength";
$rules[] = 'regex:'.self::NAME_PATTERN;
return $rules;
}
/**
* Get validation rules for description fields
*/
public static function descriptionRules(bool $required = false, int $maxLength = 255): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "max:$maxLength";
$rules[] = 'regex:'.self::DESCRIPTION_PATTERN;
return $rules;
}
/**
* Get validation messages for name fields
*/
public static function nameMessages(): array
{
return [
'name.regex' => 'The name may only contain letters (including Unicode), numbers, spaces, and these characters: - _ . / @ & ( ) # , : +',
'name.min' => 'The name must be at least :min characters.',
'name.max' => 'The name may not be greater than :max characters.',
];
}
/**
* Get validation messages for description fields
*/
public static function descriptionMessages(): array
{
return [
'description.regex' => "The description may only contain letters (including Unicode), numbers, spaces, and common punctuation: - _ . , ! ? ( ) ' \" + = * / @ &",
'description.max' => 'The description may not be greater than :max characters.',
];
}
/**
* Get validation rules for file path fields (dockerfile location, docker compose location)
*/
public static function filePathRules(int $maxLength = 255): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::FILE_PATH_PATTERN];
}
/**
* Get validation messages for file path fields
*/
public static function filePathMessages(string $field = 'dockerfileLocation', string $label = 'Dockerfile'): array
{
return [
"{$field}.regex" => "The {$label} location must be a valid path starting with / and containing only alphanumeric characters, dots, hyphens, underscores, slashes, @, ~, and +.",
];
}
/**
* Get validation rules for directory path fields (base_directory, publish_directory)
*/
public static function directoryPathRules(int $maxLength = 255): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DIRECTORY_PATH_PATTERN];
}
/**
* Get validation rules for Docker build target fields
*/
public static function dockerTargetRules(int $maxLength = 128): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::DOCKER_TARGET_PATTERN];
}
/**
* Get validation rules for shell-safe command fields
*/
public static function shellSafeCommandRules(int $maxLength = 1000): array
{
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN];
}
/**
* Get validation rules for Docker volume name fields
*/
public static function volumeNameRules(bool $required = true, int $maxLength = 255): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "max:$maxLength";
$rules[] = 'regex:'.self::VOLUME_NAME_PATTERN;
return $rules;
}
/**
* Get validation messages for volume name fields
*/
public static function volumeNameMessages(string $field = 'name'): array
{
return [
"{$field}.regex" => 'The volume name must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores.',
];
}
/**
* Pattern for port mappings with optional IP binding and protocol suffix on either side.
* Format: [ip:]port[:ip:port] where IP is IPv4 or [IPv6], port can be a range, protocol suffix optional.
* Examples: 8080:80, 127.0.0.1:8080:80, [::1]::80/udp, 127.0.0.1:8080:80/tcp
*/
public const PORT_MAPPINGS_PATTERN = '/^
(?:(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[\da-fA-F:]+\]):)? # optional IP
(?:\d+(?:-\d+)?(?:\/(?:tcp|udp|sctp))?)? # optional host port
:
(?:(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[\da-fA-F:]+\]):)? # optional IP
\d+(?:-\d+)?(?:\/(?:tcp|udp|sctp))? # container port
(?:,
(?:(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[\da-fA-F:]+\]):)?
(?:\d+(?:-\d+)?(?:\/(?:tcp|udp|sctp))?)?
:
(?:(?:\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[\da-fA-F:]+\]):)?
\d+(?:-\d+)?(?:\/(?:tcp|udp|sctp))?
)*
$/x';
/**
* Get validation rules for container name fields
*/
public static function containerNameRules(int $maxLength = 255): array
{
return ['string', 'max:'.$maxLength, 'regex:'.self::CONTAINER_NAME_PATTERN];
}
/**
* Get validation rules for port mapping fields
*/
public static function portMappingRules(): array
{
return ['nullable', 'string', 'regex:'.self::PORT_MAPPINGS_PATTERN];
}
/**
* Get validation messages for port mapping fields
*/
public static function portMappingMessages(string $field = 'portsMappings'): array
{
return [
"{$field}.regex" => 'Port mappings must be a comma-separated list of port pairs or ranges with optional IP and protocol (e.g. 3000:3000, 8080:80/udp, 127.0.0.1:8080:80, [::1]::80).',
];
}
/**
* Check if a string is a valid Docker container name.
*/
public static function isValidContainerName(string $name): bool
{
return preg_match(self::CONTAINER_NAME_PATTERN, $name) === 1;
}
/**
* Get validation rules for Docker network name fields
*/
public static function dockerNetworkRules(bool $required = true, int $maxLength = 255): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "max:$maxLength";
$rules[] = 'regex:'.self::DOCKER_NETWORK_PATTERN;
return $rules;
}
/**
* Get validation messages for Docker network name fields
*/
public static function dockerNetworkMessages(string $field = 'network'): array
{
return [
"{$field}.regex" => 'The network name must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores.',
];
}
/**
* Check if a string is a valid Docker network name.
*/
public static function isValidDockerNetwork(string $name): bool
{
return preg_match(self::DOCKER_NETWORK_PATTERN, $name) === 1;
}
/**
* Get combined validation messages for both name and description fields
*/
public static function combinedMessages(): array
{
return array_merge(self::nameMessages(), self::descriptionMessages());
}
}