Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions modules/caddyhttp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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.
Comment on lines +1207 to +1209
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring claims RFC 3986 §3.2.2 syntactic validity in general, but the implementation only validates bracketed IP-literals and port syntax (it does not validate reg-name characters/percent-encoding rules). Suggest rewording the comment to reflect the narrower scope (e.g., “validates IP-literal bracket/IPv6 syntax and optional port”) or expanding validation to cover reg-name if that’s intended.

Suggested change
// 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 returns true if the Host header value is structurally
// acceptable for this server. It specifically validates bracketed IP-literals
// (IPv6-style hosts) and optional port syntax, rejecting malformed IP-literals
// (e.g. [], [123g::1], unclosed brackets) and invalid port values. It does not
// fully validate reg-name characters or percent-encoding as defined in RFC 3986 §3.2.2.

Copilot uses AI. Check for mistakes.
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
}
53 changes: 53 additions & 0 deletions modules/caddyhttp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
Loading