web: /healthz is answered only to local probes (audit I6)

Security audit 2026-10-17, finding I6 (low), stage 3. /healthz is
registered outside auth and every proxy fragment passed it through: the
live server answered "ok" to the internet, and "unhealthy: postfix"
would have been a free tell.

Now handleHealth answers only a request with no X-Forwarded-For (nor
Forwarded) from loopback or a private address: the image's HEALTHCHECK
(127.0.0.1), the host through the loopback-published port (the Docker
gateway), a monitor on the Docker network. Every supported proxy appends
X-Forwarded-For, so through it the endpoint is 404. The plan named
"trusted-proxy peers"; that would have let the internet in again through
the trusted proxy, hence this shape. The e2e's readiness probe is the
host's, and stays answered.

Test: TestHealthzAnswersOnlyLocalProbes. Docs: guide § Container health,
architecture.md; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 22:17:27 +03:00
co-authored by Claude Opus 5.5
parent 8aad5b6223
commit a327962008
6 changed files with 76 additions and 8 deletions
+25 -1
View File
@@ -356,7 +356,17 @@ func redirectHome(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/overview", http.StatusSeeOther)
}
func handleHealth(w http.ResponseWriter, _ *http.Request) {
// handleHealth answers the liveness probe - but only to a client that did not
// come through a reverse proxy: one with no X-Forwarded-For, from loopback or
// a private address (the container's own HEALTHCHECK, the host, a monitor on
// the Docker network). Every supported proxy appends X-Forwarded-For, and the
// panel's port is published on loopback only, so the internet gets a 404:
// "unhealthy: postfix" was a free tell (security audit 2026-10-17, I6).
func handleHealth(w http.ResponseWriter, r *http.Request) {
if !localProbe(r) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
if err := health.Liveness(); err != nil {
http.Error(w, "unhealthy\n", http.StatusServiceUnavailable)
@@ -366,6 +376,20 @@ func handleHealth(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok\n"))
}
// localProbe is whether a request comes straight from loopback or a private
// address, not through a proxy.
func localProbe(r *http.Request) bool {
if r.Header.Get("X-Forwarded-For") != "" || r.Header.Get("Forwarded") != "" {
return false
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
return ip != nil && (ip.IsLoopback() || ip.IsPrivate())
}
func handleLicense(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)