Merge remote-tracking branch 'origin/next' into 9249-pr-investigation

This commit is contained in:
Andras Bacsai committed 2026-07-07 14:45:48 +02:00
commit 95a3c453d8
1200 files changed
+75614 -19321

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
<?php
namespace App\Support;
use Illuminate\Http\UploadedFile;
class DatabaseBackupFileValidator
{
public const ALLOWED_EXTENSIONS = [
'sql',
'sql.gz',
'gz',
'zip',
'tar',
'tar.gz',
'tgz',
'dump',
'bak',
'bson',
'bson.gz',
'archive',
'archive.gz',
'bz2',
'xz',
'dmp',
];
private const DANGEROUS_EXTENSIONS = [
'asp',
'aspx',
'bat',
'bash',
'cgi',
'cmd',
'com',
'exe',
'htm',
'html',
'jar',
'js',
'jsp',
'php',
'php3',
'php4',
'php5',
'phtml',
'pl',
'ps1',
'py',
'rb',
'sh',
];
public static function hasAllowedExtension(string $name): bool
{
return self::extensionFor($name) !== null;
}
public static function isUploadAllowed(UploadedFile $file, int $maxBytes): bool
{
$originalName = $file->getClientOriginalName();
$size = $file->getSize();
if ($size === false || $size > $maxBytes) {
return false;
}
$extension = self::extensionFor($originalName);
if ($extension === null) {
return false;
}
return self::contentMatchesExtension($file->getPathname(), $extension);
}
/**
* Scan a stored backup file (decompressing gzip on the fly) for PostgreSQL
* restore directives that lead to OS command execution.
*/
public static function fileContainsPostgresqlProgramExecution(string $path): bool
{
$contents = self::readPossiblyGzippedText($path);
if ($contents === null) {
return false;
}
return self::containsPostgresqlProgramExecution($contents);
}
public static function containsPostgresqlProgramExecution(string $sql): bool
{
$withoutComments = self::stripSqlComments($sql);
if (preg_match('/^\s*\\\\(?:!|copy\b.*\bprogram\b)/mi', $withoutComments) === 1) {
return true;
}
return preg_match('/\bcopy\b[\s\S]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
}
private static function extensionFor(string $name): ?string
{
$lower = strtolower($name);
$suffixes = array_map(fn (string $ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
usort($suffixes, fn (string $a, string $b) => strlen($b) <=> strlen($a));
foreach ($suffixes as $suffix) {
if (! str_ends_with($lower, $suffix)) {
continue;
}
$stem = substr($lower, 0, -strlen($suffix));
if ($stem === '' || str_ends_with($stem, '.')) {
return null;
}
$parts = array_filter(explode('.', $stem));
if (array_intersect($parts, self::DANGEROUS_EXTENSIONS) !== []) {
return null;
}
return ltrim($suffix, '.');
}
return null;
}
private static function contentMatchesExtension(string $path, string $extension): bool
{
$sample = (string) file_get_contents($path, false, null, 0, 4096);
return match ($extension) {
'sql' => self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample),
'sql.gz', 'gz', 'tar.gz', 'tgz', 'bson.gz', 'archive.gz' => str_starts_with($sample, "\x1f\x8b"),
'zip' => str_starts_with($sample, "PK\x03\x04") || str_starts_with($sample, "PK\x05\x06") || str_starts_with($sample, "PK\x07\x08"),
'tar' => substr($sample, 257, 5) === 'ustar',
'bz2' => str_starts_with($sample, 'BZh'),
'xz' => str_starts_with($sample, "\xfd7zXZ\x00"),
'dump', 'bak', 'archive', 'dmp' => str_starts_with($sample, 'PGDMP')
|| (self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample)),
'bson' => self::looksLikeBson($path, $sample),
default => false,
};
}
private static function readPossiblyGzippedText(string $path): ?string
{
// Cap the scan so a huge legitimate dump cannot exhaust memory; the
// remote pre-restore scanner inspects the full file as a second layer.
$maxBytes = 50 * 1024 * 1024;
$handle = @fopen($path, 'rb');
if ($handle === false) {
return null;
}
$magic = (string) fread($handle, 2);
fclose($handle);
if ($magic === "\x1f\x8b") {
$gz = @gzopen($path, 'rb');
if ($gz === false) {
return null;
}
$data = '';
while (! gzeof($gz) && strlen($data) < $maxBytes) {
$chunk = gzread($gz, 1024 * 1024);
if ($chunk === false || $chunk === '') {
break;
}
$data .= $chunk;
}
gzclose($gz);
return $data;
}
return (string) file_get_contents($path, false, null, 0, $maxBytes);
}
private static function looksLikeText(string $sample): bool
{
if ($sample === '' || str_contains($sample, "\0")) {
return false;
}
return mb_check_encoding($sample, 'UTF-8') || mb_check_encoding($sample, 'ASCII');
}
private static function looksLikeBson(string $path, string $sample): bool
{
if (strlen($sample) < 5) {
return false;
}
$documentLength = unpack('V', substr($sample, 0, 4))[1] ?? 0;
$fileSize = filesize($path) ?: 0;
return $documentLength >= 5 && $documentLength <= $fileSize;
}
private static function stripSqlComments(string $sql): string
{
$sql = preg_replace('/\/\*[\s\S]*?\*\//', ' ', $sql) ?? $sql;
return preg_replace('/--[^\r\n]*/', ' ', $sql) ?? $sql;
}
}
+521 -11
View File
@@ -36,14 +36,42 @@ class ValidationPatterns
public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for shell-safe command strings (docker compose commands, docker run options)
* Blocks dangerous shell metacharacters: ; | ` $ ( ) > < newlines and carriage returns
* Allows & for command chaining (&&) which is common in multi-step build commands
* Allows double quotes for build args with spaces (e.g. --build-arg KEY="value")
* Blocks backslashes and single quotes to prevent escape-sequence attacks
* Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators)
* Pattern for SSH usernames.
* Allows alphanumeric characters, dots, hyphens, and underscores.
*/
public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~&"]+$/';
public const SERVER_USERNAME_PATTERN = '/^[a-zA-Z0-9._-]+$/';
/**
* Pattern for removing characters not allowed in SSH usernames.
*/
public const INVALID_SERVER_USERNAME_CHARACTERS_PATTERN = '/[^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
@@ -65,6 +93,324 @@ class ValidationPatterns
*/
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for S3 bucket names.
*
* Bucket names must be 3-63 lowercase characters, start and end with a
* letter or digit, and contain only lowercase letters, digits, dots, and
* hyphens. Additional semantic checks live in isValidS3BucketName().
*/
public const S3_BUCKET_NAME_PATTERN = '/\A(?=.{3,63}\z)[a-z0-9][a-z0-9.-]*[a-z0-9]\z/';
/**
* Pattern for Docker-compatible environment variable keys.
* Environment variable keys are later interpolated into shell commands as Docker build args, so only shell-safe identifier characters are allowed.
*/
public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u';
/**
* Characters that are valid in some URL positions but unsafe for values
* that are later reused in shell assignment contexts.
*/
public const APPLICATION_DOMAIN_FORBIDDEN_PATTERN = '/[`$;&|<>()\\\\\r\n]/';
/**
* 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!@#%^*()_+\-=\[\]{}:,.?\/~]+$/';
/**
* Pattern for Docker image repository names without a tag.
*
* Allows an optional registry host/port followed by lowercase repository
* path components. A trailing @sha256 marker is accepted for existing
* digest-based dockerimage records that store the digest hash separately.
*/
public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/';
/**
* Pattern for Docker image tags.
*
* Docker tags may contain letters, digits, underscores, dots, and hyphens,
* must start with an alphanumeric/underscore, and are limited to 128 chars.
*/
public const DOCKER_IMAGE_TAG_PATTERN = '/\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\z/';
/**
* Normalize environment variable keys before validation and storage.
*/
public static function normalizeEnvironmentVariableKey(string $value): string
{
return str($value)->trim()->value;
}
/**
* Get validation rules for environment variable keys.
*/
public static function environmentVariableKeyRules(bool $required = true, int $maxLength = 255): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "max:$maxLength";
$rules[] = 'regex:'.self::ENVIRONMENT_VARIABLE_KEY_PATTERN;
return $rules;
}
/**
* Get validation messages for environment variable key fields.
*/
public static function environmentVariableKeyMessages(string $field = 'key', string $label = 'key'): array
{
return [
"{$field}.regex" => "The {$label} must start with a letter or underscore and may only contain letters, numbers, underscores, and dots.",
"{$field}.max" => "The {$label} may not be greater than :max characters.",
];
}
/**
* Check if a string is a valid environment variable key.
*/
public static function isValidEnvironmentVariableKey(string $value): bool
{
return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1;
}
/**
* Check if a string is a valid S3 bucket name.
*/
public static function isValidS3BucketName(string $value): bool
{
if (preg_match(self::S3_BUCKET_NAME_PATTERN, $value) !== 1) {
return false;
}
if (str_contains($value, '..') || str_contains($value, '.-') || str_contains($value, '-.')) {
return false;
}
return filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false;
}
/**
* Normalize and validate an environment variable key.
*/
public static function validatedEnvironmentVariableKey(string $value, string $label = 'key'): string
{
$key = self::normalizeEnvironmentVariableKey($value);
if (! self::isValidEnvironmentVariableKey($key)) {
throw new \InvalidArgumentException(self::environmentVariableKeyMessages(label: $label)['key.regex']);
}
return $key;
}
/**
* Get validation rules for Docker image repository names without tags.
*/
public static function dockerImageNameRules(bool $required = false, int $maxLength = 255): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "max:$maxLength";
$rules[] = 'regex:'.self::DOCKER_IMAGE_NAME_PATTERN;
return $rules;
}
/**
* Get validation rules for Docker image tags.
*/
public static function dockerImageTagRules(bool $required = false, int $maxLength = 128): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "max:$maxLength";
$rules[] = 'regex:'.self::DOCKER_IMAGE_TAG_PATTERN;
return $rules;
}
/**
* Get validation messages for Docker image fields.
*/
public static function dockerImageMessages(string $nameField = 'docker_registry_image_name', string $tagField = 'docker_registry_image_tag'): array
{
return [
"{$nameField}.regex" => 'The Docker registry image name must be a valid image repository without a tag and may not contain shell metacharacters.',
"{$tagField}.regex" => 'The Docker registry image tag must be a valid Docker tag and may not contain shell metacharacters.',
];
}
/**
* Check if a string is a valid Docker image repository name without a tag.
*/
public static function isValidDockerImageName(?string $value): bool
{
if (blank($value)) {
return true;
}
return preg_match(self::DOCKER_IMAGE_NAME_PATTERN, $value) === 1;
}
/**
* Check if a string is a valid Docker image tag.
*/
public static function isValidDockerImageTag(?string $value): bool
{
if (blank($value)) {
return true;
}
return preg_match(self::DOCKER_IMAGE_TAG_PATTERN, $value) === 1;
}
/**
* 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 rules for SSH username fields.
*/
public static function serverUsernameRules(bool $required = true): array
{
return [
$required ? 'required' : 'nullable',
'string',
'regex:'.self::SERVER_USERNAME_PATTERN,
];
}
/**
* Get validation messages for SSH username fields.
*/
public static function serverUsernameMessages(string $field = 'user', string $label = 'User'): array
{
return [
"{$field}.regex" => "The {$label} may only contain letters, numbers, dots, hyphens, and underscores.",
];
}
/**
* 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
*/
@@ -171,6 +517,156 @@ class ValidationPatterns
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN];
}
/**
* Get validation rules for comma-separated application URL fields.
*/
public static function applicationDomainRules(int $maxLength = 2048): array
{
return [
'nullable',
'string',
'max:'.$maxLength,
function (string $attribute, mixed $value, \Closure $fail): void {
foreach (self::validateApplicationDomains($value) as $error) {
$fail($error);
}
},
];
}
/**
* Validate a comma-separated list of application URLs.
*
* @return array<int, string>
*/
public static function validateApplicationDomains(mixed $value): array
{
if (blank($value)) {
return [];
}
if (! is_string($value)) {
return ['The domains field must be a string.'];
}
$errors = [];
foreach (self::applicationDomainList($value) as $url) {
if (preg_match(self::APPLICATION_DOMAIN_FORBIDDEN_PATTERN, $url) === 1) {
$errors[] = "Invalid URL: {$url}";
continue;
}
if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}";
continue;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'], true)) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
continue;
}
if (blank(parse_url($url, PHP_URL_HOST))) {
$errors[] = "Invalid URL: {$url}";
}
}
return $errors;
}
/**
* Normalize a comma-separated application URL list for storage.
*/
public static function normalizeApplicationDomains(?string $value): ?string
{
$urls = self::applicationDomainList($value);
if ($urls === []) {
return null;
}
return collect($urls)
->map(fn (string $url) => self::normalizeApplicationDomainUrl($url))
->implode(',');
}
/**
* Normalize URL components that are case-insensitive while preserving
* case-sensitive path, query, and fragment components.
*/
private static function normalizeApplicationDomainUrl(string $url): string
{
$components = parse_url($url);
if ($components === false) {
return $url;
}
$normalized = '';
if (isset($components['scheme'])) {
$normalized .= strtolower($components['scheme']).'://';
}
if (isset($components['user'])) {
$normalized .= $components['user'];
if (isset($components['pass'])) {
$normalized .= ':'.$components['pass'];
}
$normalized .= '@';
}
if (isset($components['host'])) {
$normalized .= strtolower($components['host']);
}
if (isset($components['port'])) {
$normalized .= ':'.$components['port'];
}
if (isset($components['path'])) {
$normalized .= $components['path'];
}
if (array_key_exists('query', $components)) {
$normalized .= '?'.$components['query'];
}
if (array_key_exists('fragment', $components)) {
$normalized .= '#'.$components['fragment'];
}
return $normalized;
}
/**
* Split a comma-separated application URL list into trimmed URL strings.
*
* @return array<int, string>
*/
public static function applicationDomainList(?string $value): array
{
if (blank($value)) {
return [];
}
return str($value)
->replaceStart(',', '')
->replaceEnd(',', '')
->trim()
->explode(',')
->map(fn (string $url) => trim($url))
->filter(fn (string $url) => filled($url))
->values()
->all();
}
/**
* Get validation rules for Docker volume name fields
*/
@@ -202,10 +698,24 @@ class ValidationPatterns
}
/**
* Pattern for port mappings (e.g. 3000:3000, 8080:80, 8000-8010:8000-8010)
* Each entry requires host:container format, where each side can be a number or a range (number-number)
* 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+(-\d+)?:\d+(-\d+)?)(,\d+(-\d+)?:\d+(-\d+)?)*$/';
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
@@ -229,7 +739,7 @@ class ValidationPatterns
public static function portMappingMessages(string $field = 'portsMappings'): array
{
return [
"{$field}.regex" => 'Port mappings must be a comma-separated list of port pairs or ranges (e.g. 3000:3000,8080:80,8000-8010:8000-8010).',
"{$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).',
];
}