From b398b0ce7435138e2c2495de71467e78424f874e Mon Sep 17 00:00:00 2001 From: pawannn Date: Fri, 6 Mar 2026 01:52:05 +0530 Subject: [PATCH 1/3] fix: validate Host header IP-literals and reject malformed values with 400 (fixes #7459) --- modules/caddyhttp/server.go | 85 ++++++++++++++++++++++++++++++++ modules/caddyhttp/server_test.go | 52 +++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 41a8e55b010..d663cbcc0d7 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,80 @@ 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 + if net.ParseIP(ipLiteral) == nil { + 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..8ef92358d87 100644 --- a/modules/caddyhttp/server_test.go +++ b/modules/caddyhttp/server_test.go @@ -499,3 +499,55 @@ 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}, + } + + 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) + } + }) + } +} From ef854d0b034c72e0afc224ce00a32d88ed49fd86 Mon Sep 17 00:00:00 2001 From: pawannn Date: Fri, 6 Mar 2026 02:22:13 +0530 Subject: [PATCH 2/3] chore: address copilot review comments --- modules/caddyhttp/server.go | 3 ++- modules/caddyhttp/server_test.go | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index d663cbcc0d7..d9e92e5dce2 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -1230,7 +1230,8 @@ func validHostHeader(host string) bool { } // Must parse as a valid IPv6 address - if net.ParseIP(ipLiteral) == nil { + addr, err := netip.ParseAddr(ipLiteral) + if err != nil || !addr.Is6() { return false } diff --git a/modules/caddyhttp/server_test.go b/modules/caddyhttp/server_test.go index 8ef92358d87..d94105861a9 100644 --- a/modules/caddyhttp/server_test.go +++ b/modules/caddyhttp/server_test.go @@ -517,6 +517,7 @@ func TestServeHTTP_InvalidHostHeader(t *testing.T) { {"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 { From ab013d70cca923e443536823d2600318a9bb7471 Mon Sep 17 00:00:00 2001 From: pawannn Date: Tue, 24 Mar 2026 01:00:57 +0530 Subject: [PATCH 3/3] fix: Host header validation to support IPvFuture and IPv6 zone IDs --- modules/caddyhttp/server.go | 139 +++++++++++++++++++++---------- modules/caddyhttp/server_test.go | 23 +++++ 2 files changed, 120 insertions(+), 42 deletions(-) diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index d9e92e5dce2..b3fa9a18262 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -27,6 +27,7 @@ import ( "net/url" "runtime" "slices" + "strconv" "strings" "sync" "time" @@ -1204,80 +1205,134 @@ 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. +// validHostHeader reports whether a Host header value is valid per RFC 3986 §3.2.2. 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 + return false // no closing bracket } - ipLiteral := host[1:closeBracket] - if len(ipLiteral) == 0 { - return false // empty: [] + inner := host[1:closeBracket] + if inner == "" { + return false // [] is not valid } - // IPvFuture (starts with 'v') — reject; not a valid IPv6 address - if len(ipLiteral) > 0 && (ipLiteral[0] == 'v' || ipLiteral[0] == 'V') { - return false - } + // IPvFuture starts with 'v' and is allowed by RFC 3986 + if inner[0] == 'v' || inner[0] == 'V' { + if !validIPvFuture(inner) { + return false + } + } else { + // strip Zone IDs before parsing + ipStr := inner + if i := strings.Index(ipStr, "%25"); i >= 0 { + ipStr = ipStr[:i] + } - // Must parse as a valid IPv6 address - addr, err := netip.ParseAddr(ipLiteral) - if err != nil || !addr.Is6() { - return false + addr, err := netip.ParseAddr(ipStr) + if err != nil || !addr.Is6() { + return false + } } - // Optional port after the closing bracket rest := host[closeBracket+1:] + + // No port mentioned if rest == "" { return true } - if !strings.HasPrefix(rest, ":") { + + // check if the rest of the part doesn't start with ":", host:port + if rest[0] != ':' { 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 + // More than one colon means IPv6 without brackets, always reject + if strings.Count(host, ":") > 1 { + return false } - if colonCount == 1 { - _, portStr, err := net.SplitHostPort(host) - if err != nil { - return false - } + + // Single colon means host:port + _, portStr, hasPort := strings.Cut(host, ":") + if hasPort { 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 == "" { +// validIPvFuture checks the format: "v" + hex digits + "." + allowed chars. +// RFC 3986: "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) +func validIPvFuture(inner string) bool { + // need at least "vF.x" + if len(inner) < 4 || (inner[0] != 'v' && inner[0] != 'V') { return false } - for _, c := range port { - if c < '0' || c > '9' { - return false - } + + // drop the leading 'v' + inner = inner[1:] + + // count hex digits + i := 0 + for i < len(inner) && isHexDigit(inner[i]) { + i++ + } + + // check if there are no hex digits + if i == 0 { + return false + } + + // check for one dot after the hex digits + if i >= len(inner) || inner[i] != '.' { + return false + } + i++ + + // at least one character must follow the dot + if i >= len(inner) { + return false } - n := 0 - for _, c := range port { - n = n*10 + int(c-'0') - if n > 65535 { + + // every remaining character must be in the allowed set + for ; i < len(inner); i++ { + if !isIPvFutureChar(inner[i]) { return false } } + return true } + +func isHexDigit(c byte) bool { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') +} + +// isIPvFutureChar allows: letters, digits, "-._~" (unreserved), "!$&'()*+,;=" (sub-delims), ":" +func isIPvFutureChar(c byte) bool { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { + return true + } + + switch c { + case '-', '.', '_', '~', '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', ':': + return true + } + + return false +} + +// validPort checks the port is a number between 0 and 65535. +func validPort(port string) bool { + // Empty string is rejected since "host:" with no port is not valid. + if port == "" { + return false + } + + n, err := strconv.Atoi(port) + return err == nil && n >= 0 && n <= 65535 +} diff --git a/modules/caddyhttp/server_test.go b/modules/caddyhttp/server_test.go index d94105861a9..2d4f3f709ec 100644 --- a/modules/caddyhttp/server_test.go +++ b/modules/caddyhttp/server_test.go @@ -509,6 +509,18 @@ func TestServeHTTP_InvalidHostHeader(t *testing.T) { {"valid host", "example.com", http.StatusOK}, {"valid IPv6", "[::1]", http.StatusOK}, {"valid with port", "example.com:80", http.StatusOK}, + {"valid IPv6 with port", "[::1]:8080", http.StatusOK}, + {"valid IPv6 full", "[2001:db8::1]", http.StatusOK}, + {"valid IPv6 full with port", "[2001:db8::1]:443", http.StatusOK}, + {"valid IPv6 zone", "[fe80::1%25eth0]", http.StatusOK}, + {"valid IPvFuture", "[v1.test]", http.StatusOK}, + {"valid IPvFuture with port", "[v1.test]:80", http.StatusOK}, + {"valid port zero", "example.com:0", http.StatusOK}, + {"valid port max", "example.com:65535", http.StatusOK}, + {"valid empty host", "", http.StatusBadRequest}, + {"valid IPv4", "192.168.1.1", http.StatusOK}, + {"valid IPv4 with port", "192.168.1.1:8080", http.StatusOK}, + {"valid subdomain", "sub.example.com:80", http.StatusOK}, {"empty IP-literal", "[]", http.StatusBadRequest}, {"unclosed bracket", "[::1", http.StatusBadRequest}, @@ -518,6 +530,17 @@ func TestServeHTTP_InvalidHostHeader(t *testing.T) { {"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}, + {"bare IPv6", "::1", http.StatusBadRequest}, + {"bare IPv6 full", "2001:db8::1", http.StatusBadRequest}, + {"empty port", "example.com:", http.StatusBadRequest}, + {"IPv6 empty port", "[::1]:", http.StatusBadRequest}, + {"IPv6 garbage after bracket", "[::1]abc", http.StatusBadRequest}, + {"IPvFuture no hex digits", "[v.test]", http.StatusBadRequest}, + {"IPvFuture no dot", "[v1test]", http.StatusBadRequest}, + {"IPvFuture empty after dot", "[v1.]", http.StatusBadRequest}, + {"IPvFuture uppercase V", "[V1.test]", http.StatusOK}, + {"port negative", "example.com:-1", http.StatusBadRequest}, + {"port overflow", "example.com:65536", http.StatusBadRequest}, } for _, tt := range tests {