Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
85 changes: 85 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,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.
Comment thread
pawannn marked this conversation as resolved.
Outdated
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 {
Comment thread
pawannn marked this conversation as resolved.
Outdated
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
}
52 changes: 52 additions & 0 deletions modules/caddyhttp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
Comment thread
pawannn marked this conversation as resolved.

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)
}
})
}
}