92 lines
4.2 KiB
PHP
92 lines
4.2 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Middleware;
|
|
|
|
use App\Services\DeploymentService;
|
|
use Closure;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\URL;
|
|
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 + the terminal sidecar's
|
|
// resolve call likewise come over the private network with a service-name Host).
|
|
if ($request->is('_caddy/*', 'up', '_internal/*')) {
|
|
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).
|
|
//
|
|
// In external-proxy mode the upstream terminates TLS and owns HTTP->HTTPS; this Caddy is
|
|
// HTTP-only, so the app must NOT bounce to https://domain:443 (unreachable). The forwarded
|
|
// scheme still drives cookie-Secure/HSTS above.
|
|
if (! $this->deployment->externalTls() && ! $request->isSecure()) {
|
|
return redirect()->to('https://'.$domain.$request->getRequestUri(), 301);
|
|
}
|
|
|
|
// Serving the active domain over HTTPS: force generated URLs (assets, routes) to
|
|
// https://<domain> so they MATCH the page scheme. Critical in external-proxy mode, where
|
|
// the upstream terminates TLS and the forwarded scheme may not be trusted here — without
|
|
// this asset() emits http:// URLs that the HTTPS page's CSP ('self') blocks, leaving the
|
|
// panel with no CSS/JS. The bare-IP recovery path returned earlier and stays on HTTP.
|
|
URL::forceRootUrl('https://'.$domain);
|
|
URL::forceScheme('https');
|
|
|
|
return $next($request);
|
|
}
|
|
}
|