135 lines
4.6 KiB
PHP
135 lines
4.6 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
use App\Support\AdminArea;
|
|
use App\Support\Settings;
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
use Symfony\Component\HttpFoundation\IpUtils;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* Who may reach the operator console, by network address.
|
|
*
|
|
* RestrictAdminHost answers "under which NAME does the console respond" — and a
|
|
* Host header is chosen by the caller, so it can never answer "who is asking".
|
|
* This one can: the client address behind a trusted proxy is not something the
|
|
* client gets to pick.
|
|
*
|
|
* The management VPN always counts. Beyond that the owner keeps a list — an
|
|
* office line, a home connection — so being away from the VPN does not mean
|
|
* being locked out. That list lives in the console rather than in a proxy
|
|
* config file, because the person who needs to change it is the person sitting
|
|
* in the console.
|
|
*
|
|
* 404, never 403: a stranger must not learn that a console lives here.
|
|
*
|
|
* This is not a substitute for a firewall. It runs after PHP has started, so it
|
|
* bounds who can USE the console, not who can make the server work. Where a
|
|
* network-level ACL is also possible, both belong there.
|
|
*/
|
|
class RestrictConsoleNetwork
|
|
{
|
|
public function handle(Request $request, Closure $next): Response
|
|
{
|
|
// 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);
|
|
}
|
|
|
|
if (! self::isRestricted()) {
|
|
return $next($request);
|
|
}
|
|
|
|
if (! self::allows((string) $request->ip())) {
|
|
abort(404);
|
|
}
|
|
|
|
return $next($request);
|
|
}
|
|
|
|
/** Whether the owner has switched the restriction on at all. */
|
|
public static function isRestricted(): bool
|
|
{
|
|
return Settings::bool('console.network_restricted', false);
|
|
}
|
|
|
|
/**
|
|
* Every network that may reach the console.
|
|
*
|
|
* The VPN is always in it and is not removable — it is the one path that
|
|
* exists independently of whatever the owner has typed into the list, and
|
|
* without it a bad entry would leave nobody able to fix the entry.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public static function allowedRanges(): array
|
|
{
|
|
$vpn = (array) config('admin_access.trusted_ranges', []);
|
|
$own = (array) Settings::get('console.allowed_ips', []);
|
|
|
|
return array_values(array_unique(array_filter(array_merge(
|
|
$vpn,
|
|
array_map('trim', array_map('strval', $own)),
|
|
))));
|
|
}
|
|
|
|
public static function allows(string $ip): bool
|
|
{
|
|
return self::covers($ip, self::allowedRanges());
|
|
}
|
|
|
|
/**
|
|
* Would this address still get in, given that list?
|
|
*
|
|
* Split out so the "you are about to lock yourself out" decision can be
|
|
* made — and tested — without a request: it is the one check whose failure
|
|
* mode is that nobody can reach the page that would fix it.
|
|
*
|
|
* @param array<int, string> $extra the owner's list AFTER the change
|
|
*/
|
|
public static function wouldStillAllow(string $ip, array $extra): bool
|
|
{
|
|
$vpn = (array) config('admin_access.trusted_ranges', []);
|
|
|
|
return self::covers($ip, array_merge($vpn, $extra));
|
|
}
|
|
|
|
/**
|
|
* Whether a value is an address or a CIDR range at all.
|
|
*
|
|
* Shared by the console and the recovery command on purpose: an entry that
|
|
* matches nothing is stored happily and reports success, and the operator
|
|
* then switches the restriction on believing they are covered. That is the
|
|
* exact situation the recovery command exists to get out of.
|
|
*/
|
|
public static function isNetwork(string $value): bool
|
|
{
|
|
[$address, $prefix] = array_pad(explode('/', $value, 2), 2, null);
|
|
|
|
if (filter_var($address, FILTER_VALIDATE_IP) === false) {
|
|
return false;
|
|
}
|
|
|
|
if ($prefix === null) {
|
|
return true;
|
|
}
|
|
|
|
$max = str_contains((string) $address, ':') ? 128 : 32;
|
|
|
|
return ctype_digit((string) $prefix) && (int) $prefix >= 0 && (int) $prefix <= $max;
|
|
}
|
|
|
|
/** @param array<int, string> $ranges */
|
|
private static function covers(string $ip, array $ranges): bool
|
|
{
|
|
$ranges = array_values(array_filter($ranges));
|
|
|
|
return $ranges !== [] && $ip !== '' && IpUtils::checkIp($ip, $ranges);
|
|
}
|
|
}
|