Compare commits

..

5 Commits

Author SHA1 Message Date
Constantin Graf
de13c07855 Fixed return type in TrustHosts 2026-08-06 17:49:52 +02:00
Constantin Graf
29a2e994cd Fixed phpstan error in TrustHosts middleware 2026-08-06 17:06:34 +02:00
Constantin Graf
f6d886b218 Refactored TrustHostsTest; Added return types to TrustHosts 2026-08-06 17:01:46 +02:00
Gregor Vostrak
80d98b30a1 add custom error handling for host mismatch; ensure TrustHosts runs
before TrustProxies
2026-08-06 17:01:46 +02:00
Gregor Vostrak
32f2f1431b add TrustHosts middleware with exemption for healthchecks 2026-08-06 17:01:46 +02:00
9 changed files with 432 additions and 7 deletions

View File

@@ -141,7 +141,7 @@ jobs:
${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4.5.2
uses: docker/login-action@v4
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
@@ -195,7 +195,7 @@ jobs:
merge-multiple: true
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4.5.2
uses: docker/login-action@v4
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}

View File

@@ -177,7 +177,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v4.5.2
uses: docker/login-action@v4
with:
registry: rg.fr-par.scw.cloud/solidtime
username: nologin

View File

@@ -117,13 +117,13 @@ jobs:
${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4.5.2
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v4.5.2
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -177,13 +177,13 @@ jobs:
merge-multiple: true
- name: "Login to Docker Hub"
uses: docker/login-action@v4.5.2
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR"
uses: docker/login-action@v4.5.2
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -6,7 +6,10 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Throwable;
class Handler extends ExceptionHandler
@@ -30,6 +33,29 @@ class Handler extends ExceptionHandler
$this->reportable(function (Throwable $e): void {
//
});
// A request on an untrusted host (see App\Http\Middleware\TrustHosts)
// otherwise renders as a bare "Bad request." 400. Show a message that
// says how to fix it instead. The framework has already converted the
// SuspiciousOperationException into a BadRequestHttpException by the time
// renderables run, so we match that and inspect the original.
$this->renderable(function (BadRequestHttpException $e, Request $request): ?Response {
$previous = $e->getPrevious();
if (! $previous instanceof SuspiciousOperationException
|| ! str_starts_with($previous->getMessage(), 'Untrusted Host')) {
return null; // any other bad request keeps the default response
}
$message = 'This hostname is not configured for this instance. '
.'Set APP_URL, or add the host to TRUSTED_HOSTS.';
if ($request->expectsJson()) {
return response()->json(['message' => $message], 400);
}
return response()->view('errors.untrusted-host', ['message' => $message], 400);
});
}
public function render($request, Throwable $e): Response|RedirectResponse

View File

@@ -15,6 +15,7 @@ use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\ShareInertiaData;
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;
@@ -47,6 +48,7 @@ class Kernel extends HttpKernel
*/
protected $middleware = [
ForceHttps::class,
TrustHosts::class,
TrustProxies::class,
HandleCors::class,
PreventRequestsDuringMaintenance::class,

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as BaseTrustHosts;
use Illuminate\Http\Request;
use Illuminate\Http\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(): array
{
/** @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
*/
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);
}
}

View File

@@ -75,6 +75,27 @@ return [
'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'),
'force_https' => (bool) env('APP_FORCE_HTTPS', false),

View File

@@ -0,0 +1,49 @@
{{-- Self-contained on purpose: this page is rendered for a request on an
untrusted host, so it must not call url()/route()/asset(), which would
re-trigger Host validation and throw again. --}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Untrusted host</title>
<style>
html, body { height: 100%; margin: 0; }
body {
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
color: #1f2937;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.card {
max-width: 32rem;
margin: 1.5rem;
padding: 2rem;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
h1 { margin: 0 0 0.75rem; font-size: 1.25rem; }
p { margin: 0; line-height: 1.6; color: #4b5563; }
code {
padding: 0.1rem 0.35rem;
background: #f3f4f6;
border-radius: 0.25rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
}
</style>
</head>
<body>
<div class="card">
<h1>Untrusted host</h1>
<p>
This hostname is not configured for this instance. Set
<code>APP_URL</code>, or add the host to <code>TRUSTED_HOSTS</code>.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Middleware;
use App\Http\Middleware\TrustHosts;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Http\Request;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response;
use Tests\TestCase;
#[CoversClass(TrustHosts::class)]
class TrustHostsTest extends TestCase
{
private const string 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 (Request $request): Response => new Response('passed'));
try {
dump($request->getHost());
return true;
} catch (\Throwable) {
return false;
}
}
public function test_canonical_host_is_accepted(): void
{
// Arrange
$request = Request::create(self::CANONICAL.'/login');
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertTrue($accepted);
}
public function test_subdomain_of_canonical_host_is_accepted(): void
{
// Arrange
$request = Request::create('https://team.app.example.com/login');
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertTrue($accepted);
}
public function test_declared_trusted_host_is_accepted(): void
{
// Arrange
config(['app.trusted_hosts' => ['box.tailnet.ts.net']]);
$request = Request::create('https://box.tailnet.ts.net/login');
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertTrue($accepted);
}
public function test_wildcard_trusted_host_matches_subdomains_only(): void
{
// Arrange
config(['app.trusted_hosts' => ['*.example.net']]);
$subdomainRequest = Request::create('https://foo.example.net/login');
$nestedSubdomainRequest = Request::create('https://a.b.example.net/login');
$apexRequest = Request::create('https://example.net/login');
$suffixInjectionRequest = Request::create('https://example.net.evil.com/login');
// Act
$subdomainAccepted = $this->accepts($subdomainRequest);
$nestedSubdomainAccepted = $this->accepts($nestedSubdomainRequest);
$apexAccepted = $this->accepts($apexRequest);
$suffixInjectionAccepted = $this->accepts($suffixInjectionRequest);
// Assert
$this->assertTrue($subdomainAccepted);
$this->assertTrue($nestedSubdomainAccepted);
$this->assertFalse($apexAccepted);
$this->assertFalse($suffixInjectionAccepted);
}
public function test_multiple_trusted_hosts_are_all_accepted(): void
{
// Arrange
config(['app.trusted_hosts' => [
'box.tailnet.ts.net',
'solidtime.internal',
'*.preview.example.com',
]]);
$tailnetRequest = Request::create('https://box.tailnet.ts.net/login');
$internalRequest = Request::create('https://solidtime.internal/login');
$previewRequest = Request::create('https://pr-42.preview.example.com/login');
$unlistedRequest = Request::create('https://evil.example.com/login');
// Act
$tailnetAccepted = $this->accepts($tailnetRequest);
$internalAccepted = $this->accepts($internalRequest);
$previewAccepted = $this->accepts($previewRequest);
$unlistedAccepted = $this->accepts($unlistedRequest);
// Assert
$this->assertTrue($tailnetAccepted);
$this->assertTrue($internalAccepted);
$this->assertTrue($previewAccepted);
$this->assertFalse($unlistedAccepted);
}
public function test_poisoned_host_is_rejected(): void
{
// Arrange
$request = Request::create('https://evil.example.com/login');
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertFalse($accepted);
}
public function test_poisoned_x_forwarded_host_is_rejected(): void
{
// Arrange
$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
);
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertFalse($accepted);
}
public function test_forwarded_host_from_trusted_proxy_is_accepted(): void
{
// Arrange
$request = Request::create('https://evil.example.com/login');
$request->headers->set('X-Forwarded-Host', 'app.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
);
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertTrue($accepted);
}
public function test_forwarded_host_from_non_trusted_proxy_is_rejected_if_host_is_allowed(): void
{
// Arrange
$request = Request::create('https://evil.example.com/login');
$request->headers->set('X-Forwarded-Host', 'app.example.com');
$request->setTrustedProxies(
['1.2.3.4/32'], // Not a trusted proxy
Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT
);
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertFalse($accepted);
}
public function test_health_check_endpoint_bypasses_host_validation(): void
{
// Arrange
$internalIpRequest = Request::create('https://0.0.0.0/health-check/up');
$localhostRequest = Request::create('http://localhost/health-check/up');
// Act
$internalIpAccepted = $this->accepts($internalIpRequest);
$localhostAccepted = $this->accepts($localhostRequest);
// Assert
$this->assertTrue($internalIpAccepted);
$this->assertTrue($localhostAccepted);
}
public function test_health_check_endpoint_clears_state_before_other_middleware_reads_the_host(): void
{
// Arrange
Request::setTrustedHosts(['^app\.example\.com$']);
// Act
$response = $this->get(self::CANONICAL.'/health-check/up', ['Host' => '0.0.0.0']);
// Assert
$response->assertSuccessful()
->assertExactJson(['success' => true]);
}
public function test_untrusted_host_renders_a_helpful_error(): void
{
// Arrange
$handler = app(ExceptionHandler::class);
$exception = new SuspiciousOperationException('Untrusted Host "evil.example.com".');
$request = Request::create('https://evil.example.com/login');
// Act
$response = $handler->render($request, $exception);
// Assert
$this->assertSame(400, $response->getStatusCode());
$this->assertStringContainsString('TRUSTED_HOSTS', (string) $response->getContent());
}
public function test_untrusted_host_returns_json_for_api_clients(): void
{
// Arrange
$handler = app(ExceptionHandler::class);
$exception = new SuspiciousOperationException('Untrusted Host "evil.example.com".');
$request = Request::create('https://evil.example.com/api/v1/users');
$request->headers->set('Accept', 'application/json');
// Act
$response = $handler->render($request, $exception);
// Assert
$this->assertSame(400, $response->getStatusCode());
$this->assertJson((string) $response->getContent());
$this->assertStringContainsString('TRUSTED_HOSTS', (string) $response->getContent());
}
}