diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 66f93989b26..8ca664fdf38 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -27,6 +27,7 @@ import ( "net/url" "runtime" "slices" + "strconv" "strings" "sync/atomic" "time" @@ -494,6 +495,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) } @@ -1232,3 +1241,135 @@ func getHTTP3Network(originalNetwork string) (string, error) { } return h3Network, nil } + +// validHostHeader reports whether a Host header value is valid per RFC 3986 §3.2.2. +func validHostHeader(host string) bool { + if strings.HasPrefix(host, "[") { + closeBracket := strings.LastIndex(host, "]") + if closeBracket < 0 { + return false // no closing bracket + } + + inner := host[1:closeBracket] + if inner == "" { + return false // [] is not valid + } + + // 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] + } + + addr, err := netip.ParseAddr(ipStr) + if err != nil || !addr.Is6() { + return false + } + } + + rest := host[closeBracket+1:] + + // No port mentioned + if rest == "" { + return true + } + + // check if the rest of the part doesn't start with ":", host:port + if rest[0] != ':' { + return false + } + + return validPort(rest[1:]) + } + + // More than one colon means IPv6 without brackets, always reject + if strings.Count(host, ":") > 1 { + return false + } + + // Single colon means host:port + _, portStr, hasPort := strings.Cut(host, ":") + if hasPort { + return validPort(portStr) + } + + return true +} + +// 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 + } + + // 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 + } + + // 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 eecb392e474..2d4f3f709ec 100644 --- a/modules/caddyhttp/server_test.go +++ b/modules/caddyhttp/server_test.go @@ -499,3 +499,79 @@ 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}, + {"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}, + {"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}, + {"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 { + 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) + } + }) + } +}