feat(security): admin console can be pinned to non-public hostnames
The Proxmox fleet and the operator console must never be publicly reachable. The primary control is the reverse proxy, but nginx here is a catch-all (server_name _), so /admin was served on EVERY hostname — a proxy misconfiguration would expose it. ADMIN_HOSTS pins it; any other host gets 404 (not 403: a public domain must not disclose that a console exists). Prepended to the group instead of the admin route group on purpose: route middleware is reordered by Laravel's priority list, which runs first — a guest would then be redirected to /login and learn the console is there. Covered by a test for exactly that case. Empty ADMIN_HOSTS = unrestricted, so nobody is locked out by upgrading. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
db3ef1642b
commit
1f42c05648
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Serves the operator console only on the hostnames listed in ADMIN_HOSTS.
|
||||
*
|
||||
* Responds 404 (not 403) on any other host: a public domain must not even
|
||||
* disclose that an admin console exists here. Empty list = no restriction, so
|
||||
* this can be rolled out without locking anyone out.
|
||||
*
|
||||
* Host-based rather than IP-based on purpose: behind a reverse proxy without
|
||||
* TrustProxies, request->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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']);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
| Hostnames the operator console (/admin) may be served on.
|
||||
|
|
||||
| The Proxmox hosts and the admin console must never be publicly reachable.
|
||||
| The primary control belongs in the reverse proxy (only expose the public
|
||||
| hostnames); this is defence in depth INSIDE the app, so a proxy
|
||||
| misconfiguration cannot silently expose the console on a public domain.
|
||||
|
|
||||
| Comma-separated in ADMIN_HOSTS. EMPTY = no host restriction (dev default),
|
||||
| so nobody gets locked out by simply upgrading.
|
||||
|
|
||||
| Example (production):
|
||||
| ADMIN_HOSTS=admin.clupilot.com
|
||||
*/
|
||||
'hosts' => array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) env('ADMIN_HOSTS', ''))
|
||||
))),
|
||||
|
||||
];
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
// operator() helper is defined in RbacTest.
|
||||
|
||||
it('serves the console on any host when ADMIN_HOSTS is empty (default)', function () {
|
||||
config()->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();
|
||||
});
|
||||
Loading…
Reference in New Issue