mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-13 10:42:16 +01:00
Compare commits
6 Commits
dependabot
...
blacksmith
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ad37d35d8 | ||
|
|
de13c07855 | ||
|
|
29a2e994cd | ||
|
|
f6d886b218 | ||
|
|
80d98b30a1 | ||
|
|
32f2f1431b |
4
.github/workflows/build-onpremise.yml
vendored
4
.github/workflows/build-onpremise.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- runs-on: "ubuntu-24.04-arm"
|
- runs-on: "blacksmith-4vcpu-ubuntu-2404-arm"
|
||||||
platform: "linux/arm64"
|
platform: "linux/arm64"
|
||||||
- runs-on: "ubuntu-24.04"
|
- runs-on: "ubuntu-24.04"
|
||||||
platform: "linux/amd64"
|
platform: "linux/amd64"
|
||||||
@@ -182,7 +182,7 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
merge:
|
merge:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
needs:
|
needs:
|
||||||
- build
|
- build
|
||||||
|
|||||||
2
.github/workflows/build-private.yml
vendored
2
.github/workflows/build-private.yml
vendored
@@ -18,7 +18,7 @@ permissions:
|
|||||||
name: Build - Private
|
name: Build - Private
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/build-public.yml
vendored
4
.github/workflows/build-public.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- runs-on: "ubuntu-24.04-arm"
|
- runs-on: "blacksmith-4vcpu-ubuntu-2404-arm"
|
||||||
platform: "linux/arm64"
|
platform: "linux/arm64"
|
||||||
- runs-on: "ubuntu-24.04"
|
- runs-on: "ubuntu-24.04"
|
||||||
platform: "linux/amd64"
|
platform: "linux/amd64"
|
||||||
@@ -164,7 +164,7 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
merge:
|
merge:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
needs:
|
needs:
|
||||||
- build
|
- build
|
||||||
|
|||||||
2
.github/workflows/generate-api-docs.yml
vendored
2
.github/workflows/generate-api-docs.yml
vendored
@@ -8,7 +8,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
api_docs:
|
api_docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|||||||
2
.github/workflows/npm-build.yml
vendored
2
.github/workflows/npm-build.yml
vendored
@@ -6,7 +6,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
2
.github/workflows/npm-format-check.yml
vendored
2
.github/workflows/npm-format-check.yml
vendored
@@ -4,7 +4,7 @@ on: [push]
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
format-check:
|
format-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
2
.github/workflows/npm-lint.yml
vendored
2
.github/workflows/npm-lint.yml
vendored
@@ -6,7 +6,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
2
.github/workflows/npm-publish-api.yml
vendored
2
.github/workflows/npm-publish-api.yml
vendored
@@ -5,7 +5,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|||||||
2
.github/workflows/npm-publish-ui.yml
vendored
2
.github/workflows/npm-publish-ui.yml
vendored
@@ -5,7 +5,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|||||||
2
.github/workflows/npm-test-unit.yml
vendored
2
.github/workflows/npm-test-unit.yml
vendored
@@ -6,7 +6,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
env:
|
env:
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
|
|||||||
2
.github/workflows/npm-typecheck.yml
vendored
2
.github/workflows/npm-typecheck.yml
vendored
@@ -5,7 +5,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
2
.github/workflows/phpstan.yml
vendored
2
.github/workflows/phpstan.yml
vendored
@@ -4,7 +4,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
phpstan:
|
phpstan:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
2
.github/workflows/phpunit.yml
vendored
2
.github/workflows/phpunit.yml
vendored
@@ -4,7 +4,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
phpunit:
|
phpunit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
2
.github/workflows/pint.yml
vendored
2
.github/workflows/pint.yml
vendored
@@ -4,7 +4,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
pint:
|
pint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
4
.github/workflows/playwright.yml
vendored
4
.github/workflows/playwright.yml
vendored
@@ -4,7 +4,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -96,7 +96,7 @@ jobs:
|
|||||||
merge-reports:
|
merge-reports:
|
||||||
if: always()
|
if: always()
|
||||||
needs: [test]
|
needs: [test]
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout code"
|
- name: "Checkout code"
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|||||||
2
.github/workflows/vouch-check-pr.yml
vendored
2
.github/workflows/vouch-check-pr.yml
vendored
@@ -12,7 +12,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
if: >-
|
if: >-
|
||||||
github.event_name == 'pull_request_target' ||
|
github.event_name == 'pull_request_target' ||
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
manage:
|
manage:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
2
.github/workflows/vouch-manage-by-issue.yml
vendored
2
.github/workflows/vouch-manage-by-issue.yml
vendored
@@ -18,7 +18,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
manage:
|
manage:
|
||||||
runs-on: ubuntu-latest
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -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