mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 17:44:36 +08:00
TrustHosts skipped its check while the cache key instance_settings_fqdn_host
was empty, and only the skipped check filled that key. So since e1fe58639
(October 2025) no release restricted the Host header. Turning it on now would
lock out users who reach Coolify by server IP or another name when the domain
or proxy is broken.
Links in emails and notifications do not use the Host header: base_url()
uses the configured FQDN or the server IP (see ResetPasswordUrlTest). So
remove the middleware, its cache key and its tests. TrustProxies is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
5.2 KiB
PHP
167 lines
5.2 KiB
PHP
<?php
|
|
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\User;
|
|
use App\Notifications\TransactionalEmails\ResetPassword;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Once;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Once::flush();
|
|
});
|
|
|
|
function setResetUrlInstanceSettings(array $attributes): void
|
|
{
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], $attributes));
|
|
}
|
|
|
|
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 () {
|
|
setResetUrlInstanceSettings(['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 () {
|
|
setResetUrlInstanceSettings(['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 () {
|
|
setResetUrlInstanceSettings(['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 () {
|
|
setResetUrlInstanceSettings(['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 () {
|
|
setResetUrlInstanceSettings(['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 () {
|
|
setResetUrlInstanceSettings(['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 () {
|
|
setResetUrlInstanceSettings(['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 () {
|
|
setResetUrlInstanceSettings(['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));
|
|
});
|