From 7247edf9d8fe843f6f06795109f8a60e5ccdb953 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 21:01:02 +0200 Subject: [PATCH] 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 --- .env.example | 2 +- docker/nginx/default.conf | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 3c3cb94..f06c306 100644 --- a/.env.example +++ b/.env.example @@ -164,7 +164,7 @@ MONITORING_ATTEMPTS=2 # antwortet /admin mit 404 (nicht 403 — verrät nicht, dass es die Konsole gibt). # Komma-getrennt. LEER = keine Einschränkung (aktueller Dev-Stand). # 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 ───────────────────────────────────────── # NOT an env var: set the Proxmox template VMID per plan in diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 80fbb94..70cb343 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -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 { listen 80 default_server; server_name _; @@ -7,6 +27,21 @@ server { charset utf-8; 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 / { try_files $uri $uri/ /index.php?$query_string; }