- 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 <noreply@anthropic.com>
123 lines
3.6 KiB
PHP
123 lines
3.6 KiB
PHP
<?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 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));
|
|
});
|