mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 22:15:35 +00:00
Merge remote-tracking branch 'origin/next' into 9249-pr-investigation
This commit is contained in:
commit
95a3c453d8
1200 files changed
+75614
-19321
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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).',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user