clusev/app/Http/Middleware/PanelScheme.php

78 lines
3.2 KiB
PHP

<?php
namespace App\Http\Middleware;
use App\Services\DeploymentService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Ties cookie security, HTTPS enforcement and host enforcement to the ACTUAL request so
* a panel-domain change can never lock the operator out nor expose the panel on the wrong
* host. Runs behind TrustProxies (prod), so isSecure()/getHost() reflect Caddy's
* X-Forwarded-* headers.
*
* - The session cookie's `Secure` flag follows the real request scheme, so the bare-IP /
* plain-HTTP recovery path always receives its cookie and can log in.
* - When a domain is active, ONLY that domain (over HTTPS) and the literal server IP
* (over HTTP, the recovery path) may serve the panel. Any other hostname — a stale/old
* domain whose certificate Caddy may still hold, or an attacker's domain pointed at this
* server — is refused, so the authenticated panel is never served in plaintext or on an
* unintended host. Caddy's TLS-ask + the health check are exempt.
* - In bare-IP mode app.url is kept in step with the request, so generated absolute URLs
* never point at a removed/old domain left in .env.
*/
class PanelScheme
{
public function __construct(private DeploymentService $deployment) {}
public function handle(Request $request, Closure $next): Response
{
// Cookie security follows the real request scheme (never a static .env flag).
config(['session.secure' => $request->isSecure()]);
$domain = $this->deployment->domain();
// Bare-IP mode: no TLS, no host restriction. Keep app.url on the current request
// so URLs don't point at a domain that was removed from service.
if ($domain === null) {
config(['app.url' => $request->getSchemeAndHttpHost()]);
return $next($request);
}
// Internal endpoints must answer on any host/scheme (Caddy's on-demand TLS ask is
// a plain-HTTP call with an internal Host; the health check likewise).
if ($request->is('_caddy/*', 'up')) {
return $next($request);
}
$host = strtolower($request->getHost());
// The literal server IP is the plaintext recovery path — always served over HTTP.
// Pin app.url to the IP request too, so login/onboarding redirects (absolute
// route() URLs) stay on the reachable IP instead of bouncing to the (possibly
// unreachable) domain that AppServiceProvider set as app.url.
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
config(['app.url' => $request->getSchemeAndHttpHost()]);
return $next($request);
}
// Only the active domain may serve the panel by hostname.
if ($host !== $domain) {
abort(404);
}
// Force HTTPS for the active domain — on the default TLS port (443). Build the URL
// explicitly so a non-standard HTTP APP_PORT is never carried into the https:// URL
// (Caddy serves TLS only on 443; https://domain:8080 would be unreachable).
if (! $request->isSecure()) {
return redirect()->to('https://'.$domain.$request->getRequestUri(), 301);
}
return $next($request);
}
}