mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
add TrustHosts middleware with exemption for healthchecks
This commit is contained in:
@@ -15,6 +15,7 @@ use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
|||||||
use App\Http\Middleware\RedirectIfAuthenticated;
|
use App\Http\Middleware\RedirectIfAuthenticated;
|
||||||
use App\Http\Middleware\ShareInertiaData;
|
use App\Http\Middleware\ShareInertiaData;
|
||||||
use App\Http\Middleware\TrimStrings;
|
use App\Http\Middleware\TrimStrings;
|
||||||
|
use App\Http\Middleware\TrustHosts;
|
||||||
use App\Http\Middleware\TrustProxies;
|
use App\Http\Middleware\TrustProxies;
|
||||||
use App\Http\Middleware\ValidateSignature;
|
use App\Http\Middleware\ValidateSignature;
|
||||||
use App\Http\Middleware\VerifyCsrfToken;
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
@@ -48,6 +49,7 @@ class Kernel extends HttpKernel
|
|||||||
protected $middleware = [
|
protected $middleware = [
|
||||||
ForceHttps::class,
|
ForceHttps::class,
|
||||||
TrustProxies::class,
|
TrustProxies::class,
|
||||||
|
TrustHosts::class,
|
||||||
HandleCors::class,
|
HandleCors::class,
|
||||||
PreventRequestsDuringMaintenance::class,
|
PreventRequestsDuringMaintenance::class,
|
||||||
ValidatePostSize::class,
|
ValidatePostSize::class,
|
||||||
|
|||||||
57
app/Http/Middleware/TrustHosts.php
Normal file
57
app/Http/Middleware/TrustHosts.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Middleware\TrustHosts as BaseTrustHosts;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects requests whose Host is not trusted, preventing Host-header poisoning of
|
||||||
|
* generated URLs (password reset, SSO callback, invitations). Trusted = the
|
||||||
|
* APP_URL host and its subdomains, plus TRUSTED_HOSTS (for multi-host access such
|
||||||
|
* as a Tailscale name). Health-check endpoints are exempt (probed by IP).
|
||||||
|
*/
|
||||||
|
class TrustHosts extends BaseTrustHosts
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, string|null>
|
||||||
|
*/
|
||||||
|
public function hosts()
|
||||||
|
{
|
||||||
|
/** @var array<int, string> $configured */
|
||||||
|
$configured = config('app.trusted_hosts', []);
|
||||||
|
|
||||||
|
$extra = array_map(function (string $host): string {
|
||||||
|
$host = trim($host);
|
||||||
|
|
||||||
|
// "*.example.com" matches any subdomain, not the apex.
|
||||||
|
if (str_starts_with($host, '*.')) {
|
||||||
|
return '^.+\.'.preg_quote(substr($host, 2), '#').'$';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '^'.preg_quote($host, '#').'$';
|
||||||
|
}, $configured);
|
||||||
|
|
||||||
|
return array_merge([$this->allSubdomainsOfApplicationUrl()], $extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Closure(Request): Response $next
|
||||||
|
* @return Response
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, $next)
|
||||||
|
{
|
||||||
|
// Exempt health checks (probed by IP). Also reset the trusted hosts,
|
||||||
|
// since Octane leaks the static state across requests.
|
||||||
|
if ($request->is('health-check/*')) {
|
||||||
|
Request::setTrustedHosts([]);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::handle($request, $next);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,27 @@ return [
|
|||||||
|
|
||||||
'url' => env('APP_URL', 'http://localhost'),
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Trusted Hosts
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Additional hostnames (besides the APP_URL host and its subdomains) that
|
||||||
|
| the application is allowed to respond on. This is needed for multi-host
|
||||||
|
| setups, e.g. reaching the instance over both a public domain and a
|
||||||
|
| Tailscale name. A request arriving on any host that is neither APP_URL
|
||||||
|
| (nor a subdomain of it) nor listed here is rejected, which prevents
|
||||||
|
| Host-header poisoning of password reset and other out-of-band links.
|
||||||
|
|
|
||||||
|
| See App\Http\Middleware\TrustHosts.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'trusted_hosts' => array_values(array_filter(array_map(
|
||||||
|
'trim',
|
||||||
|
explode(',', (string) env('TRUSTED_HOSTS', ''))
|
||||||
|
))),
|
||||||
|
|
||||||
'asset_url' => env('ASSET_URL'),
|
'asset_url' => env('ASSET_URL'),
|
||||||
|
|
||||||
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
|
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
|
||||||
|
|||||||
124
tests/Feature/TrustHostsTest.php
Normal file
124
tests/Feature/TrustHostsTest.php
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\TrustHosts;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TrustHostsTest extends TestCase
|
||||||
|
{
|
||||||
|
private const CANONICAL = 'https://app.example.com';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config(['app.url' => self::CANONICAL]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Request::setTrustedHosts([]); // don't leak static state between tests
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real middleware, with only the environment gate forced on (it
|
||||||
|
* self-exempts in the testing environment).
|
||||||
|
*/
|
||||||
|
private function middleware(): TrustHosts
|
||||||
|
{
|
||||||
|
return new class($this->app) extends TrustHosts
|
||||||
|
{
|
||||||
|
protected function shouldSpecifyTrustedHosts(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function accepts(Request $request): bool
|
||||||
|
{
|
||||||
|
$this->middleware()->handle($request, fn (): string => 'passed');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$request->getHost();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_canonical_host_is_accepted(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue($this->accepts(Request::create(self::CANONICAL.'/login')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_subdomain_of_canonical_host_is_accepted(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue($this->accepts(Request::create('https://team.app.example.com/login')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_declared_trusted_host_is_accepted(): void
|
||||||
|
{
|
||||||
|
config(['app.trusted_hosts' => ['box.tailnet.ts.net']]);
|
||||||
|
|
||||||
|
$this->assertTrue($this->accepts(Request::create('https://box.tailnet.ts.net/login')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_wildcard_trusted_host_matches_subdomains_only(): void
|
||||||
|
{
|
||||||
|
config(['app.trusted_hosts' => ['*.example.net']]);
|
||||||
|
|
||||||
|
$this->assertTrue($this->accepts(Request::create('https://foo.example.net/login')));
|
||||||
|
$this->assertTrue($this->accepts(Request::create('https://a.b.example.net/login')));
|
||||||
|
// The apex is not matched by the wildcard, and suffix-injection is rejected.
|
||||||
|
$this->assertFalse($this->accepts(Request::create('https://example.net/login')));
|
||||||
|
$this->assertFalse($this->accepts(Request::create('https://example.net.evil.com/login')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_multiple_trusted_hosts_are_all_accepted(): void
|
||||||
|
{
|
||||||
|
config(['app.trusted_hosts' => [
|
||||||
|
'box.tailnet.ts.net',
|
||||||
|
'solidtime.internal',
|
||||||
|
'*.preview.example.com',
|
||||||
|
]]);
|
||||||
|
|
||||||
|
$this->assertTrue($this->accepts(Request::create('https://box.tailnet.ts.net/login')));
|
||||||
|
$this->assertTrue($this->accepts(Request::create('https://solidtime.internal/login')));
|
||||||
|
$this->assertTrue($this->accepts(Request::create('https://pr-42.preview.example.com/login')));
|
||||||
|
// A host that is not listed is still rejected.
|
||||||
|
$this->assertFalse($this->accepts(Request::create('https://evil.example.com/login')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_poisoned_host_is_rejected(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse($this->accepts(Request::create('https://evil.example.com/login')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_poisoned_x_forwarded_host_is_rejected(): void
|
||||||
|
{
|
||||||
|
$request = Request::create(self::CANONICAL.'/login');
|
||||||
|
$request->headers->set('X-Forwarded-Host', 'evil.example.com');
|
||||||
|
$request->setTrustedProxies(
|
||||||
|
['0.0.0.0/0', '2000::/3'],
|
||||||
|
Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
|
||||||
|
Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT
|
||||||
|
);
|
||||||
|
|
||||||
|
// getHost() now resolves to the poisoned X-Forwarded-Host value.
|
||||||
|
$this->assertFalse($this->accepts($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_health_check_endpoint_bypasses_host_validation(): void
|
||||||
|
{
|
||||||
|
// Probed on internal hosts/IPs; must not be rejected.
|
||||||
|
$this->assertTrue($this->accepts(Request::create('https://0.0.0.0/health-check/up')));
|
||||||
|
$this->assertTrue($this->accepts(Request::create('http://localhost/health-check/up')));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user