From 0770b52f790a6d97b35d406111b760a511c0f600 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Mon, 20 Jul 2026 13:25:16 +0200 Subject: [PATCH] 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/. Co-Authored-By: Gerald KERMA --- .../cmd/sbxwaf/main_test.go | 47 +++++++++++++++++++ .../cmd/sbxwaf/wakerproxy.go | 9 ++++ 2 files changed, 56 insertions(+) diff --git a/packages/secubox-toolbox-ng/cmd/sbxwaf/main_test.go b/packages/secubox-toolbox-ng/cmd/sbxwaf/main_test.go index 37d17cca..d8754b75 100644 --- a/packages/secubox-toolbox-ng/cmd/sbxwaf/main_test.go +++ b/packages/secubox-toolbox-ng/cmd/sbxwaf/main_test.go @@ -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). diff --git a/packages/secubox-toolbox-ng/cmd/sbxwaf/wakerproxy.go b/packages/secubox-toolbox-ng/cmd/sbxwaf/wakerproxy.go index 1a01a76e..484fcb2e 100644 --- a/packages/secubox-toolbox-ng/cmd/sbxwaf/wakerproxy.go +++ b/packages/secubox-toolbox-ng/cmd/sbxwaf/wakerproxy.go @@ -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.