diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 41a8e55b010..d9e92e5dce2 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -493,6 +493,14 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) error { } } + // RFC 3986 §3.2.2: validate Host header syntax for IP-literals and port values + if !validHostHeader(r.Host) { + return HandlerError{ + Err: fmt.Errorf("invalid Host header: %q", r.Host), + StatusCode: http.StatusBadRequest, + } + } + // execute the primary handler chain return s.primaryHandlerChain.ServeHTTP(w, r) } @@ -1195,3 +1203,81 @@ func getHTTP3Network(originalNetwork string) (string, error) { } return h3Network, nil } + +// validHostHeader returns true if the Host header value is syntactically +// valid per RFC 3986 §3.2.2. It rejects malformed IP-literals (e.g. [], +// [123g::1], unclosed brackets) and invalid port values. +func validHostHeader(host string) bool { + if host == "" { + return true // empty host is handled separately per HTTP version + } + + if strings.HasPrefix(host, "[") { + // IP-literal: find closing bracket + closeBracket := strings.LastIndex(host, "]") + if closeBracket < 0 { + return false // unclosed bracket: e.g. [::1 + } + + ipLiteral := host[1:closeBracket] + if len(ipLiteral) == 0 { + return false // empty: [] + } + + // IPvFuture (starts with 'v') — reject; not a valid IPv6 address + if len(ipLiteral) > 0 && (ipLiteral[0] == 'v' || ipLiteral[0] == 'V') { + return false + } + + // Must parse as a valid IPv6 address + addr, err := netip.ParseAddr(ipLiteral) + if err != nil || !addr.Is6() { + return false + } + + // Optional port after the closing bracket + rest := host[closeBracket+1:] + if rest == "" { + return true + } + if !strings.HasPrefix(rest, ":") { + return false + } + return validPort(rest[1:]) + } + + // Non-IP-literal: must have at most one colon (for port) + colonCount := strings.Count(host, ":") + if colonCount > 1 { + return false // e.g. example.com::80 + } + if colonCount == 1 { + _, portStr, err := net.SplitHostPort(host) + if err != nil { + return false + } + return validPort(portStr) + } + + return true +} + +// validPort returns true if the string is a valid numeric port (0–65535). +func validPort(port string) bool { + if port == "" { + return false + } + for _, c := range port { + if c < '0' || c > '9' { + return false + } + } + n := 0 + for _, c := range port { + n = n*10 + int(c-'0') + if n > 65535 { + return false + } + } + return true +} diff --git a/modules/caddyhttp/server_test.go b/modules/caddyhttp/server_test.go index eecb392e474..d94105861a9 100644 --- a/modules/caddyhttp/server_test.go +++ b/modules/caddyhttp/server_test.go @@ -499,3 +499,56 @@ func TestServer_DetermineTrustedProxy_MatchRightMostUntrustedFirst(t *testing.T) assert.True(t, trusted) assert.Equal(t, clientIP, "90.100.110.120") } + +func TestServeHTTP_InvalidHostHeader(t *testing.T) { + tests := []struct { + name string + host string + wantStatus int + }{ + {"valid host", "example.com", http.StatusOK}, + {"valid IPv6", "[::1]", http.StatusOK}, + {"valid with port", "example.com:80", http.StatusOK}, + + {"empty IP-literal", "[]", http.StatusBadRequest}, + {"unclosed bracket", "[::1", http.StatusBadRequest}, + {"invalid IPv6", "[12345]", http.StatusBadRequest}, + {"invalid hex char", "[123g::1]", http.StatusBadRequest}, + {"double colon host", "example.com::80", http.StatusBadRequest}, + {"non-numeric port", "example.com:80a", http.StatusBadRequest}, + {"port out of range", "example.com:99999", http.StatusBadRequest}, + {"bracketed IPv4", "[127.0.0.1]", http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Server{} + + // minimal handler that always returns 200 + s.primaryHandlerChain = HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + w.WriteHeader(http.StatusOK) + return nil + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = tt.host + req.Proto = "HTTP/1.1" + req.ProtoMajor = 1 + req.ProtoMinor = 1 + + rr := httptest.NewRecorder() + err := s.serveHTTP(rr, req) + + gotStatus := rr.Code + if err != nil { + if he, ok := err.(HandlerError); ok { + gotStatus = he.StatusCode + } + } + + if gotStatus != tt.wantStatus { + t.Errorf("host %q: got status %d, want %d", tt.host, gotStatus, tt.wantStatus) + } + }) + } +}