fix: use server-side config for password reset URL generation (#9193)

This commit is contained in:
Andras Bacsai 2026-03-28 12:20:42 +01:00 committed by GitHub
commit 98569e4edb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 250 additions and 413 deletions

View file

@ -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;
}
}

View file

@ -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)

View file

@ -0,0 +1,187 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use App\Notifications\TransactionalEmails\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
Cache::forget('instance_settings_fqdn_host');
Once::flush();
});
function callResetUrl(ResetPassword $notification, $notifiable): string
{
$method = new ReflectionMethod($notification, 'resetUrl');
return $method->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));
});

View file

@ -1,360 +0,0 @@
<?php
use App\Http\Middleware\TrustHosts;
use App\Models\InstanceSettings;
use Illuminate\Support\Facades\Cache;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
// Clear cache before each test to ensure isolation
Cache::forget('instance_settings_fqdn_host');
});
it('trusts the configured FQDN from InstanceSettings', function () {
// Create instance settings with FQDN
InstanceSettings::updateOrCreate(
['id' => 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);
});