feat(security): keep the operator console off the public hostnames

Only the marketing site and the customer portal are public; /admin and the
Proxmox hosts stay on the private network. nginx was a single server_name _
catch-all, so /admin was served on all four dev domains.

Two layers:
- nginx denies /admin on the known-public hostnames before PHP is reached
  (denylist, so an allowlist typo cannot lock the operators out).
- ADMIN_HOSTS is now populated, making the app layer a strict allowlist that
  also covers /livewire/update, which nginx cannot attribute to /admin.

Verified live against the running stack: /admin is 404 on www/app/api/ws and on
any unlisted host, 302->login on admin.dev / the private IP / localhost, while
/ and /dashboard stay reachable everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 21:01:02 +02:00
parent ba861ad579
commit 7247edf9d8
2 changed files with 36 additions and 1 deletions

View File

@ -164,7 +164,7 @@ MONITORING_ATTEMPTS=2
# antwortet /admin mit 404 (nicht 403 — verrät nicht, dass es die Konsole gibt). # antwortet /admin mit 404 (nicht 403 — verrät nicht, dass es die Konsole gibt).
# Komma-getrennt. LEER = keine Einschränkung (aktueller Dev-Stand). # Komma-getrennt. LEER = keine Einschränkung (aktueller Dev-Stand).
# Beispiel: ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185 # Beispiel: ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185
ADMIN_HOSTS= ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185,localhost,127.0.0.1
# ── Nextcloud blueprint template ───────────────────────────────────────── # ── Nextcloud blueprint template ─────────────────────────────────────────
# NOT an env var: set the Proxmox template VMID per plan in # NOT an env var: set the Proxmox template VMID per plan in

View File

@ -1,3 +1,23 @@
# Hostnames that are reachable from the public internet. The operator console
# (/admin) must NEVER be served on these — only the marketing site and the
# customer portal are public; Proxmox hosts and /admin stay on the private
# network / VPN.
#
# Deliberately a denylist, not an allowlist: a typo in an allowlist would lock
# the operators out of their own console. The app-level allowlist (ADMIN_HOSTS,
# see config/admin_access.php) is the strict layer — this one guarantees that a
# request arriving on a known-public hostname never even reaches PHP.
map $host $admin_host_is_public {
default 0;
www.dev.clupilot.com 1;
app.dev.clupilot.com 1;
api.dev.clupilot.com 1;
ws.dev.clupilot.com 1;
# Production (add the real public hostnames here before launch):
# www.clupilot.com 1;
# app.clupilot.com 1;
}
server { server {
listen 80 default_server; listen 80 default_server;
server_name _; server_name _;
@ -7,6 +27,21 @@ server {
charset utf-8; charset utf-8;
client_max_body_size 64M; client_max_body_size 64M;
# ^~ beats the regex locations, so this decides before PHP is reached. The
# try_files rewrite to /index.php re-runs location matching, so the console
# still executes normally on a permitted host.
#
# This only covers direct hits: Livewire posts component actions to
# /livewire/update, which nginx cannot attribute to /admin. That case is
# handled in the app (RestrictAdminHost is registered as Livewire-persistent
# middleware) — do not treat this block as the only control.
location ^~ /admin {
if ($admin_host_is_public) {
return 404;
}
try_files $uri $uri/ /index.php?$query_string;
}
location / { location / {
try_files $uri $uri/ /index.php?$query_string; try_files $uri $uri/ /index.php?$query_string;
} }