diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index 0a20deae5..22c48aa89 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -30,7 +30,8 @@ public function handle(Server $server) ]); ['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection(); if (! $this->uptime) { - $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error.'
'; + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$sanitizedError.'
'; $server->update([ 'validation_logs' => $this->error, ]); diff --git a/app/Actions/Stripe/UpdateSubscriptionQuantity.php b/app/Actions/Stripe/UpdateSubscriptionQuantity.php index a3eab4dca..d4d29af20 100644 --- a/app/Actions/Stripe/UpdateSubscriptionQuantity.php +++ b/app/Actions/Stripe/UpdateSubscriptionQuantity.php @@ -4,6 +4,7 @@ use App\Jobs\ServerLimitCheckJob; use App\Models\Team; +use Stripe\Exception\InvalidRequestException; use Stripe\StripeClient; class UpdateSubscriptionQuantity @@ -42,6 +43,7 @@ public function fetchPricePreview(Team $team, int $quantity): array } $currency = strtoupper($item->price->currency ?? 'usd'); + $billingInterval = $item->price->recurring->interval ?? 'month'; // Upcoming invoice gives us the prorated amount due now $upcomingInvoice = $this->stripe->invoices->upcoming([ @@ -99,6 +101,7 @@ public function fetchPricePreview(Team $team, int $quantity): array 'tax_description' => $taxDescription, 'quantity' => $quantity, 'currency' => $currency, + 'billing_interval' => $billingInterval, ], ]; } catch (\Exception $e) { @@ -184,7 +187,7 @@ public function execute(Team $team, int $quantity): array \Log::info("Subscription {$subscription->stripe_subscription_id} quantity updated to {$quantity} for team {$team->name}"); return ['success' => true, 'error' => null]; - } catch (\Stripe\Exception\InvalidRequestException $e) { + } catch (InvalidRequestException $e) { \Log::error("Stripe update quantity error for team {$team->id}: ".$e->getMessage()); return ['success' => false, 'error' => 'Stripe error: '.$e->getMessage()]; diff --git a/app/Console/Commands/Dev.php b/app/Console/Commands/Dev.php index acc6dc2f9..7daa6ba28 100644 --- a/app/Console/Commands/Dev.php +++ b/app/Console/Commands/Dev.php @@ -30,32 +30,32 @@ public function init() // Generate APP_KEY if not exists if (empty(config('app.key'))) { - echo "Generating APP_KEY.\n"; + echo " INFO Generating APP_KEY.\n"; Artisan::call('key:generate'); } // Generate STORAGE link if not exists if (! file_exists(public_path('storage'))) { - echo "Generating STORAGE link.\n"; + echo " INFO Generating storage link.\n"; Artisan::call('storage:link'); } // Seed database if it's empty $settings = InstanceSettings::find(0); if (! $settings) { - echo "Initializing instance, seeding database.\n"; + echo " INFO Initializing instance, seeding database.\n"; Artisan::call('migrate --seed'); } else { - echo "Instance already initialized.\n"; + echo " INFO Instance already initialized.\n"; } // Clean up stuck jobs and stale locks on development startup try { - echo "Cleaning up Redis (stuck jobs and stale locks)...\n"; + echo " INFO Cleaning up Redis (stuck jobs and stale locks)...\n"; Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]); - echo "Redis cleanup completed.\n"; + echo " INFO Redis cleanup completed.\n"; } catch (\Throwable $e) { - echo "Error in cleanup:redis: {$e->getMessage()}\n"; + echo " ERROR Redis cleanup failed: {$e->getMessage()}\n"; } try { @@ -66,10 +66,10 @@ public function init() ]); if ($updatedTaskCount > 0) { - echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n"; + echo " INFO Marked {$updatedTaskCount} stuck scheduled task executions as failed.\n"; } } catch (\Throwable $e) { - echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n"; + echo " ERROR Could not clean up stuck scheduled task executions: {$e->getMessage()}\n"; } try { @@ -80,10 +80,10 @@ public function init() ]); if ($updatedBackupCount > 0) { - echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n"; + echo " INFO Marked {$updatedBackupCount} stuck database backup executions as failed.\n"; } } catch (\Throwable $e) { - echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n"; + echo " ERROR Could not clean up stuck database backup executions: {$e->getMessage()}\n"; } CheckHelperImageJob::dispatch(); diff --git a/app/Console/Commands/Horizon.php b/app/Console/Commands/Horizon.php deleted file mode 100644 index d3e35ca5a..000000000 --- a/app/Console/Commands/Horizon.php +++ /dev/null @@ -1,23 +0,0 @@ -info('Horizon is enabled on this server.'); - $this->call('horizon'); - exit(0); - } else { - exit(0); - } - } -} diff --git a/app/Console/Commands/Nightwatch.php b/app/Console/Commands/Nightwatch.php deleted file mode 100644 index 40fd86a81..000000000 --- a/app/Console/Commands/Nightwatch.php +++ /dev/null @@ -1,22 +0,0 @@ -info('Nightwatch is enabled on this server.'); - $this->call('nightwatch:agent'); - } - - exit(0); - } -} diff --git a/app/Console/Commands/Scheduler.php b/app/Console/Commands/Scheduler.php deleted file mode 100644 index ee64368c3..000000000 --- a/app/Console/Commands/Scheduler.php +++ /dev/null @@ -1,23 +0,0 @@ -info('Scheduler is enabled on this server.'); - $this->call('schedule:work'); - exit(0); - } else { - exit(0); - } - } -} diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php index 288904471..ee8cf2797 100644 --- a/app/Jobs/ValidateAndInstallServerJob.php +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -45,7 +45,8 @@ public function handle(): void // Validate connection ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); if (! $uptime) { - $errorMessage = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error; + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $errorMessage = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$sanitizedError; $this->server->update([ 'validation_logs' => $errorMessage, 'is_validating' => false, @@ -197,7 +198,7 @@ public function handle(): void ]); $this->server->update([ - 'validation_logs' => 'An error occurred during validation: '.$e->getMessage(), + 'validation_logs' => 'An error occurred during validation: '.htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'), 'is_validating' => false, ]); } diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index fd55717fa..810b95ed4 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -63,7 +63,8 @@ public function checkConnection() $this->dispatch('success', 'Server is reachable.'); $this->dispatch('refreshServerShow'); } else { - $this->dispatch('error', 'Server is not reachable.

Check this documentation for further help.

Error: '.$error); + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $this->dispatch('error', 'Server is not reachable.

Check this documentation for further help.

Error: '.$sanitizedError); return; } diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 198d823b9..59ca4cd36 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -89,7 +89,8 @@ public function validateConnection() $this->authorize('update', $this->server); ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); if (! $this->uptime) { - $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error.'
'; + $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); + $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$sanitizedError.'
'; $this->server->update([ 'validation_logs' => $this->error, ]); diff --git a/app/Models/Server.php b/app/Models/Server.php index b3dcf6353..0ae524f49 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -267,6 +267,13 @@ public static function flushIdentityMap(): void use HasSafeStringAttribute; + public function setValidationLogsAttribute($value): void + { + $this->attributes['validation_logs'] = $value !== null + ? \Stevebauman\Purify\Facades\Purify::config('validation_logs')->clean($value) + : null; + } + public function type() { return 'server'; diff --git a/app/Notifications/TransactionalEmails/ResetPassword.php b/app/Notifications/TransactionalEmails/ResetPassword.php index 179c8d948..511818e21 100644 --- a/app/Notifications/TransactionalEmails/ResetPassword.php +++ b/app/Notifications/TransactionalEmails/ResetPassword.php @@ -67,9 +67,12 @@ protected function resetUrl($notifiable) return call_user_func(static::$createUrlCallback, $notifiable, $this->token); } - return url(route('password.reset', [ + $path = route('password.reset', [ 'token' => $this->token, 'email' => $notifiable->getEmailForPasswordReset(), - ], false)); + ], false); + + // Use server-side config (FQDN / public IP) instead of request host + return rtrim(base_url(), '/').$path; } } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 84472a07e..cd773f6a9 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -16,6 +16,7 @@ use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; +use App\Models\SharedEnvironmentVariable; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -28,8 +29,10 @@ use App\Models\User; use Carbon\CarbonImmutable; use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Process\Pool; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; @@ -49,10 +52,14 @@ use Lcobucci\JWT\Signer\Hmac\Sha256; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Token\Builder; +use Livewire\Component; +use Nubs\RandomNameGenerator\All; +use Nubs\RandomNameGenerator\Alliteration; use phpseclib3\Crypt\EC; use phpseclib3\Crypt\RSA; use Poliander\Cron\CronExpression; use PurplePixie\PhpDns\DNSQuery; +use PurplePixie\PhpDns\DNSTypes; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; use Visus\Cuid2\Cuid2; @@ -116,7 +123,7 @@ function sanitize_string(?string $input = null): ?string * @param string $context Descriptive name for error messages (e.g., 'volume source', 'service name') * @return string The validated input (unchanged if valid) * - * @throws \Exception If dangerous characters are detected + * @throws Exception If dangerous characters are detected */ function validateShellSafePath(string $input, string $context = 'path'): string { @@ -138,7 +145,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string // Check for dangerous characters foreach ($dangerousChars as $char => $description) { if (str_contains($input, $char)) { - throw new \Exception( + throw new Exception( "Invalid {$context}: contains forbidden character '{$char}' ({$description}). ". 'Shell metacharacters are not allowed for security reasons.' ); @@ -160,7 +167,7 @@ function validateShellSafePath(string $input, string $context = 'path'): string * @param string $input The databases_to_backup string * @return string The validated input * - * @throws \Exception If any component contains dangerous characters + * @throws Exception If any component contains dangerous characters */ function validateDatabasesBackupInput(string $input): string { @@ -211,7 +218,7 @@ function validateDatabasesBackupInput(string $input): string * @param string $context Descriptive name for error messages * @return string The validated input (trimmed) * - * @throws \Exception If the input contains disallowed characters + * @throws Exception If the input contains disallowed characters */ function validateGitRef(string $input, string $context = 'git ref'): string { @@ -223,12 +230,12 @@ function validateGitRef(string $input, string $context = 'git ref'): string // Must not start with a hyphen (git flag injection) if (str_starts_with($input, '-')) { - throw new \Exception("Invalid {$context}: must not start with a hyphen."); + throw new Exception("Invalid {$context}: must not start with a hyphen."); } // Allow only alphanumeric characters, dots, hyphens, underscores, and slashes if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/', $input)) { - throw new \Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed."); + throw new Exception("Invalid {$context}: contains disallowed characters. Only alphanumeric characters, dots, hyphens, underscores, and slashes are allowed."); } return $input; @@ -282,7 +289,7 @@ function refreshSession(?Team $team = null): void }); session(['currentTeam' => $team]); } -function handleError(?Throwable $error = null, ?Livewire\Component $livewire = null, ?string $customErrorMessage = null) +function handleError(?Throwable $error = null, ?Component $livewire = null, ?string $customErrorMessage = null) { if ($error instanceof TooManyRequestsException) { if (isset($livewire)) { @@ -299,7 +306,7 @@ function handleError(?Throwable $error = null, ?Livewire\Component $livewire = n return 'Duplicate entry found. Please use a different name.'; } - if ($error instanceof \Illuminate\Database\Eloquent\ModelNotFoundException) { + if ($error instanceof ModelNotFoundException) { abort(404); } @@ -329,7 +336,7 @@ function get_latest_sentinel_version(): string $versions = $response->json(); return data_get($versions, 'coolify.sentinel.version'); - } catch (\Throwable) { + } catch (Throwable) { return '0.0.0'; } } @@ -339,7 +346,7 @@ function get_latest_version_of_coolify(): string $versions = get_versions_data(); return data_get($versions, 'coolify.v4.version', '0.0.0'); - } catch (\Throwable $e) { + } catch (Throwable $e) { return '0.0.0'; } @@ -347,9 +354,9 @@ function get_latest_version_of_coolify(): string function generate_random_name(?string $cuid = null): string { - $generator = new \Nubs\RandomNameGenerator\All( + $generator = new All( [ - new \Nubs\RandomNameGenerator\Alliteration, + new Alliteration, ] ); if (is_null($cuid)) { @@ -448,7 +455,7 @@ function getFqdnWithoutPort(string $fqdn) $path = $url->getPath(); return "$scheme://$host$path"; - } catch (\Throwable) { + } catch (Throwable) { return $fqdn; } } @@ -478,13 +485,13 @@ function base_url(bool $withPort = true): string } if ($settings->public_ipv6) { if ($withPort) { - return "http://$settings->public_ipv6:$port"; + return "http://[$settings->public_ipv6]:$port"; } - return "http://$settings->public_ipv6"; + return "http://[$settings->public_ipv6]"; } - return url('/'); + return config('app.url'); } function isSubscribed() @@ -537,21 +544,21 @@ function validate_cron_expression($expression_to_validate): bool * Even if the job runs minutes late, it still catches the missed cron window. * Without a dedupKey, falls back to a simple isDue() check. */ -function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?\Illuminate\Support\Carbon $executionTime = null): bool +function shouldRunCronNow(string $frequency, string $timezone, ?string $dedupKey = null, ?Carbon $executionTime = null): bool { - $cron = new \Cron\CronExpression($frequency); - $executionTime = ($executionTime ?? \Illuminate\Support\Carbon::now())->copy()->setTimezone($timezone); + $cron = new Cron\CronExpression($frequency); + $executionTime = ($executionTime ?? Carbon::now())->copy()->setTimezone($timezone); if ($dedupKey === null) { return $cron->isDue($executionTime); } - $previousDue = \Illuminate\Support\Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); + $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true)); $lastDispatched = Cache::get($dedupKey); $shouldFire = $lastDispatched === null ? $cron->isDue($executionTime) - : $previousDue->gt(\Illuminate\Support\Carbon::parse($lastDispatched)); + : $previousDue->gt(Carbon::parse($lastDispatched)); // Always write: seeds on first miss, refreshes on dispatch. // 30-day static TTL covers all intervals; orphan keys self-clean. @@ -932,7 +939,7 @@ function get_service_templates(bool $force = false): Collection $services = $response->json(); return collect($services); - } catch (\Throwable) { + } catch (Throwable) { $services = File::get(base_path('templates/'.config('constants.services.file_name'))); return collect(json_decode($services))->sortKeys(); @@ -955,7 +962,7 @@ function getResourceByUuid(string $uuid, ?int $teamId = null) } // ServiceDatabase has a different relationship path: service->environment->project->team_id - if ($resource instanceof \App\Models\ServiceDatabase) { + if ($resource instanceof ServiceDatabase) { if ($resource->service?->environment?->project?->team_id === $teamId) { return $resource; } @@ -1081,7 +1088,7 @@ function generateGitManualWebhook($resource, $type) if ($resource->source_id !== 0 && ! is_null($resource->source_id)) { return null; } - if ($resource->getMorphClass() === \App\Models\Application::class) { + if ($resource->getMorphClass() === Application::class) { $baseUrl = base_url(); return Url::fromString($baseUrl)."/webhooks/source/$type/events/manual"; @@ -1102,11 +1109,11 @@ function sanitizeLogsForExport(string $text): string function getTopLevelNetworks(Service|Application $resource) { - if ($resource->getMorphClass() === \App\Models\Service::class) { + if ($resource->getMorphClass() === Service::class) { if ($resource->docker_compose_raw) { try { $yaml = Yaml::parse($resource->docker_compose_raw); - } catch (\Exception $e) { + } catch (Exception $e) { // If the docker-compose.yml file is not valid, we will return the network name as the key $topLevelNetworks = collect([ $resource->uuid => [ @@ -1169,10 +1176,10 @@ function getTopLevelNetworks(Service|Application $resource) return $topLevelNetworks->keys(); } - } elseif ($resource->getMorphClass() === \App\Models\Application::class) { + } elseif ($resource->getMorphClass() === Application::class) { try { $yaml = Yaml::parse($resource->docker_compose_raw); - } catch (\Exception $e) { + } catch (Exception $e) { // If the docker-compose.yml file is not valid, we will return the network name as the key $topLevelNetworks = collect([ $resource->uuid => [ @@ -1479,7 +1486,7 @@ function validateDNSEntry(string $fqdn, Server $server) $ip = $server->ip; } $found_matching_ip = false; - $type = \PurplePixie\PhpDns\DNSTypes::NAME_A; + $type = DNSTypes::NAME_A; foreach ($dns_servers as $dns_server) { try { $query = new DNSQuery($dns_server); @@ -1500,7 +1507,7 @@ function validateDNSEntry(string $fqdn, Server $server) } } } - } catch (\Exception) { + } catch (Exception) { } } @@ -1682,7 +1689,7 @@ function get_public_ips() } InstanceSettings::get()->update(['public_ipv4' => $ipv4]); } - } catch (\Exception $e) { + } catch (Exception $e) { echo "Error: {$e->getMessage()}\n"; } try { @@ -1697,7 +1704,7 @@ function get_public_ips() } InstanceSettings::get()->update(['public_ipv6' => $ipv6]); } - } catch (\Throwable $e) { + } catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; } } @@ -1795,15 +1802,15 @@ function customApiValidator(Collection|array $item, array $rules) } function parseDockerComposeFile(Service|Application $resource, bool $isNew = false, int $pull_request_id = 0, ?int $preview_id = null) { - if ($resource->getMorphClass() === \App\Models\Service::class) { + if ($resource->getMorphClass() === Service::class) { if ($resource->docker_compose_raw) { // Extract inline comments from raw YAML before Symfony parser discards them $envComments = extractYamlEnvironmentComments($resource->docker_compose_raw); try { $yaml = Yaml::parse($resource->docker_compose_raw); - } catch (\Exception $e) { - throw new \RuntimeException($e->getMessage()); + } catch (Exception $e) { + throw new RuntimeException($e->getMessage()); } $allServices = get_service_templates(); $topLevelVolumes = collect(data_get($yaml, 'volumes', [])); @@ -2567,10 +2574,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } else { return collect([]); } - } elseif ($resource->getMorphClass() === \App\Models\Application::class) { + } elseif ($resource->getMorphClass() === Application::class) { try { $yaml = Yaml::parse($resource->docker_compose_raw); - } catch (\Exception) { + } catch (Exception) { return; } $server = $resource->destination->server; @@ -3332,7 +3339,7 @@ function isAssociativeArray($array) } if (! is_array($array)) { - throw new \InvalidArgumentException('Input must be an array or a Collection.'); + throw new InvalidArgumentException('Input must be an array or a Collection.'); } if ($array === []) { @@ -3448,7 +3455,7 @@ function wireNavigate(): string // Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : ''; - } catch (\Exception $e) { + } catch (Exception $e) { return 'wire:navigate.hover'; } } @@ -3457,13 +3464,13 @@ function wireNavigate(): string * Redirect to a named route with SPA navigation support. * Automatically uses wire:navigate when is_wire_navigate_enabled is true. */ -function redirectRoute(Livewire\Component $component, string $name, array $parameters = []): mixed +function redirectRoute(Component $component, string $name, array $parameters = []): mixed { $navigate = true; try { $navigate = instanceSettings()->is_wire_navigate_enabled ?? true; - } catch (\Exception $e) { + } catch (Exception $e) { $navigate = true; } @@ -3505,7 +3512,7 @@ function loadConfigFromGit(string $repository, string $branch, string $base_dire ]); try { return instant_remote_process($commands, $server); - } catch (\Exception) { + } catch (Exception) { // continue } } @@ -3636,8 +3643,8 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp| // If this happens, the user may have provided an HTTP URL when they needed an SSH one // Let's try and fix that for known Git providers switch ($source->getMorphClass()) { - case \App\Models\GithubApp::class: - case \App\Models\GitlabApp::class: + case GithubApp::class: + case GitlabApp::class: $providerInfo['host'] = Url::fromString($source->html_url)->getHost(); $providerInfo['port'] = $source->custom_port; $providerInfo['user'] = $source->custom_user; @@ -3915,10 +3922,10 @@ function shouldSkipPasswordConfirmation(): bool * - User has no password (OAuth users) * * @param mixed $password The password to verify (may be array if skipped by frontend) - * @param \Livewire\Component|null $component Optional Livewire component to add errors to + * @param Component|null $component Optional Livewire component to add errors to * @return bool True if verification passed (or skipped), false if password is incorrect */ -function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $component = null): bool +function verifyPasswordConfirmation(mixed $password, ?Component $component = null): bool { // Skip if password confirmation should be skipped if (shouldSkipPasswordConfirmation()) { @@ -3941,17 +3948,17 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon * Extract hard-coded environment variables from docker-compose YAML. * * @param string $dockerComposeRaw Raw YAML content - * @return \Illuminate\Support\Collection Collection of arrays with: key, value, comment, service_name + * @return Collection Collection of arrays with: key, value, comment, service_name */ -function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection +function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collection { if (blank($dockerComposeRaw)) { return collect([]); } try { - $yaml = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); - } catch (\Exception $e) { + $yaml = Yaml::parse($dockerComposeRaw); + } catch (Exception $e) { // Malformed YAML - return empty collection return collect([]); } @@ -4100,7 +4107,7 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string if (is_null($id)) { continue; } - $found = \App\Models\SharedEnvironmentVariable::where('type', $type) + $found = SharedEnvironmentVariable::where('type', $type) ->where('key', $variable) ->where('team_id', $resource->team()->id) ->where("{$type}_id", $id) diff --git a/config/purify.php b/config/purify.php index 66dbbb568..a5dcabb92 100644 --- a/config/purify.php +++ b/config/purify.php @@ -49,6 +49,17 @@ 'AutoFormat.RemoveEmpty' => false, ], + 'validation_logs' => [ + 'Core.Encoding' => 'utf-8', + 'HTML.Doctype' => 'HTML 4.01 Transitional', + 'HTML.Allowed' => 'a[href|title|target|class],br,div[class],pre[class],span[class],p[class]', + 'HTML.ForbiddenElements' => '', + 'CSS.AllowedProperties' => '', + 'AutoFormat.AutoParagraph' => false, + 'AutoFormat.RemoveEmpty' => false, + 'Attr.AllowedFrameTargets' => ['_blank'], + ], + ], /* diff --git a/docker/development/etc/s6-overlay/s6-rc.d/horizon/run b/docker/development/etc/s6-overlay/s6-rc.d/horizon/run index ada19b3a3..dbc472d06 100644 --- a/docker/development/etc/s6-overlay/s6-rc.d/horizon/run +++ b/docker/development/etc/s6-overlay/s6-rc.d/horizon/run @@ -1,12 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:horizon -} +if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then + echo " INFO Horizon is disabled, sleeping." + exec sleep infinity +fi +echo " INFO Horizon is enabled, starting..." +exec php artisan horizon diff --git a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run index 1166ccd08..ee46dba7e 100644 --- a/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run +++ b/docker/development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run @@ -1,12 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:nightwatch -} +if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then + echo " INFO Nightwatch is enabled, starting..." + exec php artisan nightwatch:agent +fi +echo " INFO Nightwatch is disabled, sleeping." +exec sleep infinity diff --git a/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run b/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run index b81a44833..bfa44c7e3 100644 --- a/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run +++ b/docker/development/etc/s6-overlay/s6-rc.d/scheduler-worker/run @@ -1,13 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:scheduler -} - +if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then + echo " INFO Scheduler is disabled, sleeping." + exec sleep infinity +fi +echo " INFO Scheduler is enabled, starting..." +exec php artisan schedule:work diff --git a/docker/production/etc/s6-overlay/s6-rc.d/horizon/run b/docker/production/etc/s6-overlay/s6-rc.d/horizon/run index be6647607..dbc472d06 100644 --- a/docker/production/etc/s6-overlay/s6-rc.d/horizon/run +++ b/docker/production/etc/s6-overlay/s6-rc.d/horizon/run @@ -1,11 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:horizon -} +if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then + echo " INFO Horizon is disabled, sleeping." + exec sleep infinity +fi + +echo " INFO Horizon is enabled, starting..." +exec php artisan horizon diff --git a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run index 80d73eadb..ee46dba7e 100644 --- a/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run +++ b/docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run @@ -1,11 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:nightwatch -} +if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then + echo " INFO Nightwatch is enabled, starting..." + exec php artisan nightwatch:agent +fi + +echo " INFO Nightwatch is disabled, sleeping." +exec sleep infinity diff --git a/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run b/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run index a2ecb0a73..bfa44c7e3 100644 --- a/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run +++ b/docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run @@ -1,10 +1,11 @@ -#!/command/execlineb -P +#!/bin/sh -# Use with-contenv to ensure environment variables are available -with-contenv cd /var/www/html -foreground { - php - artisan - start:scheduler -} + +if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then + echo " INFO Scheduler is disabled, sleeping." + exec sleep infinity +fi + +echo " INFO Scheduler is enabled, starting..." +exec php artisan schedule:work diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php index 6fba0ed83..aa129043b 100644 --- a/resources/views/livewire/subscription/actions.blade.php +++ b/resources/views/livewire/subscription/actions.blade.php @@ -160,7 +160,7 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
- Total / {{ $billingInterval === 'yearly' ? 'year' : 'month' }} + Total / month
diff --git a/tests/Feature/ResetPasswordUrlTest.php b/tests/Feature/ResetPasswordUrlTest.php new file mode 100644 index 000000000..7e940fc71 --- /dev/null +++ b/tests/Feature/ResetPasswordUrlTest.php @@ -0,0 +1,187 @@ +invoke($notification, $notifiable); +} + +it('generates reset URL using configured FQDN, not request host', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91'] + ); + Once::flush(); + + $user = User::factory()->create(); + $notification = new ResetPassword('test-token-abc', isTransactionalEmail: false); + + $url = callResetUrl($notification, $user); + + expect($url) + ->toStartWith('https://coolify.example.com/') + ->toContain('test-token-abc') + ->toContain(urlencode($user->email)) + ->not->toContain('localhost'); +}); + +it('generates reset URL using public IP when no FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => null, 'public_ipv4' => '65.21.3.91'] + ); + Once::flush(); + + $user = User::factory()->create(); + $notification = new ResetPassword('test-token-abc', isTransactionalEmail: false); + + $url = callResetUrl($notification, $user); + + expect($url) + ->toContain('65.21.3.91') + ->toContain('test-token-abc') + ->not->toContain('evil.com'); +}); + +it('is immune to X-Forwarded-Host header poisoning when FQDN is set', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com', 'public_ipv4' => '65.21.3.91'] + ); + Once::flush(); + + // Simulate a request with a spoofed X-Forwarded-Host header + $user = User::factory()->create(); + + $this->withHeaders([ + 'X-Forwarded-Host' => 'evil.com', + ])->get('/'); + + $notification = new ResetPassword('poisoned-token', isTransactionalEmail: false); + $url = callResetUrl($notification, $user); + + expect($url) + ->toStartWith('https://coolify.example.com/') + ->toContain('poisoned-token') + ->not->toContain('evil.com'); +}); + +it('is immune to X-Forwarded-Host header poisoning when using IP only', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => null, 'public_ipv4' => '65.21.3.91'] + ); + Once::flush(); + + $user = User::factory()->create(); + + $this->withHeaders([ + 'X-Forwarded-Host' => 'evil.com', + ])->get('/'); + + $notification = new ResetPassword('poisoned-token', isTransactionalEmail: false); + $url = callResetUrl($notification, $user); + + expect($url) + ->toContain('65.21.3.91') + ->toContain('poisoned-token') + ->not->toContain('evil.com'); +}); + +it('generates reset URL with bracketed IPv6 when no FQDN is configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1'] + ); + Once::flush(); + + $user = User::factory()->create(); + $notification = new ResetPassword('ipv6-token', isTransactionalEmail: false); + + $url = callResetUrl($notification, $user); + + expect($url) + ->toContain('[2001:db8::1]') + ->toContain('ipv6-token') + ->toContain(urlencode($user->email)); +}); + +it('is immune to X-Forwarded-Host header poisoning when using IPv6 only', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => '2001:db8::1'] + ); + Once::flush(); + + $user = User::factory()->create(); + + $this->withHeaders([ + 'X-Forwarded-Host' => 'evil.com', + ])->get('/'); + + $notification = new ResetPassword('poisoned-token', isTransactionalEmail: false); + $url = callResetUrl($notification, $user); + + expect($url) + ->toContain('[2001:db8::1]') + ->toContain('poisoned-token') + ->not->toContain('evil.com'); +}); + +it('uses APP_URL fallback when no FQDN or public IPs are configured', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => null, 'public_ipv4' => null, 'public_ipv6' => null] + ); + Once::flush(); + + config(['app.url' => 'http://my-coolify.local']); + + $user = User::factory()->create(); + + $this->withHeaders([ + 'X-Forwarded-Host' => 'evil.com', + ])->get('/'); + + $notification = new ResetPassword('fallback-token', isTransactionalEmail: false); + $url = callResetUrl($notification, $user); + + expect($url) + ->toStartWith('http://my-coolify.local/') + ->toContain('fallback-token') + ->not->toContain('evil.com'); +}); + +it('generates a valid route path in the reset URL', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + Once::flush(); + + $user = User::factory()->create(); + $notification = new ResetPassword('my-token', isTransactionalEmail: false); + + $url = callResetUrl($notification, $user); + + // Should contain the password reset route path with token and email + expect($url) + ->toContain('/reset-password/') + ->toContain('my-token') + ->toContain(urlencode($user->email)); +}); diff --git a/tests/Feature/ServerValidationXssTest.php b/tests/Feature/ServerValidationXssTest.php new file mode 100644 index 000000000..ba8e6fcae --- /dev/null +++ b/tests/Feature/ServerValidationXssTest.php @@ -0,0 +1,75 @@ +create(); + $this->team = Team::factory()->create(); + $user->teams()->attach($this->team); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +it('strips dangerous HTML from validation_logs via mutator', function () { + $xssPayload = ''; + $this->server->update(['validation_logs' => $xssPayload]); + $this->server->refresh(); + + expect($this->server->validation_logs)->not->toContain('and($this->server->validation_logs)->not->toContain('onerror'); +}); + +it('strips script tags from validation_logs', function () { + $xssPayload = ''; + $this->server->update(['validation_logs' => $xssPayload]); + $this->server->refresh(); + + expect($this->server->validation_logs)->not->toContain('server->update(['validation_logs' => $allowedHtml]); + $this->server->refresh(); + + expect($this->server->validation_logs)->toContain('and($this->server->validation_logs)->toContain('and($this->server->validation_logs)->toContain('and($this->server->validation_logs)->toContain('Connection refused'); +}); + +it('allows null validation_logs', function () { + $this->server->update(['validation_logs' => null]); + $this->server->refresh(); + + expect($this->server->validation_logs)->toBeNull(); +}); + +it('sanitizes XSS embedded within valid error HTML', function () { + $maliciousError = 'Server is not reachable.
Error:
'; + $this->server->update(['validation_logs' => $maliciousError]); + $this->server->refresh(); + + expect($this->server->validation_logs)->toContain('and($this->server->validation_logs)->toContain('Error:') + ->and($this->server->validation_logs)->not->toContain('onerror') + ->and($this->server->validation_logs)->not->toContain('server->update(['validation_logs' => $payload]); + $this->server->refresh(); + + expect($this->server->validation_logs)->toContain('and($this->server->validation_logs)->not->toContain('onmouseover'); +}); diff --git a/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php index 3e13170f0..3eda322e8 100644 --- a/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php +++ b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php @@ -7,6 +7,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; +use Stripe\Exception\InvalidRequestException; use Stripe\Service\InvoiceService; use Stripe\Service\SubscriptionService; use Stripe\Service\TaxRateService; @@ -46,7 +47,7 @@ 'data' => [(object) [ 'id' => 'si_item_123', 'quantity' => 2, - 'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'], + 'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'month']], ]], ], ]; @@ -187,7 +188,7 @@ test('handles stripe API error gracefully', function () { $this->mockSubscriptions ->shouldReceive('retrieve') - ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found')); + ->andThrow(new InvalidRequestException('Subscription not found')); $action = new UpdateSubscriptionQuantity($this->mockStripe); $result = $action->execute($this->team, 5); @@ -199,7 +200,7 @@ test('handles generic exception gracefully', function () { $this->mockSubscriptions ->shouldReceive('retrieve') - ->andThrow(new \RuntimeException('Network error')); + ->andThrow(new RuntimeException('Network error')); $action = new UpdateSubscriptionQuantity($this->mockStripe); $result = $action->execute($this->team, 5); @@ -270,6 +271,46 @@ expect($result['preview']['tax_description'])->toContain('27%'); expect($result['preview']['quantity'])->toBe(3); expect($result['preview']['currency'])->toBe('USD'); + expect($result['preview']['billing_interval'])->toBe('month'); + }); + + test('returns yearly billing interval for annual subscriptions', function () { + $yearlySubscriptionResponse = (object) [ + 'items' => (object) [ + 'data' => [(object) [ + 'id' => 'si_item_123', + 'quantity' => 2, + 'price' => (object) ['unit_amount' => 500, 'currency' => 'usd', 'recurring' => (object) ['interval' => 'year']], + ]], + ], + ]; + + $this->mockSubscriptions + ->shouldReceive('retrieve') + ->with('sub_test_qty') + ->andReturn($yearlySubscriptionResponse); + + $this->mockInvoices + ->shouldReceive('upcoming') + ->andReturn((object) [ + 'amount_due' => 1000, + 'total' => 1000, + 'subtotal' => 1000, + 'tax' => 0, + 'currency' => 'usd', + 'lines' => (object) [ + 'data' => [ + (object) ['amount' => 1000, 'proration' => false], + ], + ], + 'total_tax_amounts' => [], + ]); + + $action = new UpdateSubscriptionQuantity($this->mockStripe); + $result = $action->fetchPricePreview($this->team, 2); + + expect($result['success'])->toBeTrue(); + expect($result['preview']['billing_interval'])->toBe('year'); }); test('returns preview without tax when no tax applies', function () { @@ -336,7 +377,7 @@ test('handles Stripe API error gracefully', function () { $this->mockSubscriptions ->shouldReceive('retrieve') - ->andThrow(new \RuntimeException('API error')); + ->andThrow(new RuntimeException('API error')); $action = new UpdateSubscriptionQuantity($this->mockStripe); $result = $action->fetchPricePreview($this->team, 5); diff --git a/tests/Feature/TrustHostsMiddlewareTest.php b/tests/Feature/TrustHostsMiddlewareTest.php deleted file mode 100644 index 5c60b30d6..000000000 --- a/tests/Feature/TrustHostsMiddlewareTest.php +++ /dev/null @@ -1,360 +0,0 @@ - 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('coolify.example.com'); -}); - -it('rejects password reset request with malicious host header', function () { - // Set up instance settings with legitimate FQDN - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - // The malicious host should NOT be in the trusted hosts - expect($hosts)->not->toContain('coolify.example.com.evil.com'); - expect($hosts)->toContain('coolify.example.com'); -}); - -it('handles missing FQDN gracefully', function () { - // Create instance settings without FQDN - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => null] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - // Should still return APP_URL pattern without throwing - expect($hosts)->not->toBeEmpty(); -}); - -it('filters out null and empty values from trusted hosts', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => ''] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - // Should not contain empty strings or null - foreach ($hosts as $host) { - if ($host !== null) { - expect($host)->not->toBeEmpty(); - } - } -}); - -it('extracts host from FQDN with protocol and port', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com:8443'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('coolify.example.com'); -}); - -it('handles exception during InstanceSettings fetch', function () { - // Drop the instance_settings table to simulate installation - \Schema::dropIfExists('instance_settings'); - - $middleware = new TrustHosts($this->app); - - // Should not throw an exception - $hosts = $middleware->hosts(); - - expect($hosts)->not->toBeEmpty(); -}); - -it('trusts IP addresses with port', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'http://65.21.3.91:8000'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('65.21.3.91'); -}); - -it('trusts IP addresses without port', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'http://192.168.1.100'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('192.168.1.100'); -}); - -it('rejects malicious host when using IP address', function () { - // Simulate an instance using IP address - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'http://65.21.3.91:8000'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - // The malicious host attempting to mimic the IP should NOT be trusted - expect($hosts)->not->toContain('65.21.3.91.evil.com'); - expect($hosts)->not->toContain('evil.com'); - expect($hosts)->toContain('65.21.3.91'); -}); - -it('trusts IPv6 addresses', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'http://[2001:db8::1]:8000'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - // IPv6 addresses are enclosed in brackets, getHost() should handle this - expect($hosts)->toContain('[2001:db8::1]'); -}); - -it('invalidates cache when FQDN is updated', function () { - // Set initial FQDN - $settings = InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://old-domain.com'] - ); - - // First call should cache it - $middleware = new TrustHosts($this->app); - $hosts1 = $middleware->hosts(); - expect($hosts1)->toContain('old-domain.com'); - - // Verify cache exists - expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue(); - - // Update FQDN - should trigger cache invalidation - $settings->fqdn = 'https://new-domain.com'; - $settings->save(); - - // Cache should be cleared - expect(Cache::has('instance_settings_fqdn_host'))->toBeFalse(); - - // New call should return updated host - $middleware2 = new TrustHosts($this->app); - $hosts2 = $middleware2->hosts(); - expect($hosts2)->toContain('new-domain.com'); - expect($hosts2)->not->toContain('old-domain.com'); -}); - -it('caches trusted hosts to avoid database queries on every request', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - // Clear cache first - Cache::forget('instance_settings_fqdn_host'); - - // First call - should query database and cache result - $middleware1 = new TrustHosts($this->app); - $hosts1 = $middleware1->hosts(); - - // Verify result is cached - expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue(); - expect(Cache::get('instance_settings_fqdn_host'))->toBe('coolify.example.com'); - - // Subsequent calls should use cache (no DB query) - $middleware2 = new TrustHosts($this->app); - $hosts2 = $middleware2->hosts(); - - expect($hosts1)->toBe($hosts2); - expect($hosts2)->toContain('coolify.example.com'); -}); - -it('caches negative results when no FQDN is configured', function () { - // Create instance settings without FQDN - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => null] - ); - - // Clear cache first - Cache::forget('instance_settings_fqdn_host'); - - // First call - should query database and cache empty string sentinel - $middleware1 = new TrustHosts($this->app); - $hosts1 = $middleware1->hosts(); - - // Verify empty string sentinel is cached (not null, which wouldn't be cached) - expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue(); - expect(Cache::get('instance_settings_fqdn_host'))->toBe(''); - - // Subsequent calls should use cached sentinel value - $middleware2 = new TrustHosts($this->app); - $hosts2 = $middleware2->hosts(); - - expect($hosts1)->toBe($hosts2); - // Should only contain APP_URL pattern, not any FQDN - expect($hosts2)->not->toBeEmpty(); -}); - -it('skips host validation for terminal auth routes', function () { - // These routes should be accessible with any Host header (for internal container communication) - $response = $this->postJson('/terminal/auth', [], [ - 'Host' => 'coolify:8080', // Internal Docker host - ]); - - // Should not get 400 Bad Host (might get 401 Unauthorized instead) - expect($response->status())->not->toBe(400); -}); - -it('skips host validation for terminal auth ips route', function () { - // These routes should be accessible with any Host header (for internal container communication) - $response = $this->postJson('/terminal/auth/ips', [], [ - 'Host' => 'soketi:6002', // Another internal Docker host - ]); - - // Should not get 400 Bad Host (might get 401 Unauthorized instead) - expect($response->status())->not->toBe(400); -}); - -it('still enforces host validation for non-terminal routes', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - // Regular routes should still validate Host header - $response = $this->get('/', [ - 'Host' => 'evil.com', - ]); - - // Should get 400 Bad Host for untrusted host - expect($response->status())->toBe(400); -}); - -it('skips host validation for API routes', function () { - // All API routes use token-based auth (Sanctum), not host validation - // They should be accessible from any host (mobile apps, CLI tools, scripts) - - // Test health check endpoint - $response = $this->get('/api/health', [ - 'Host' => 'internal-lb.local', - ]); - expect($response->status())->not->toBe(400); - - // Test v1 health check - $response = $this->get('/api/v1/health', [ - 'Host' => '10.0.0.5', - ]); - expect($response->status())->not->toBe(400); - - // Test feedback endpoint - $response = $this->post('/api/feedback', [], [ - 'Host' => 'mobile-app.local', - ]); - expect($response->status())->not->toBe(400); -}); - -it('trusts localhost when FQDN is configured', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('localhost'); -}); - -it('trusts 127.0.0.1 when FQDN is configured', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('127.0.0.1'); -}); - -it('trusts IPv6 loopback when FQDN is configured', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $middleware = new TrustHosts($this->app); - $hosts = $middleware->hosts(); - - expect($hosts)->toContain('[::1]'); -}); - -it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $response = $this->get('/', [ - 'Host' => 'localhost', - ]); - - // Should NOT be rejected as untrusted host (would be 400) - expect($response->status())->not->toBe(400); -}); - -it('skips host validation for webhook endpoints', function () { - // All webhook routes are under /webhooks/* prefix (see RouteServiceProvider) - // and use cryptographic signature validation instead of host validation - - // Test GitHub webhook - $response = $this->post('/webhooks/source/github/events', [], [ - 'Host' => 'github-webhook-proxy.local', - ]); - expect($response->status())->not->toBe(400); - - // Test GitLab webhook - $response = $this->post('/webhooks/source/gitlab/events/manual', [], [ - 'Host' => 'gitlab.example.com', - ]); - expect($response->status())->not->toBe(400); - - // Test Stripe webhook - $response = $this->post('/webhooks/payments/stripe/events', [], [ - 'Host' => 'stripe-webhook-forwarder.local', - ]); - expect($response->status())->not->toBe(400); -});