Give the three console guards one answer to "is this the console?"
The hostname guard, the network allowlist and the public-site switch each decided for themselves, by testing the path against admin/*. That works only while the console sits under /admin. It is also a trap: the moment the console moves to the root of its own hostname, PublicSiteGate stops recognising it, and with the public site hidden the console answers 503 to the very person trying to sign in and switch it back on. The guard does not fail loudly — it silently stops matching. AdminArea is now the single answer. It has two modes and no third: a console hostname is configured, in which case the console IS that host and answers at its root; or nothing is configured, in which case the console stays under /admin on any host exactly as before, so upgrading cannot lock anyone out of a system that was working. RestrictAdminHost gains the half it was missing. Binding console routes to a hostname does not stop the customer routes from answering there too, because they are registered without one — so the console's hostname would still serve portal pages wherever the paths did not collide. It now enforces both directions, with the endpoints both sides genuinely share written out as a list rather than inferred. Nothing changes yet for an installation with no ADMIN_HOSTS set, which is every development machine and every fresh checkout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/portal-design tested-20260727-0315-b860ce2
parent
30a80b6c15
commit
b860ce2e9b
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Where the operator console lives, and how to tell that a request is for it.
|
||||
*
|
||||
* Three guards need this answer — the hostname guard, the network allowlist and
|
||||
* the public-site switch — and until now each decided for itself by testing the
|
||||
* path against `admin/*`. That was survivable while the console sat under
|
||||
* /admin. It stops being survivable the moment the console moves to the root of
|
||||
* its own hostname: PublicSiteGate would no longer recognise it, and with the
|
||||
* public site hidden the console would answer 503 to the person trying to sign
|
||||
* in and turn it back on. That is the lock-out this class exists to prevent.
|
||||
*
|
||||
* Two modes, and only two:
|
||||
*
|
||||
* - A console hostname is configured. The console IS that host: it answers at
|
||||
* the root, and every request to that host is a console request.
|
||||
* - Nothing is configured (a fresh checkout, a development machine). The
|
||||
* console stays under /admin on any host, exactly as before, so upgrading
|
||||
* cannot lock anyone out of a system that was working.
|
||||
*
|
||||
* Read from config on every call rather than cached in a static: `config:cache`
|
||||
* already freezes the value at deploy time, and a second layer of staleness in
|
||||
* a long-lived worker is the kind of thing that is only discovered from the
|
||||
* wrong side of a locked door.
|
||||
*/
|
||||
final class AdminArea
|
||||
{
|
||||
/** The path prefix used when no console hostname is configured. */
|
||||
public const FALLBACK_PREFIX = 'admin';
|
||||
|
||||
/**
|
||||
* The single hostname the console answers on, or null when unconfigured.
|
||||
*
|
||||
* ADMIN_HOSTS may list several names — an operator who reaches the console
|
||||
* by IP as well as by name, for instance. The FIRST is the canonical one
|
||||
* and the one URLs are generated for; the rest keep working because
|
||||
* covers() accepts any of them. Dropping the alternates would remove
|
||||
* exactly the recovery paths someone locked out would reach for.
|
||||
*/
|
||||
public static function host(): ?string
|
||||
{
|
||||
return self::hosts()[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every hostname that reaches the console.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue