diff --git a/.env.example b/.env.example index 35a56b9..3c3cb94 100644 --- a/.env.example +++ b/.env.example @@ -157,6 +157,15 @@ KUMA_TOTP= MONITORING_REQUIRED=false MONITORING_ATTEMPTS=2 +# ── Admin-Konsole: erlaubte Hostnamen ──────────────────────────────────── +# Die Proxmox-Hosts UND das Admin-Dashboard dürfen nie öffentlich erreichbar +# sein. Primär regelt das dein Reverse Proxy (nur www/app/ws/api öffentlich); +# dies ist die zweite Verteidigungslinie IN der App: auf jedem anderen Host +# antwortet /admin mit 404 (nicht 403 — verrät nicht, dass es die Konsole gibt). +# Komma-getrennt. LEER = keine Einschränkung (aktueller Dev-Stand). +# Beispiel: ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185 +ADMIN_HOSTS= + # ── Nextcloud blueprint template ───────────────────────────────────────── # NOT an env var: set the Proxmox template VMID per plan in # config/provisioning.php → plans.*.template_vmid (default 9000). diff --git a/app/Http/Middleware/RestrictAdminHost.php b/app/Http/Middleware/RestrictAdminHost.php new file mode 100644 index 0000000..59be3d8 --- /dev/null +++ b/app/Http/Middleware/RestrictAdminHost.php @@ -0,0 +1,41 @@ +ip() is the proxy's address, so an IP allowlist would + * be misleading. The real network-level control stays in the proxy. + */ +class RestrictAdminHost +{ + public function handle(Request $request, Closure $next): Response + { + // Path-scoped and prepended to the `web` group rather than attached to + // the admin route group: route middleware is reordered by Laravel's + // priority list, which puts `auth` first — a guest would then be + // redirected to /login and thereby learn the console exists. Running + // ahead of the whole route stack makes the 404 deterministic. + if (! $request->is('admin', 'admin/*')) { + return $next($request); + } + + $allowed = (array) config('admin_access.hosts', []); + + if ($allowed !== [] && ! in_array($request->getHost(), $allowed, true)) { + abort(404); + } + + return $next($request); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index a0be5d8..9386d44 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -18,6 +18,11 @@ return Application::configure(basePath: dirname(__DIR__)) 'customer.active' => \App\Http\Middleware\EnsureCustomerActive::class, ]); + // Runs before the whole route stack (incl. `auth`), so /admin on a public + // hostname 404s instead of redirecting to /login and thereby revealing + // that an operator console is hosted here. Self-scoped to /admin*. + $middleware->prependToGroup('web', \App\Http\Middleware\RestrictAdminHost::class); + // Stripe posts server-to-server with its own signature (no CSRF token). $middleware->validateCsrfTokens(except: ['webhooks/stripe']); }) diff --git a/config/admin_access.php b/config/admin_access.php new file mode 100644 index 0000000..8183074 --- /dev/null +++ b/config/admin_access.php @@ -0,0 +1,24 @@ + array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('ADMIN_HOSTS', '')) + ))), + +]; diff --git a/routes/web.php b/routes/web.php index dc7e868..be5ee5c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -58,6 +58,8 @@ Route::middleware(['auth', 'customer.active'])->group(function () { }); // Admin / operator console — dark theme, gated to is_admin users (R1/R2, R13). +// Host restriction runs ahead of this stack (RestrictAdminHost, prepended to the +// `web` group in bootstrap/app.php): on a public hostname /admin 404s outright. Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () { Route::get('/', Admin\Overview::class)->name('overview'); Route::get('/customers', Admin\Customers::class)->name('customers'); diff --git a/tests/Feature/Admin/AdminHostRestrictionTest.php b/tests/Feature/Admin/AdminHostRestrictionTest.php new file mode 100644 index 0000000..21cf069 --- /dev/null +++ b/tests/Feature/Admin/AdminHostRestrictionTest.php @@ -0,0 +1,53 @@ +set('admin_access.hosts', []); + + $this->actingAs(operator('Owner')) + ->get('http://app.dev.clupilot.com/admin') + ->assertOk(); +}); + +it('404s the console on a hostname that is not allowed', function () { + config()->set('admin_access.hosts', ['admin.dev.clupilot.com']); + + // A PUBLIC hostname must not even hint that a console exists here. + $this->actingAs(operator('Owner')) + ->get('http://app.dev.clupilot.com/admin') + ->assertNotFound(); + + $this->actingAs(operator('Owner')) + ->get('http://www.dev.clupilot.com/admin/hosts') + ->assertNotFound(); +}); + +it('serves the console on an allowed hostname', function () { + config()->set('admin_access.hosts', ['admin.dev.clupilot.com', '10.10.90.185']); + + $this->actingAs(operator('Owner')) + ->get('http://admin.dev.clupilot.com/admin') + ->assertOk(); + + $this->actingAs(operator('Owner')) + ->get('http://10.10.90.185/admin') + ->assertOk(); +}); + +it('404s before revealing a login redirect to guests on a public host', function () { + config()->set('admin_access.hosts', ['admin.dev.clupilot.com']); + + // Not a redirect to /login — that would confirm the console's existence. + $this->get('http://www.dev.clupilot.com/admin')->assertNotFound(); +}); + +it('keeps the customer portal reachable on public hosts', function () { + config()->set('admin_access.hosts', ['admin.dev.clupilot.com']); + $user = User::factory()->create(); + + $this->actingAs($user)->get('http://app.dev.clupilot.com/dashboard')->assertOk(); + $this->get('http://www.dev.clupilot.com/')->assertOk(); +});