mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
refactor(http): remove the TrustHosts middleware that never checked the host
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1f4006ac49
commit
0448c6ca51
@@ -18,7 +18,6 @@ use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
|
||||
use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
||||
use App\Http\Middleware\RedirectIfAuthenticated;
|
||||
use App\Http\Middleware\TrimStrings;
|
||||
use App\Http\Middleware\TrustHosts;
|
||||
use App\Http\Middleware\TrustProxies;
|
||||
use App\Http\Middleware\ValidateSignature;
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
@@ -53,7 +52,6 @@ class Kernel extends HttpKernel
|
||||
protected $middleware = [
|
||||
// Outermost so Server-Timing includes the full middleware + app cost.
|
||||
AddServerTimingHeaders::class,
|
||||
TrustHosts::class,
|
||||
TrustProxies::class,
|
||||
HandleCors::class,
|
||||
PreventRequestsDuringMaintenance::class,
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Http\Middleware\TrustHosts as Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class TrustHosts extends Middleware
|
||||
{
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* Skip host validation for certain routes:
|
||||
* - API routes (use token-based authentication, not host validation)
|
||||
* - Webhook endpoints (use cryptographic signature validation)
|
||||
*/
|
||||
public function handle(Request $request, $next)
|
||||
{
|
||||
// Skip host validation for these routes
|
||||
if ($request->is(
|
||||
'api/*',
|
||||
'webhooks/*'
|
||||
)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the host patterns that should be trusted.
|
||||
*
|
||||
* @return array<int, string|null>
|
||||
*/
|
||||
public function hosts(): array
|
||||
{
|
||||
$trustedHosts = [];
|
||||
|
||||
// Trust the configured FQDN from InstanceSettings (cached to avoid DB query on every request)
|
||||
// Use empty string as sentinel value instead of null so negative results are cached
|
||||
$fqdnHost = Cache::remember('instance_settings_fqdn_host', 300, function () {
|
||||
try {
|
||||
$settings = InstanceSettings::get();
|
||||
if ($settings && $settings->fqdn) {
|
||||
$url = Url::fromString($settings->fqdn);
|
||||
$host = $url->getHost();
|
||||
|
||||
return $host ?: '';
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// If instance settings table doesn't exist yet (during installation),
|
||||
// return empty string (sentinel) so this result is cached
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
// Convert sentinel value back to null for consumption
|
||||
$fqdnHost = $fqdnHost !== '' ? $fqdnHost : null;
|
||||
|
||||
if ($fqdnHost) {
|
||||
$trustedHosts[] = $fqdnHost;
|
||||
}
|
||||
|
||||
// Trust the APP_URL host itself (not just subdomains)
|
||||
$appUrl = config('app.url');
|
||||
if ($appUrl) {
|
||||
try {
|
||||
$appUrlHost = parse_url($appUrl, PHP_URL_HOST);
|
||||
if ($appUrlHost && ! in_array($appUrlHost, $trustedHosts, true)) {
|
||||
$trustedHosts[] = $appUrlHost;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Trust all subdomains of APP_URL as fallback
|
||||
$trustedHosts[] = $this->allSubdomainsOfApplicationUrl();
|
||||
|
||||
// Always trust loopback addresses so local access works even when FQDN is configured
|
||||
foreach (['localhost', '127.0.0.1', '[::1]'] as $localHost) {
|
||||
if (! in_array($localHost, $trustedHosts, true)) {
|
||||
$trustedHosts[] = $localHost;
|
||||
}
|
||||
}
|
||||
|
||||
return array_filter($trustedHosts);
|
||||
}
|
||||
}
|
||||
@@ -111,11 +111,6 @@ class InstanceSettings extends Model
|
||||
static::updated(function ($settings) {
|
||||
// Clear once() cache so subsequent calls get fresh data
|
||||
Once::flush();
|
||||
|
||||
// Clear trusted hosts cache when FQDN changes
|
||||
if ($settings->wasChanged('fqdn')) {
|
||||
\Cache::forget('instance_settings_fqdn_host');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,11 @@ 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();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::forget('instance_settings_fqdn_host');
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['fqdn' => null]));
|
||||
// Ensure session.secure starts unconfigured for each test
|
||||
config(['session.secure' => null]);
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\TrustHosts;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Clear cache before each test to ensure isolation
|
||||
Cache::forget('instance_settings_fqdn_host');
|
||||
|
||||
// Laravel disables trusted host checks while running unit tests, so enable them
|
||||
// explicitly to exercise the real request-level host validation.
|
||||
$this->app->bind(TrustHosts::class, fn ($app) => new class($app) extends TrustHosts
|
||||
{
|
||||
protected function shouldSpecifyTrustedHosts(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Trusted host patterns are stored statically on the request class
|
||||
Request::setTrustedHosts([]);
|
||||
});
|
||||
|
||||
it('trusts the configured FQDN from InstanceSettings', function () {
|
||||
// Create instance settings with FQDN
|
||||
InstanceSettings::unguarded(fn () => 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::unguarded(fn () => 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::unguarded(fn () => 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::unguarded(fn () => 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::unguarded(fn () => 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::unguarded(fn () => 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::unguarded(fn () => 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::unguarded(fn () => 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::unguarded(fn () => 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::unguarded(fn () => 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::unguarded(fn () => 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::unguarded(fn () => 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('allows terminal auth requests from the local terminal server', function () {
|
||||
$response = $this->postJson('http://127.0.0.1:8080/terminal/auth');
|
||||
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('enforces host validation for terminal auth routes', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
));
|
||||
Cache::forget('instance_settings_fqdn_host');
|
||||
|
||||
// The test client derives the Host from the URL, so the untrusted host goes in the URL
|
||||
$response = $this->postJson('http://evil.com/terminal/auth/ips');
|
||||
|
||||
expect($response->status())->toBe(400);
|
||||
});
|
||||
|
||||
it('still enforces host validation for non-terminal routes', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
));
|
||||
|
||||
// Regular routes should still validate Host header
|
||||
$response = $this->get('http://evil.com/');
|
||||
|
||||
// Should get 400 Bad Host for untrusted host
|
||||
expect($response->status())->toBe(400);
|
||||
});
|
||||
|
||||
it('skips host validation for API routes', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
));
|
||||
|
||||
// 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('http://internal-lb.local/api/health');
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test v1 health check
|
||||
$response = $this->get('http://10.0.0.5/api/v1/health');
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test feedback endpoint
|
||||
$response = $this->post('http://mobile-app.local/api/feedback');
|
||||
expect($response->status())->not->toBe(400);
|
||||
});
|
||||
|
||||
it('trusts localhost when FQDN is configured', function () {
|
||||
InstanceSettings::unguarded(fn () => 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::unguarded(fn () => 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::unguarded(fn () => 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::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
));
|
||||
|
||||
$response = $this->get('http://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 () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||
['id' => 0],
|
||||
['fqdn' => 'https://coolify.example.com']
|
||||
));
|
||||
|
||||
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
|
||||
// and use cryptographic signature validation instead of host validation
|
||||
|
||||
// Test GitHub webhook
|
||||
$response = $this->post('http://github-webhook-proxy.local/webhooks/source/github/events');
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test GitLab webhook
|
||||
$response = $this->post('http://gitlab.example.com/webhooks/source/gitlab/events/manual');
|
||||
expect($response->status())->not->toBe(400);
|
||||
|
||||
// Test Stripe webhook
|
||||
// The Stripe controller itself answers 400 for an unsigned request, so assert the
|
||||
// request reached the controller instead of being rejected as an untrusted host.
|
||||
$response = $this->post('http://stripe-webhook-forwarder.local/webhooks/payments/stripe/events');
|
||||
expect($response->getContent())->toBe('Invalid signature.');
|
||||
});
|
||||
Reference in New Issue
Block a user