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
141 changes: 141 additions & 0 deletions modules/caddyhttp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"net/url"
"runtime"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
76 changes: 76 additions & 0 deletions modules/caddyhttp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
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)
}
})
}
}