mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-17 00:14:43 +00:00
Merge remote-tracking branch 'origin/next' into fix/url-validator-underscore-hostnames
This commit is contained in:
commit
b50839d451
505 files changed
+21391
-5998
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;
|
||||
}
|
||||
}
|
||||
@@ -94,10 +94,25 @@ class ValidationPatterns
|
||||
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
|
||||
|
||||
/**
|
||||
* Pattern for Docker-compatible environment variable keys.
|
||||
* Docker environment entries are KEY=value strings, so keys must be non-empty and cannot contain '=' or NUL.
|
||||
* 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 ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[^=\x00]+\z/u';
|
||||
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).
|
||||
@@ -164,7 +179,7 @@ class ValidationPatterns
|
||||
public static function environmentVariableKeyMessages(string $field = 'key', string $label = 'key'): array
|
||||
{
|
||||
return [
|
||||
"{$field}.regex" => "The {$label} must be a non-empty Docker-compatible environment variable key and cannot contain '=' or NUL characters.",
|
||||
"{$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.",
|
||||
];
|
||||
}
|
||||
@@ -177,6 +192,22 @@ class ValidationPatterns
|
||||
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.
|
||||
*/
|
||||
@@ -486,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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user