mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-11 09:42:15 +01:00
Compare commits
5 Commits
dependabot
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de13c07855 | ||
|
|
29a2e994cd | ||
|
|
f6d886b218 | ||
|
|
80d98b30a1 | ||
|
|
32f2f1431b |
@@ -6,7 +6,10 @@ namespace App\Exceptions;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class Handler extends ExceptionHandler
|
class Handler extends ExceptionHandler
|
||||||
@@ -30,6 +33,29 @@ class Handler extends ExceptionHandler
|
|||||||
$this->reportable(function (Throwable $e): void {
|
$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
|
public function render($request, Throwable $e): Response|RedirectResponse
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -47,6 +48,7 @@ class Kernel extends HttpKernel
|
|||||||
*/
|
*/
|
||||||
protected $middleware = [
|
protected $middleware = [
|
||||||
ForceHttps::class,
|
ForceHttps::class,
|
||||||
|
TrustHosts::class,
|
||||||
TrustProxies::class,
|
TrustProxies::class,
|
||||||
HandleCors::class,
|
HandleCors::class,
|
||||||
PreventRequestsDuringMaintenance::class,
|
PreventRequestsDuringMaintenance::class,
|
||||||
|
|||||||
56
app/Http/Middleware/TrustHosts.php
Normal file
56
app/Http/Middleware/TrustHosts.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
49
resources/views/errors/untrusted-host.blade.php
Normal file
49
resources/views/errors/untrusted-host.blade.php
Normal 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>
|
||||||
271
tests/Unit/Middleware/TrustHostsTest.php
Normal file
271
tests/Unit/Middleware/TrustHostsTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user