From 103d5b6c0634644b8e1bc01bf8540480aef65d0a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:36:36 +0100 Subject: [PATCH 1/8] fix: sanitize error output in server validation logs Escape dynamic error messages with htmlspecialchars() before concatenating into HTML strings stored in validation_logs. Add a Purify-based mutator on Server model as defense-in-depth, with a dedicated HTMLPurifier config that allows only safe structural tags. Co-Authored-By: Claude Opus 4.6 --- app/Actions/Server/ValidateServer.php | 3 +- app/Jobs/ValidateAndInstallServerJob.php | 5 +- app/Livewire/Server/PrivateKey/Show.php | 3 +- app/Livewire/Server/ValidateAndInstall.php | 3 +- app/Models/Server.php | 7 ++ config/purify.php | 11 ++++ tests/Feature/ServerValidationXssTest.php | 75 ++++++++++++++++++++++ 7 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/ServerValidationXssTest.php 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/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 9237763c8..00843b3da 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -269,6 +269,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/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/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'); +}); From e1d4b4682efc898ba5aa3751b2da2072f89c7e24 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:43:40 +0100 Subject: [PATCH 2/8] fix: harden TrustHosts middleware and use base_url() for password reset links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix circular cache dependency in TrustHosts where handle() checked cache before hosts() could populate it, causing host validation to never activate - Validate both Host and X-Forwarded-Host headers against trusted hosts list (X-Forwarded-Host is checked before TrustProxies applies it to the request) - Use base_url() instead of url() for password reset link generation so the URL is derived from server-side config (FQDN / public IP) instead of the request context - Strip port from X-Forwarded-Host before matching (e.g. host:443 → host) - Add tests for host validation, cache population, and reset URL generation Co-Authored-By: Claude Opus 4.6 --- app/Http/Middleware/TrustHosts.php | 59 ++++++- .../TransactionalEmails/ResetPassword.php | 7 +- tests/Feature/ResetPasswordUrlTest.php | 123 +++++++++++++++ tests/Feature/TrustHostsMiddlewareTest.php | 145 ++++++++++++++++-- 4 files changed, 321 insertions(+), 13 deletions(-) create mode 100644 tests/Feature/ResetPasswordUrlTest.php diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php index 5fca583d9..d44b6057a 100644 --- a/app/Http/Middleware/TrustHosts.php +++ b/app/Http/Middleware/TrustHosts.php @@ -30,14 +30,44 @@ public function handle(Request $request, $next) return $next($request); } + // Eagerly call hosts() to populate the cache (fixes circular dependency + // where handle() checked cache before hosts() could populate it via + // Cache::remember, causing host validation to never activate) + $this->hosts(); + // Skip host validation if no FQDN is configured (initial setup) $fqdnHost = Cache::get('instance_settings_fqdn_host'); if ($fqdnHost === '' || $fqdnHost === null) { return $next($request); } - // For all other routes, use parent's host validation - return parent::handle($request, $next); + // Validate the request host against trusted hosts explicitly. + // We check manually instead of relying on Symfony's lazy getHost() validation, + // which can be bypassed if getHost() was already called earlier in the pipeline. + $trustedHosts = array_filter($this->hosts()); + + // Collect all hosts to validate: the actual Host header, plus X-Forwarded-Host + // if present. We must check X-Forwarded-Host here because this middleware runs + // BEFORE TrustProxies, which would later apply X-Forwarded-Host to the request. + $hostsToValidate = [strtolower(trim($request->getHost()))]; + + $forwardedHost = $request->headers->get('X-Forwarded-Host'); + if ($forwardedHost) { + // X-Forwarded-Host can be a comma-separated list; validate the first (client-facing) value. + // Strip port if present (e.g. "coolify.example.com:443" → "coolify.example.com") + // to match the trusted hosts list which stores hostnames without ports. + $forwardedHostValue = strtolower(trim(explode(',', $forwardedHost)[0])); + $forwardedHostValue = preg_replace('/:\d+$/', '', $forwardedHostValue); + $hostsToValidate[] = $forwardedHostValue; + } + + foreach ($hostsToValidate as $hostToCheck) { + if (! $this->isHostTrusted($hostToCheck, $trustedHosts)) { + return response('Bad Host', 400); + } + } + + return $next($request); } /** @@ -100,4 +130,29 @@ public function hosts(): array return array_filter($trustedHosts); } + + /** + * Check if a host matches the trusted hosts list. + * + * Regex patterns (from allSubdomainsOfApplicationUrl, starting with ^) + * are matched with preg_match. Literal hostnames use exact comparison + * only — they are NOT passed to preg_match, which would treat unescaped + * dots as wildcards and match unanchored substrings. + * + * @param array $trustedHosts + */ + protected function isHostTrusted(string $host, array $trustedHosts): bool + { + foreach ($trustedHosts as $pattern) { + if (str_starts_with($pattern, '^')) { + if (@preg_match('{'.$pattern.'}i', $host)) { + return true; + } + } elseif ($host === $pattern) { + return true; + } + } + + return false; + } } 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/tests/Feature/ResetPasswordUrlTest.php b/tests/Feature/ResetPasswordUrlTest.php new file mode 100644 index 000000000..03d1103f0 --- /dev/null +++ b/tests/Feature/ResetPasswordUrlTest.php @@ -0,0 +1,123 @@ +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 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/TrustHostsMiddlewareTest.php b/tests/Feature/TrustHostsMiddlewareTest.php index 5c60b30d6..a16698a6a 100644 --- a/tests/Feature/TrustHostsMiddlewareTest.php +++ b/tests/Feature/TrustHostsMiddlewareTest.php @@ -2,13 +2,16 @@ use App\Http\Middleware\TrustHosts; use App\Models\InstanceSettings; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Once; -uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); +uses(RefreshDatabase::class); beforeEach(function () { - // Clear cache before each test to ensure isolation + // Clear cache and once() memoization to ensure isolation between tests Cache::forget('instance_settings_fqdn_host'); + Once::flush(); }); it('trusts the configured FQDN from InstanceSettings', function () { @@ -84,7 +87,7 @@ it('handles exception during InstanceSettings fetch', function () { // Drop the instance_settings table to simulate installation - \Schema::dropIfExists('instance_settings'); + Schema::dropIfExists('instance_settings'); $middleware = new TrustHosts($this->app); @@ -248,21 +251,144 @@ expect($response->status())->not->toBe(400); }); +it('populates cache on first request via handle() — no circular dependency', function () { + // Regression test: handle() used to check cache before hosts() could + // populate it, so host validation never activated. + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + // Clear cache to simulate cold start + Cache::forget('instance_settings_fqdn_host'); + + // Make a request — handle() should eagerly call hosts() to populate cache + $this->get('/', ['Host' => 'localhost']); + + // Cache should now be populated by the middleware + expect(Cache::get('instance_settings_fqdn_host'))->toBe('coolify.example.com'); +}); + +it('rejects host that is a superstring of trusted FQDN via suffix', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + // coolify.example.com.evil.com contains "coolify.example.com" as a substring — + // must NOT match. Literal hosts use exact comparison, not regex substring matching. + $response = $this->get('http://coolify.example.com.evil.com/'); + + expect($response->status())->toBe(400); +}); + +it('rejects host that is a superstring of trusted FQDN via prefix', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + // evil-coolify.example.com also contains the FQDN as a substring + $response = $this->get('http://evil-coolify.example.com/'); + + expect($response->status())->toBe(400); +}); + +it('rejects X-Forwarded-Host that is a superstring of trusted FQDN', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + $response = $this->get('/', [ + 'X-Forwarded-Host' => 'coolify.example.com.evil.com', + ]); + + expect($response->status())->toBe(400); +}); + +it('rejects host containing localhost as substring', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + // "evil-localhost" contains "localhost" — must not match the literal entry + $response = $this->get('http://evil-localhost/'); + + expect($response->status())->toBe(400); +}); + +it('allows subdomain of APP_URL via regex pattern', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + // sub.localhost should match ^(.+\.)?localhost$ from allSubdomainsOfApplicationUrl + $response = $this->get('http://sub.localhost/'); + + 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', - ]); + // Use full URL so Laravel's test client doesn't override Host with APP_URL + $response = $this->get('http://evil.com/'); // Should get 400 Bad Host for untrusted host expect($response->status())->toBe(400); }); +it('rejects requests with spoofed X-Forwarded-Host header', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + // Host header is trusted (localhost), but X-Forwarded-Host is spoofed. + // TrustHosts must reject this BEFORE TrustProxies can apply the spoofed host. + $response = $this->get('/', [ + 'X-Forwarded-Host' => 'evil.com', + ]); + + expect($response->status())->toBe(400); +}); + +it('allows legitimate X-Forwarded-Host from reverse proxy matching configured FQDN', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + // Legitimate request from Cloudflare/Traefik — X-Forwarded-Host matches the configured FQDN + $response = $this->get('/', [ + 'X-Forwarded-Host' => 'coolify.example.com', + ]); + + // Should NOT be rejected (would be 400 for Bad Host) + expect($response->status())->not->toBe(400); +}); + +it('allows X-Forwarded-Host with port matching configured FQDN', function () { + InstanceSettings::updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + ); + + // Some proxies include the port in X-Forwarded-Host + $response = $this->get('/', [ + 'X-Forwarded-Host' => 'coolify.example.com:443', + ]); + + // Should NOT be rejected — port is stripped before matching + expect($response->status())->not->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) @@ -352,9 +478,10 @@ ]); expect($response->status())->not->toBe(400); - // Test Stripe webhook + // Test Stripe webhook — may return 400 from Stripe signature validation, + // but the response should NOT contain "Bad Host" (host validation error) $response = $this->post('/webhooks/payments/stripe/events', [], [ 'Host' => 'stripe-webhook-forwarder.local', ]); - expect($response->status())->not->toBe(400); + expect($response->content())->not->toContain('Bad Host'); }); From 9b0088072cd29e39632b2546918db776fc8b371c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:12:30 +0100 Subject: [PATCH 3/8] refactor(docker): migrate service startup from Artisan commands to shell scripts Remove custom Artisan console commands (Horizon, Nightwatch, Scheduler) and refactor service startup logic directly into s6-overlay shell scripts. Check environment variables from .env instead of routing through Laravel config. Services now sleep when disabled instead of exiting immediately. Both development and production environments updated consistently. --- app/Console/Commands/Horizon.php | 23 ------------------- app/Console/Commands/Nightwatch.php | 22 ------------------ app/Console/Commands/Scheduler.php | 23 ------------------- .../etc/s6-overlay/s6-rc.d/horizon/run | 15 ++++++------ .../s6-overlay/s6-rc.d/nightwatch-agent/run | 15 ++++++------ .../s6-overlay/s6-rc.d/scheduler-worker/run | 16 ++++++------- .../etc/s6-overlay/s6-rc.d/horizon/run | 16 ++++++------- .../s6-overlay/s6-rc.d/nightwatch-agent/run | 16 ++++++------- .../s6-overlay/s6-rc.d/scheduler-worker/run | 17 +++++++------- 9 files changed, 46 insertions(+), 117 deletions(-) delete mode 100644 app/Console/Commands/Horizon.php delete mode 100644 app/Console/Commands/Nightwatch.php delete mode 100644 app/Console/Commands/Scheduler.php 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/docker/development/etc/s6-overlay/s6-rc.d/horizon/run b/docker/development/etc/s6-overlay/s6-rc.d/horizon/run index ada19b3a3..e6a17f858 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 "horizon: disabled, sleeping." + exec sleep infinity +fi +echo "horizon: 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..80b421c92 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 "nightwatch-agent: enabled, starting..." + exec php artisan nightwatch:agent +fi +echo "nightwatch-agent: 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..6c4d2be9f 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 "scheduler-worker: disabled, sleeping." + exec sleep infinity +fi +echo "scheduler-worker: 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..e6a17f858 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 "horizon: disabled, sleeping." + exec sleep infinity +fi + +echo "horizon: 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..80b421c92 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 "nightwatch-agent: enabled, starting..." + exec php artisan nightwatch:agent +fi + +echo "nightwatch-agent: 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..6c4d2be9f 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 "scheduler-worker: disabled, sleeping." + exec sleep infinity +fi + +echo "scheduler-worker: enabled, starting..." +exec php artisan schedule:work From af3826eac0e0fa0ac846302e888997ac725f865e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:14:01 +0100 Subject: [PATCH 4/8] feat(reset-password): add IPv6 support and header poisoning protection - Add support for bracketed IPv6 addresses when FQDN is not configured - Harden password reset URL generation against X-Forwarded-Host header poisoning - Add test coverage for IPv6-only configurations with malicious headers - Update imports and clean up exception syntax in shared helpers --- bootstrap/helpers/shared.php | 107 +++++++++++++------------ tests/Feature/ResetPasswordUrlTest.php | 40 +++++++++ 2 files changed, 97 insertions(+), 50 deletions(-) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 84472a07e..920d458b3 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,10 +485,10 @@ 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('/'); @@ -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/tests/Feature/ResetPasswordUrlTest.php b/tests/Feature/ResetPasswordUrlTest.php index 03d1103f0..65efbb5a1 100644 --- a/tests/Feature/ResetPasswordUrlTest.php +++ b/tests/Feature/ResetPasswordUrlTest.php @@ -103,6 +103,46 @@ function callResetUrl(ResetPassword $notification, $notifiable): string ->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('generates a valid route path in the reset URL', function () { InstanceSettings::updateOrCreate( ['id' => 0], From 638f1d37f1f9e3a53fbb8e7f5eaa5314ba34419d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:05:13 +0100 Subject: [PATCH 5/8] feat(subscription): add billing interval to price preview Extract and return the billing interval (month/year) from subscription pricing data in fetchPricePreview. Update the view to dynamically display the correct billing period based on the preview response instead of using static PHP logic. --- .../Stripe/UpdateSubscriptionQuantity.php | 5 +- .../livewire/subscription/actions.blade.php | 2 +- .../UpdateSubscriptionQuantityTest.php | 49 +++++++++++++++++-- 3 files changed, 50 insertions(+), 6 deletions(-) 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/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/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); From c28fbab36a1d090fcb0b90fb64757198a2fa0f84 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:05:36 +0100 Subject: [PATCH 6/8] style(docker): standardize service startup log message format Align log messages across all service startup scripts (horizon, nightwatch-agent, scheduler-worker) in both development and production environments to use a consistent " INFO " prefix format. --- docker/development/etc/s6-overlay/s6-rc.d/horizon/run | 4 ++-- .../development/etc/s6-overlay/s6-rc.d/nightwatch-agent/run | 4 ++-- .../development/etc/s6-overlay/s6-rc.d/scheduler-worker/run | 4 ++-- docker/production/etc/s6-overlay/s6-rc.d/horizon/run | 4 ++-- docker/production/etc/s6-overlay/s6-rc.d/nightwatch-agent/run | 4 ++-- docker/production/etc/s6-overlay/s6-rc.d/scheduler-worker/run | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) 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 e6a17f858..dbc472d06 100644 --- a/docker/development/etc/s6-overlay/s6-rc.d/horizon/run +++ b/docker/development/etc/s6-overlay/s6-rc.d/horizon/run @@ -3,9 +3,9 @@ cd /var/www/html if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then - echo "horizon: disabled, sleeping." + echo " INFO Horizon is disabled, sleeping." exec sleep infinity fi -echo "horizon: enabled, starting..." +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 80b421c92..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 @@ -3,9 +3,9 @@ cd /var/www/html if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then - echo "nightwatch-agent: enabled, starting..." + echo " INFO Nightwatch is enabled, starting..." exec php artisan nightwatch:agent fi -echo "nightwatch-agent: disabled, sleeping." +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 6c4d2be9f..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 @@ -3,9 +3,9 @@ cd /var/www/html if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then - echo "scheduler-worker: disabled, sleeping." + echo " INFO Scheduler is disabled, sleeping." exec sleep infinity fi -echo "scheduler-worker: enabled, starting..." +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 e6a17f858..dbc472d06 100644 --- a/docker/production/etc/s6-overlay/s6-rc.d/horizon/run +++ b/docker/production/etc/s6-overlay/s6-rc.d/horizon/run @@ -3,9 +3,9 @@ cd /var/www/html if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then - echo "horizon: disabled, sleeping." + echo " INFO Horizon is disabled, sleeping." exec sleep infinity fi -echo "horizon: enabled, starting..." +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 80b421c92..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 @@ -3,9 +3,9 @@ cd /var/www/html if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then - echo "nightwatch-agent: enabled, starting..." + echo " INFO Nightwatch is enabled, starting..." exec php artisan nightwatch:agent fi -echo "nightwatch-agent: disabled, sleeping." +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 6c4d2be9f..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 @@ -3,9 +3,9 @@ cd /var/www/html if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then - echo "scheduler-worker: disabled, sleeping." + echo " INFO Scheduler is disabled, sleeping." exec sleep infinity fi -echo "scheduler-worker: enabled, starting..." +echo " INFO Scheduler is enabled, starting..." exec php artisan schedule:work From bd9a8cee07ce3358eba2a539fe8aaa14022e48db Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:07:34 +0100 Subject: [PATCH 7/8] style(dev): standardize log message format with INFO/ERROR prefixes - Add INFO prefix to informational messages - Add ERROR prefix to error messages - Fix grammar and punctuation for consistency --- app/Console/Commands/Dev.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) 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(); From e396c70903f0f99d40ad78dd91c1fc591367b6fc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:12:48 +0100 Subject: [PATCH 8/8] refactor: simplify TrustHosts middleware and use APP_URL as base_url fallback - Delegate host validation to parent class instead of custom implementation - Update base_url() helper to use config('app.url') instead of url('/') - Add test for APP_URL fallback when no FQDN or public IPs configured - Remove dedicated TrustHostsMiddlewareTest (logic now tested via integration tests) --- app/Http/Middleware/TrustHosts.php | 59 +-- bootstrap/helpers/shared.php | 2 +- tests/Feature/ResetPasswordUrlTest.php | 24 + tests/Feature/TrustHostsMiddlewareTest.php | 487 --------------------- 4 files changed, 27 insertions(+), 545 deletions(-) delete mode 100644 tests/Feature/TrustHostsMiddlewareTest.php diff --git a/app/Http/Middleware/TrustHosts.php b/app/Http/Middleware/TrustHosts.php index d44b6057a..5fca583d9 100644 --- a/app/Http/Middleware/TrustHosts.php +++ b/app/Http/Middleware/TrustHosts.php @@ -30,44 +30,14 @@ public function handle(Request $request, $next) return $next($request); } - // Eagerly call hosts() to populate the cache (fixes circular dependency - // where handle() checked cache before hosts() could populate it via - // Cache::remember, causing host validation to never activate) - $this->hosts(); - // Skip host validation if no FQDN is configured (initial setup) $fqdnHost = Cache::get('instance_settings_fqdn_host'); if ($fqdnHost === '' || $fqdnHost === null) { return $next($request); } - // Validate the request host against trusted hosts explicitly. - // We check manually instead of relying on Symfony's lazy getHost() validation, - // which can be bypassed if getHost() was already called earlier in the pipeline. - $trustedHosts = array_filter($this->hosts()); - - // Collect all hosts to validate: the actual Host header, plus X-Forwarded-Host - // if present. We must check X-Forwarded-Host here because this middleware runs - // BEFORE TrustProxies, which would later apply X-Forwarded-Host to the request. - $hostsToValidate = [strtolower(trim($request->getHost()))]; - - $forwardedHost = $request->headers->get('X-Forwarded-Host'); - if ($forwardedHost) { - // X-Forwarded-Host can be a comma-separated list; validate the first (client-facing) value. - // Strip port if present (e.g. "coolify.example.com:443" → "coolify.example.com") - // to match the trusted hosts list which stores hostnames without ports. - $forwardedHostValue = strtolower(trim(explode(',', $forwardedHost)[0])); - $forwardedHostValue = preg_replace('/:\d+$/', '', $forwardedHostValue); - $hostsToValidate[] = $forwardedHostValue; - } - - foreach ($hostsToValidate as $hostToCheck) { - if (! $this->isHostTrusted($hostToCheck, $trustedHosts)) { - return response('Bad Host', 400); - } - } - - return $next($request); + // For all other routes, use parent's host validation + return parent::handle($request, $next); } /** @@ -130,29 +100,4 @@ public function hosts(): array return array_filter($trustedHosts); } - - /** - * Check if a host matches the trusted hosts list. - * - * Regex patterns (from allSubdomainsOfApplicationUrl, starting with ^) - * are matched with preg_match. Literal hostnames use exact comparison - * only — they are NOT passed to preg_match, which would treat unescaped - * dots as wildcards and match unanchored substrings. - * - * @param array $trustedHosts - */ - protected function isHostTrusted(string $host, array $trustedHosts): bool - { - foreach ($trustedHosts as $pattern) { - if (str_starts_with($pattern, '^')) { - if (@preg_match('{'.$pattern.'}i', $host)) { - return true; - } - } elseif ($host === $pattern) { - return true; - } - } - - return false; - } } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 920d458b3..cd773f6a9 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -491,7 +491,7 @@ function base_url(bool $withPort = true): string return "http://[$settings->public_ipv6]"; } - return url('/'); + return config('app.url'); } function isSubscribed() diff --git a/tests/Feature/ResetPasswordUrlTest.php b/tests/Feature/ResetPasswordUrlTest.php index 65efbb5a1..7e940fc71 100644 --- a/tests/Feature/ResetPasswordUrlTest.php +++ b/tests/Feature/ResetPasswordUrlTest.php @@ -143,6 +143,30 @@ function callResetUrl(ResetPassword $notification, $notifiable): string ->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], diff --git a/tests/Feature/TrustHostsMiddlewareTest.php b/tests/Feature/TrustHostsMiddlewareTest.php deleted file mode 100644 index a16698a6a..000000000 --- a/tests/Feature/TrustHostsMiddlewareTest.php +++ /dev/null @@ -1,487 +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('populates cache on first request via handle() — no circular dependency', function () { - // Regression test: handle() used to check cache before hosts() could - // populate it, so host validation never activated. - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - // Clear cache to simulate cold start - Cache::forget('instance_settings_fqdn_host'); - - // Make a request — handle() should eagerly call hosts() to populate cache - $this->get('/', ['Host' => 'localhost']); - - // Cache should now be populated by the middleware - expect(Cache::get('instance_settings_fqdn_host'))->toBe('coolify.example.com'); -}); - -it('rejects host that is a superstring of trusted FQDN via suffix', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - // coolify.example.com.evil.com contains "coolify.example.com" as a substring — - // must NOT match. Literal hosts use exact comparison, not regex substring matching. - $response = $this->get('http://coolify.example.com.evil.com/'); - - expect($response->status())->toBe(400); -}); - -it('rejects host that is a superstring of trusted FQDN via prefix', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - // evil-coolify.example.com also contains the FQDN as a substring - $response = $this->get('http://evil-coolify.example.com/'); - - expect($response->status())->toBe(400); -}); - -it('rejects X-Forwarded-Host that is a superstring of trusted FQDN', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - $response = $this->get('/', [ - 'X-Forwarded-Host' => 'coolify.example.com.evil.com', - ]); - - expect($response->status())->toBe(400); -}); - -it('rejects host containing localhost as substring', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - // "evil-localhost" contains "localhost" — must not match the literal entry - $response = $this->get('http://evil-localhost/'); - - expect($response->status())->toBe(400); -}); - -it('allows subdomain of APP_URL via regex pattern', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - // sub.localhost should match ^(.+\.)?localhost$ from allSubdomainsOfApplicationUrl - $response = $this->get('http://sub.localhost/'); - - 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'] - ); - - // Use full URL so Laravel's test client doesn't override Host with APP_URL - $response = $this->get('http://evil.com/'); - - // Should get 400 Bad Host for untrusted host - expect($response->status())->toBe(400); -}); - -it('rejects requests with spoofed X-Forwarded-Host header', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - // Host header is trusted (localhost), but X-Forwarded-Host is spoofed. - // TrustHosts must reject this BEFORE TrustProxies can apply the spoofed host. - $response = $this->get('/', [ - 'X-Forwarded-Host' => 'evil.com', - ]); - - expect($response->status())->toBe(400); -}); - -it('allows legitimate X-Forwarded-Host from reverse proxy matching configured FQDN', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - // Legitimate request from Cloudflare/Traefik — X-Forwarded-Host matches the configured FQDN - $response = $this->get('/', [ - 'X-Forwarded-Host' => 'coolify.example.com', - ]); - - // Should NOT be rejected (would be 400 for Bad Host) - expect($response->status())->not->toBe(400); -}); - -it('allows X-Forwarded-Host with port matching configured FQDN', function () { - InstanceSettings::updateOrCreate( - ['id' => 0], - ['fqdn' => 'https://coolify.example.com'] - ); - - // Some proxies include the port in X-Forwarded-Host - $response = $this->get('/', [ - 'X-Forwarded-Host' => 'coolify.example.com:443', - ]); - - // Should NOT be rejected — port is stripped before matching - expect($response->status())->not->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 — may return 400 from Stripe signature validation, - // but the response should NOT contain "Bad Host" (host validation error) - $response = $this->post('/webhooks/payments/stripe/events', [], [ - 'Host' => 'stripe-webhook-forwarder.local', - ]); - expect($response->content())->not->toContain('Bad Host'); -});