- 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>
487 lines
15 KiB
PHP
487 lines
15 KiB
PHP
<?php
|
|
|
|
use App\Http\Middleware\TrustHosts;
|
|
use App\Models\InstanceSettings;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Once;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
// 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 () {
|
|
// 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('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');
|
|
});
|