diff --git a/app/Http/Middleware/PublicSiteGate.php b/app/Http/Middleware/PublicSiteGate.php index 23f38ca..85e6b00 100644 --- a/app/Http/Middleware/PublicSiteGate.php +++ b/app/Http/Middleware/PublicSiteGate.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Support\AdminArea; use App\Support\Settings; use Closure; use Illuminate\Http\Request; @@ -40,11 +41,18 @@ class PublicSiteGate * Host header, which the caller chooses, and one forged header would unhide * the entire portal. */ - private const ALWAYS_ALLOWED = ['admin', 'admin/*', 'webhooks/*', 'up', 'robots.txt']; + private const ALWAYS_ALLOWED = ['webhooks/*', 'up', 'robots.txt']; public function handle(Request $request, Closure $next): Response { - if ($request->is(...self::ALWAYS_ALLOWED) || Settings::bool('site.public', true)) { + // The console is exempt wherever it currently lives. This used to be + // the literal paths 'admin' and 'admin/*', which stops matching the + // moment the console moves to the root of its own hostname — and the + // result would be 503 on the sign-in page of the console, which is the + // only place the switch can be turned back on. Asking AdminArea keeps + // the exemption attached to the console rather than to a path that + // happened to be true once. + if (AdminArea::isConsole($request) || $request->is(...self::ALWAYS_ALLOWED) || Settings::bool('site.public', true)) { return $next($request); } diff --git a/app/Http/Middleware/RestrictAdminHost.php b/app/Http/Middleware/RestrictAdminHost.php index 33546f0..c9d13a0 100644 --- a/app/Http/Middleware/RestrictAdminHost.php +++ b/app/Http/Middleware/RestrictAdminHost.php @@ -2,47 +2,87 @@ namespace App\Http\Middleware; +use App\Support\AdminArea; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; /** - * Serves the operator console only on the hostnames listed in ADMIN_HOSTS. + * The console answers only on its own hostname — and that hostname answers + * only the console. * - * 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. + * Both halves matter. The first keeps the console off the public domains. The + * second is what actually separates the two products: binding the console + * routes to a hostname does not stop the CUSTOMER routes from answering there + * as well, because they are registered without a hostname. Without this, + * app.clupilot.com and admin.clupilot.com would still serve each other's pages + * wherever the paths did not collide. * - * 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. + * 404 in both directions, never 403: a stranger must not learn that a console + * lives here, and an operator on the wrong host should see the same nothing a + * stranger does. + * + * A handful of endpoints are shared by both sides and must answer on either + * host — Livewire's own endpoints and the authentication actions. They are + * listed explicitly below, because the failure mode of getting that list wrong + * is "the console is broken" rather than "the rule is wrong", and a list you + * can read is easier to correct than a rule you have to infer. */ class RestrictAdminHost { + /** + * Endpoints both the console and the portal need, on whichever host the + * caller is currently on. + * + * Livewire's component endpoint is guarded separately and more strictly: + * the console's own middleware is registered as persistent, so an action + * posted to /livewire/update is re-checked against the component's real + * route. Letting the path through here does not let anything through there. + */ + private const SHARED = [ + 'livewire/*', + 'login', + 'logout', + 'two-factor-challenge', + 'up', + ]; + 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/*')) { + // Not host-bound (development, a fresh checkout): the console keeps its + // /admin prefix on any host and nothing is separated. Upgrading must + // not lock anyone out of a system that was working. + if (! AdminArea::isHostBound()) { return $next($request); } - // Lowercased here as well as in the config: DNS names are - // case-insensitive and getHost() always returns lowercase, so a - // capitalised ADMIN_HOSTS entry must not lock operators out — whatever - // route the value took into the config (env, cached config, runtime set). - $allowed = array_map( - fn ($host) => strtolower((string) $host), - (array) config('admin_access.hosts', []), - ); + $isConsoleRoute = $this->isConsoleRoute($request); + $onConsoleHost = AdminArea::covers($request->getHost()); - if ($allowed !== [] && ! in_array($request->getHost(), $allowed, true)) { + // The console, reached through a hostname that is not the console's. + if ($isConsoleRoute && ! $onConsoleHost) { + abort(404); + } + + // The portal or the public site, reached through the console's hostname. + if ($onConsoleHost && ! $isConsoleRoute && ! $request->is(...self::SHARED)) { abort(404); } return $next($request); } + + /** + * Console routes are the ones named `admin.*`. + * + * By name rather than by path: the path is exactly what changes between + * host-bound and fallback mode, and a rule written against it would be + * wrong in one of them. + */ + private function isConsoleRoute(Request $request): bool + { + $name = $request->route()?->getName(); + + return $name !== null && str_starts_with($name, 'admin.'); + } } diff --git a/app/Http/Middleware/RestrictConsoleNetwork.php b/app/Http/Middleware/RestrictConsoleNetwork.php index b3d70c4..bbb2377 100644 --- a/app/Http/Middleware/RestrictConsoleNetwork.php +++ b/app/Http/Middleware/RestrictConsoleNetwork.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use App\Support\AdminArea; use App\Support\Settings; use Closure; use Illuminate\Http\Request; @@ -32,11 +33,11 @@ class RestrictConsoleNetwork { public function handle(Request $request, Closure $next): Response { - // Path-scoped and prepended to the `web` group for the same reason - // RestrictAdminHost is: route middleware gets reordered behind `auth`, - // and a guest would then be redirected to /login and thereby learn the - // console exists. - if (! $request->is('admin', 'admin/*')) { + // Scoped to the console, wherever the console currently is. Asking + // AdminArea rather than testing the path: once the console moves to the + // root of its own hostname there is no `/admin` left to match, and a + // guard that silently stops matching is worse than no guard. + if (! AdminArea::isConsole($request)) { return $next($request); } diff --git a/app/Support/AdminArea.php b/app/Support/AdminArea.php new file mode 100644 index 0000000..6354c60 --- /dev/null +++ b/app/Support/AdminArea.php @@ -0,0 +1,105 @@ + + */ + public static function hosts(): array + { + return array_values(array_filter(array_map( + fn ($host) => strtolower(trim((string) $host)), + (array) config('admin_access.hosts', []), + ))); + } + + /** Whether the console is bound to a hostname at all. */ + public static function isHostBound(): bool + { + return self::hosts() !== []; + } + + /** + * Is this request addressed to the console? + * + * The one question the guards ask. When the console is host-bound the whole + * host is the console — including its sign-in page, which is precisely what + * the path test used to miss. + */ + public static function isConsole(Request $request): bool + { + return self::isHostBound() + ? self::covers($request->getHost()) + : $request->is(self::FALLBACK_PREFIX, self::FALLBACK_PREFIX.'/*'); + } + + /** Is this hostname one the console answers on? */ + public static function covers(string $host): bool + { + return in_array(strtolower($host), self::hosts(), true); + } + + /** + * The path prefix console routes are registered under. + * + * Empty when host-bound (the console is at the root of its own hostname), + * `admin` otherwise. + */ + public static function prefix(): string + { + return self::isHostBound() ? '' : self::FALLBACK_PREFIX; + } + + /** Where the console's own front page lives, as a path. */ + public static function home(): string + { + return '/'.self::prefix(); + } +}