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:
@@ -0,0 +1,34 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// /healthz answers a probe that did not come through a proxy, from loopback
|
||||
// or a private address, and is not found for anyone else (security audit
|
||||
// 2026-10-17, I6). Liveness itself is not under test here: a probe that is
|
||||
// answered gets 200 or 503, never 404.
|
||||
func TestHealthzAnswersOnlyLocalProbes(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name, remote, xff string
|
||||
answered bool
|
||||
}{
|
||||
{"the container's own probe", "127.0.0.1:40000", "", true},
|
||||
{"the host through the published port", "172.18.0.1:40000", "", true},
|
||||
{"through the reverse proxy", "172.18.0.1:40000", "198.51.100.7", false},
|
||||
{"a public address", "198.51.100.7:40000", "", false},
|
||||
} {
|
||||
r := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
r.RemoteAddr = c.remote
|
||||
if c.xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", c.xff)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handleHealth(rec, r)
|
||||
if got := rec.Code != http.StatusNotFound; got != c.answered {
|
||||
t.Errorf("%s: %d, answered %v, want %v", c.name, rec.Code, got, c.answered)
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user