fix(waf-ng): normalize wake host casing so mixed-case Host reaches the waker (ref #896)

The waker Director rebuilt the wake path from the raw req.Host without
lowercasing, while OnDemand.Contains matches case-insensitively. A
mixed-case Host (hand-typed URL, script caller) would pass the
on-demand gate but the waker's exact-match lookup against the
lowercase-stored portal_domain would miss, leaving the service
permanently unwoken behind the splash. Normalize the same way
(lowercase + trim) in the Director before building /_wake/<host>.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-20 13:25:16 +02:00
parent 942ecbb3a6
commit 0770b52f79
2 changed files with 56 additions and 0 deletions

View File

@ -134,6 +134,53 @@ func TestOnDemandProxiesToWaker(t *testing.T) {
// still gets 421 from this same handler code path.
}
// TestOnDemandProxiesWithMixedCaseHost verifies that a mixed/upper-case Host
// header — which OnDemand.Contains already matches case-insensitively — is
// normalized to lowercase by the waker Director too, so the wake key sent to
// the waker matches the lowercase portal_domain stored in on-demand-vhosts.json.
// Without this normalization the waker's exact-match lookup on the mixed-case
// path would miss and the wake would never fire (permanent splash).
func TestOnDemandProxiesWithMixedCaseHost(t *testing.T) {
sockPath := filepath.Join(t.TempDir(), "waker3.sock")
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen unix %s: %v", sockPath, err)
}
defer ln.Close()
var gotPath string
waker := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = io.WriteString(w, "waking up…")
}),
}
go waker.Serve(ln) //nolint:errcheck
defer waker.Close()
srv := &Server{
routeLookup: func(host string) (string, int, bool) { return "", 0, false },
// Stored lowercase, exactly as the on-demand-vhosts.json generator emits it.
onDemand: &OnDemand{entries: map[string]bool{"sleepy.example.com": true}},
wakerSocket: sockPath,
}
handler := srv.handler()
// Mixed-case Host header — a hand-typed URL or script caller.
req := httptest.NewRequest(http.MethodGet, "http://Sleepy.Example.COM/", nil)
req.Host = "Sleepy.Example.COM"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 from waker splash, got %d", rec.Code)
}
if gotPath != "/_wake/sleepy.example.com" {
t.Fatalf("expected canonical lowercase waker path /_wake/sleepy.example.com, got %q", gotPath)
}
}
// TestOnDemandVisitsExcluded verifies that a request served by the waker
// splash is NOT tallied into the legitimate visit-stats (mirrors the existing
// 403/421 exclusion).

View File

@ -21,6 +21,7 @@ import (
"net"
"net/http"
"net/http/httputil"
"strings"
)
// defaultWakerSocket is the production path to the waker's unix socket.
@ -56,6 +57,14 @@ func (s *Server) wakerProxy() *httputil.ReverseProxy {
if err != nil {
host = req.Host
}
// Normalize exactly like OnDemand.Contains / Routes.Lookup
// (lowercase + trim) so the wake key sent to the waker matches
// the lowercase portal_domain stored in on-demand-vhosts.json
// regardless of the casing a client sent in its Host header.
// Without this, a mixed-case Host would pass the on-demand
// gate (case-insensitive) but miss the waker's exact-match
// lookup, leaving the service permanently unwoken.
host = strings.ToLower(strings.TrimSpace(host))
// Dummy scheme/host: the Transport ignores them and always
// dials the unix socket above; only the rewritten path
// carries the information the waker needs.