Improve application URL handling (#10836)

This commit is contained in:
Andras Bacsai 2026-07-02 18:41:13 +02:00 committed by GitHub
commit d32d7cf3d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 422 additions and 117 deletions

View file

@ -952,6 +952,10 @@ private function create_application(Request $request, $type)
} }
$serverUuid = $request->server_uuid; $serverUuid = $request->server_uuid;
$fqdn = $request->domains; $fqdn = $request->domains;
if ($request->has('domains') && is_string($request->domains)) {
$fqdn = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $fqdn);
}
$autogenerateDomain = $request->boolean('autogenerate_domain', true); $autogenerateDomain = $request->boolean('autogenerate_domain', true);
$instantDeploy = $request->instant_deploy; $instantDeploy = $request->instant_deploy;
$githubAppUuid = $request->github_app_uuid; $githubAppUuid = $request->github_app_uuid;
@ -1031,7 +1035,7 @@ private function create_application(Request $request, $type)
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
]; ];
// ports_exposes is not required for dockercompose // ports_exposes is not required for dockercompose
if ($request->build_pack === 'dockercompose') { if ($request->build_pack === 'dockercompose') {
@ -1239,7 +1243,7 @@ private function create_application(Request $request, $type)
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
]; ];
$validationRules = array_merge(sharedDataApplications(), $validationRules); $validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [ $validationMessages = [
@ -1479,7 +1483,7 @@ private function create_application(Request $request, $type)
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
]; ];
$validationRules = array_merge(sharedDataApplications(), $validationRules); $validationRules = array_merge(sharedDataApplications(), $validationRules);
@ -2378,7 +2382,7 @@ public function update_by_uuid(Request $request)
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'custom_nginx_configuration' => 'string|nullable', 'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable', 'is_http_basic_auth_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string', 'http_basic_auth_username' => 'string',
@ -2482,29 +2486,7 @@ public function update_by_uuid(Request $request)
$requestHasDomains = $request->has('domains'); $requestHasDomains = $request->has('domains');
if ($requestHasDomains && $server->isProxyShouldRun()) { if ($requestHasDomains && $server->isProxyShouldRun()) {
$uuid = $request->uuid; $uuid = $request->uuid;
$urls = $request->domains; $errors = ValidationPatterns::validateApplicationDomains($request->domains);
$urls = str($urls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
$errors = [];
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
if (count($errors) > 0) { if (count($errors) > 0) {
return response()->json([ return response()->json([
@ -2512,6 +2494,9 @@ public function update_by_uuid(Request $request)
'errors' => $errors, 'errors' => $errors,
], 422); ], 422);
} }
$domains = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $domains);
$urls = collect(ValidationPatterns::applicationDomainList($domains));
// Check for domain conflicts // Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid); $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) { if (isset($result['error'])) {
@ -3871,36 +3856,16 @@ private function validateDataApplications(Request $request, Server $server)
} }
if ($request->has('domains') && $server->isProxyShouldRun()) { if ($request->has('domains') && $server->isProxyShouldRun()) {
$uuid = $request->uuid; $uuid = $request->uuid;
$urls = $request->domains; $errors = ValidationPatterns::validateApplicationDomains($request->domains);
$urls = str($urls)->replaceEnd(',', '')->trim();
$urls = str($urls)->replaceStart(',', '')->trim();
$errors = [];
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return str($url)->lower();
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
if (count($errors) > 0) { if (count($errors) > 0) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
'errors' => $errors, 'errors' => $errors,
], 422); ], 422);
} }
$normalizedDomains = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $normalizedDomains);
$urls = collect(ValidationPatterns::applicationDomainList($normalizedDomains));
// Check for domain conflicts // Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid); $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) { if (isset($result['error'])) {

View file

@ -60,19 +60,10 @@ private function applyServiceUrls(Service $service, array $urlsArray, string $te
return str($urlValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter(); return str($urlValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter();
}); });
$urls = $urls->map(function ($url) use (&$errors) { $errors = ValidationPatterns::validateApplicationDomains($urls->implode(','));
if (! filter_var($url, FILTER_VALIDATE_URL)) { $urls = collect(ValidationPatterns::applicationDomainList(
$errors[] = "Invalid URL: {$url}"; ValidationPatterns::normalizeApplicationDomains($urls->implode(','))
));
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return $url;
});
$duplicates = $urls->duplicates()->unique()->values(); $duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
@ -101,10 +92,10 @@ private function applyServiceUrls(Service $service, array $urlsArray, string $te
} }
if (filled($containerUrls)) { if (filled($containerUrls)) {
$containerUrls = str($containerUrls)->replaceStart(',', '')->replaceEnd(',', '')->trim(); $containerUrls = ValidationPatterns::normalizeApplicationDomains($containerUrls);
$containerUrls = str($containerUrls)->explode(',')->map(fn ($url) => str(trim($url))->lower()); $containerUrlCollection = collect(ValidationPatterns::applicationDomainList($containerUrls));
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $application->uuid); $result = checkIfDomainIsAlreadyUsedViaAPI($containerUrlCollection, $teamId, $application->uuid);
if (isset($result['error'])) { if (isset($result['error'])) {
$errors[] = $result['error']; $errors[] = $result['error'];
@ -116,8 +107,6 @@ private function applyServiceUrls(Service $service, array $urlsArray, string $te
return; return;
} }
$containerUrls = $containerUrls->filter(fn ($u) => filled($u))->unique()->implode(',');
} else { } else {
$containerUrls = null; $containerUrls = null;
} }

View file

@ -2229,7 +2229,7 @@ private function set_coolify_variables()
// Only include SOURCE_COMMIT in build context if enabled in settings // Only include SOURCE_COMMIT in build context if enabled in settings
if ($this->application->settings->include_source_commit_in_build) { if ($this->application->settings->include_source_commit_in_build) {
$this->coolify_variables .= "SOURCE_COMMIT={$this->commit} "; $this->coolify_variables .= 'SOURCE_COMMIT='.escapeShellValue($this->commit).' ';
} }
if ($this->pull_request_id === 0) { if ($this->pull_request_id === 0) {
$fqdn = $this->application->fqdn; $fqdn = $this->application->fqdn;
@ -2241,17 +2241,33 @@ private function set_coolify_variables()
$fqdn = $url->getHost(); $fqdn = $url->getHost();
$url = $url->withHost($fqdn)->withPort(null)->__toString(); $url = $url->withHost($fqdn)->withPort(null)->__toString();
if ((int) $this->application->compose_parsing_version >= 3) { if ((int) $this->application->compose_parsing_version >= 3) {
$this->coolify_variables .= "COOLIFY_URL={$url} "; $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
$this->coolify_variables .= "COOLIFY_FQDN={$fqdn} "; $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
} else { } else {
$this->coolify_variables .= "COOLIFY_URL={$fqdn} "; $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
$this->coolify_variables .= "COOLIFY_FQDN={$url} "; $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
} }
} }
if (isset($this->application->git_branch)) { if (isset($this->application->git_branch)) {
$this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' '; $this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
} }
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} "; $this->coolify_variables .= 'COOLIFY_RESOURCE_UUID='.escapeShellValue($this->application->uuid).' ';
}
private function shellAssignmentForDockerfileArg(string $assignment): string
{
[$key, $value] = array_pad(explode('=', $assignment, 2), 2, null);
if ($value === null) {
return $assignment;
}
if (str_starts_with($value, "'") && str_ends_with($value, "'")) {
$value = substr($value, 1, -1);
$value = str_replace("'\\''", "'", $value);
}
return "{$key}={$value}";
} }
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
@ -4227,7 +4243,7 @@ private function add_build_env_variables_to_dockerfile()
$coolify_vars = collect(explode(' ', trim($this->coolify_variables))) $coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter() ->filter()
->map(function ($var) { ->map(function ($var) {
return "ARG {$var}"; return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
}); });
$argsToInsert = $argsToInsert->merge($coolify_vars); $argsToInsert = $argsToInsert->merge($coolify_vars);
} }
@ -4249,7 +4265,7 @@ private function add_build_env_variables_to_dockerfile()
$coolify_vars = collect(explode(' ', trim($this->coolify_variables))) $coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter() ->filter()
->map(function ($var) { ->map(function ($var) {
return "ARG {$var}"; return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
}); });
$argsToInsert = $argsToInsert->merge($coolify_vars); $argsToInsert = $argsToInsert->merge($coolify_vars);
} }

View file

@ -12,7 +12,6 @@
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
use Livewire\Features\SupportEvents\Event; use Livewire\Features\SupportEvents\Event;
use Spatie\Url\Url;
class General extends Component class General extends Component
{ {
@ -142,7 +141,8 @@ protected function rules(): array
return [ return [
'name' => ValidationPatterns::nameRules(), 'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable', 'fqdn' => ValidationPatterns::applicationDomainRules(),
'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(),
'gitRepository' => 'required', 'gitRepository' => 'required',
'gitBranch' => ['required', 'string', new ValidGitBranch], 'gitBranch' => ['required', 'string', new ValidGitBranch],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
@ -771,16 +771,7 @@ public function submit($showToaster = true)
$oldBaseDirectory = $this->application->base_directory; $oldBaseDirectory = $this->application->base_directory;
// Process FQDN with intermediate variable to avoid Collection/string confusion // Process FQDN with intermediate variable to avoid Collection/string confusion
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn); $warning = sslipDomainWarning($this->fqdn);
if ($warning) { if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain')); $this->dispatch('warning', __('warning.sslipdomain'));
@ -863,6 +854,9 @@ public function submit($showToaster = true)
} }
} }
if ($this->buildPack === 'dockercompose') { if ($this->buildPack === 'dockercompose') {
foreach ($this->parsedServiceDomains as $serviceName => $service) {
$this->parsedServiceDomains[$serviceName]['domain'] = ValidationPatterns::normalizeApplicationDomains(data_get($service, 'domain'));
}
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) { if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) { foreach ($this->parsedServiceDomains as $service) {

View file

@ -6,6 +6,7 @@
use App\Jobs\DeleteResourceJob; use App\Jobs\DeleteResourceJob;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
@ -117,12 +118,14 @@ public function save_preview($preview_id)
}); });
if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) { if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) {
$this->validate([
"previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(),
]);
$fqdn = $this->previewFqdns[$previewKey]; $fqdn = $this->previewFqdns[$previewKey];
if (! empty($fqdn)) { if (! empty($fqdn)) {
$fqdn = str($fqdn)->replaceEnd(',', '')->trim(); $fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn);
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
$fqdn = str($fqdn)->trim()->lower();
$this->previewFqdns[$previewKey] = $fqdn; $this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) { if (! validateDNSEntry($fqdn, $this->application->destination->server)) {

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application; namespace App\Livewire\Project\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url; use Spatie\Url\Url;
@ -33,6 +34,11 @@ public function save()
{ {
try { try {
$this->authorize('update', $this->preview->application); $this->authorize('update', $this->preview->application);
$this->validate([
'domain' => ValidationPatterns::applicationDomainRules(),
]);
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); $docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: []; $docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
@ -73,9 +79,13 @@ public function generate()
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
} else { } else {
foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) {
throw new \InvalidArgumentException($error);
}
// Use the existing domain from the main application // Use the existing domain from the main application
// Handle multiple domains separated by commas // Handle multiple domains separated by commas
$domain_list = explode(',', $domain_string); $domain_list = ValidationPatterns::applicationDomainList($domain_string);
$preview_fqdns = []; $preview_fqdns = [];
$template = $this->preview->application->preview_url_template; $template = $this->preview->application->preview_url_template;
$random = new_public_id(); $random = new_public_id();

View file

@ -3,10 +3,10 @@
namespace App\Livewire\Project\Service; namespace App\Livewire\Project\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url;
class EditDomain extends Component class EditDomain extends Component
{ {
@ -28,12 +28,15 @@ class EditDomain extends Component
public $requiredPort = null; public $requiredPort = null;
#[Validate(['nullable'])] #[Validate]
public ?string $fqdn = null; public ?string $fqdn = null;
protected $rules = [ protected function rules(): array
'fqdn' => 'nullable', {
]; return [
'fqdn' => ValidationPatterns::applicationDomainRules(),
];
}
public function mount() public function mount()
{ {
@ -82,15 +85,9 @@ public function submit()
{ {
try { try {
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); $this->validate();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower(); $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn); $warning = sslipDomainWarning($this->fqdn);
if ($warning) { if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain')); $this->dispatch('warning', __('warning.sslipdomain'));

View file

@ -8,11 +8,11 @@
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url;
class Index extends Component class Index extends Component
{ {
@ -480,15 +480,11 @@ public function submitApplication()
{ {
try { try {
$this->authorize('update', $this->serviceApplication); $this->authorize('update', $this->serviceApplication);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); $this->validate([
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); 'fqdn' => ValidationPatterns::applicationDomainRules(),
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { ]);
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower(); $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn); $warning = sslipDomainWarning($this->fqdn);
if ($warning) { if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain')); $this->dispatch('warning', __('warning.sslipdomain'));

View file

@ -108,6 +108,12 @@ class ValidationPatterns
*/ */
public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u'; 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). * Pattern for SQL-safe unquoted database identifiers (usernames, database names).
* Allows letters, digits, underscore; first char must be letter or underscore. * Allows letters, digits, underscore; first char must be letter or underscore.
@ -511,6 +517,156 @@ public static function shellSafeCommandRules(int $maxLength = 1000): array
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN]; 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 (! filter_var($url, FILTER_VALIDATE_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 * Get validation rules for Docker volume name fields
*/ */

View file

@ -98,7 +98,7 @@ function sharedDataApplications()
'is_auto_deploy_enabled' => 'boolean', 'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean', 'is_force_https_enabled' => 'boolean',
'static_image' => Rule::enum(StaticImageTypes::class), 'static_image' => Rule::enum(StaticImageTypes::class),
'domains' => 'string|nullable', 'domains' => ValidationPatterns::applicationDomainRules(),
'redirect' => Rule::enum(RedirectTypes::class), 'redirect' => Rule::enum(RedirectTypes::class),
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(), 'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(),

View file

@ -2,6 +2,40 @@
use App\Livewire\Project\Application\General; use App\Livewire\Project\Application\General;
it('uses safe domain validation rules in the application general form', function () {
$component = new General;
$method = new ReflectionMethod($component, 'rules');
$rules = $method->invoke($component);
$validator = validator([
'fqdn' => 'http://$(whoami).example.com',
], [
'fqdn' => $rules['fqdn'],
]);
expect($validator->fails())->toBeTrue()
->and($validator->errors()->has('fqdn'))->toBeTrue();
});
it('uses safe docker compose service domain validation rules in the application general form', function () {
$component = new General;
$method = new ReflectionMethod($component, 'rules');
$rules = $method->invoke($component);
$validator = validator([
'parsedServiceDomains' => [
'app' => [
'domain' => 'http://$(whoami).example.com',
],
],
], [
'parsedServiceDomains.*.domain' => $rules['parsedServiceDomains.*.domain'],
]);
expect($validator->fails())->toBeTrue()
->and($validator->errors()->has('parsedServiceDomains.app.domain'))->toBeTrue();
});
it('uses safe docker registry image validation rules in the application general form', function () { it('uses safe docker registry image validation rules in the application general form', function () {
$component = new General; $component = new General;
$method = new ReflectionMethod($component, 'rules'); $method = new ReflectionMethod($component, 'rules');

View file

@ -130,6 +130,69 @@
}); });
describe('API validation rules for path fields', function () { describe('API validation rules for path fields', function () {
test('domains validation rejects command injection payloads', function (string $payload) {
$rules = sharedDataApplications();
$validator = validator(
['domains' => $payload],
['domains' => $rules['domains']]
);
expect($validator->fails())->toBeTrue();
})->with([
'host command substitution' => 'http://$(whoami).example.com',
'path command substitution' => 'http://example.com/$(whoami)',
'query command substitution' => 'http://example.com/path?next=$(id)',
'host backtick substitution' => 'http://`whoami`.example.com',
'path backtick substitution' => 'http://example.com/`whoami`',
'semicolon command separator' => 'http://example.com/path;id',
'newline injection' => "http://example.com\nwhoami",
'carriage return injection' => "http://example.com\rwhoami",
'pipe injection' => 'http://example.com/path|id',
]);
test('domains validation rejects non http schemes', function () {
$rules = sharedDataApplications();
$validator = validator(
['domains' => 'ftp://example.com'],
['domains' => $rules['domains']]
);
expect($validator->fails())->toBeTrue();
});
test('domains validation allows comma separated http and https urls', function () {
$rules = sharedDataApplications();
$validator = validator(
['domains' => 'https://app.example.com,http://api.example.com/path'],
['domains' => $rules['domains']]
);
expect($validator->fails())->toBeFalse();
});
test('docker compose service domains validation rejects command injection payloads', function () {
$rules = [
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
$validator = validator(
[
'docker_compose_domains' => [
['name' => 'app', 'domain' => 'https://app.example.com/$(whoami)'],
],
],
$rules
);
expect($validator->fails())->toBeTrue();
});
test('git_branch validation rejects shell metacharacters', function (string $branch) { test('git_branch validation rejects shell metacharacters', function (string $branch) {
$rules = sharedDataApplications(); $rules = sharedDataApplications();
@ -276,7 +339,45 @@
expect($coolifyVariables->getValue($instance)) expect($coolifyVariables->getValue($instance))
->toContain("COOLIFY_BRANCH='main`id`' ") ->toContain("COOLIFY_BRANCH='main`id`' ")
->toContain('COOLIFY_RESOURCE_UUID=app-uuid '); ->toContain("COOLIFY_RESOURCE_UUID='app-uuid' ");
});
test('coolify url and fqdn shell assignments are quoted', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$instance = $job->newInstanceWithoutConstructor();
$application = new Application;
$application->uuid = 'app-uuid';
$application->git_branch = 'main';
$application->fqdn = 'https://app.example.com/path';
$application->compose_parsing_version = '3';
$settings = new ApplicationSetting;
$settings->include_source_commit_in_build = true;
$application->setRelation('settings', $settings);
foreach ([
'application' => $application,
'commit' => 'HEAD$(id)',
'pull_request_id' => 0,
] as $property => $value) {
$reflectionProperty = $job->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($instance, $value);
}
$method = $job->getMethod('set_coolify_variables');
$method->setAccessible(true);
$method->invoke($instance);
$coolifyVariables = $job->getProperty('coolify_variables');
$coolifyVariables->setAccessible(true);
expect($coolifyVariables->getValue($instance))
->toContain("SOURCE_COMMIT='HEAD$(id)' ")
->toContain("COOLIFY_URL='https://app.example.com/path' ")
->toContain("COOLIFY_FQDN='app.example.com' ")
->toContain("COOLIFY_RESOURCE_UUID='app-uuid' ");
}); });
}); });

View file

@ -0,0 +1,37 @@
<?php
use App\Http\Controllers\Api\ServicesController;
use App\Models\Service;
it('does not treat service URLs with different path casing as duplicates', function () {
$controller = new ServicesController;
$method = new ReflectionMethod($controller, 'applyServiceUrls');
$service = new class extends Service
{
public function applications()
{
return new class
{
public function where(string $column, mixed $value): self
{
return $this;
}
public function first(): null
{
return null;
}
};
}
};
$result = $method->invoke($controller, $service, [
['name' => 'web', 'url' => 'https://example.com/Route'],
['name' => 'api', 'url' => 'HTTPS://EXAMPLE.COM/route'],
], '1');
expect($result['errors'] ?? [])->toBe([
"Service container with 'web' not found.",
"Service container with 'api' not found.",
]);
});

View file

@ -180,3 +180,10 @@
expect($environmentVariable->key)->toBe('APP_ENV'); expect($environmentVariable->key)->toBe('APP_ENV');
}); });
it('normalizes application domain scheme and host without lowercasing path query or fragment', function () {
$domains = ' HTTPS://EXAMPLE.COM/MixedCase/Path?Token=ABC#Fragment, http://Sub.EXAMPLE.com/Api/V1 ';
expect(ValidationPatterns::normalizeApplicationDomains($domains))
->toBe('https://example.com/MixedCase/Path?Token=ABC#Fragment,http://sub.example.com/Api/V1');
});