diff --git a/modules/caddytls/automation_test.go b/modules/caddytls/automation_test.go index d991b9cf621..8a614a89c56 100644 --- a/modules/caddytls/automation_test.go +++ b/modules/caddytls/automation_test.go @@ -7,6 +7,36 @@ import ( "go.uber.org/zap" ) +// TestGetAutomationPolicyForNameIPv6Brackets verifies that getAutomationPolicyForName +// does NOT match a policy registered for "2a12:4944:efe4::" when given the bracketed +// form "[2a12:4944:efe4::]" that Go's HTTP server places in r.Host for IPv6 requests. +func TestGetAutomationPolicyForNameIPv6Brackets(t *testing.T) { + ipv6Addr := "2a12:4944:efe4::" + + specificPolicy := &AutomationPolicy{ + subjects: []string{ipv6Addr}, + } + defaultPolicy := &AutomationPolicy{} + + tlsApp := &TLS{ + Automation: &AutomationConfig{ + Policies: []*AutomationPolicy{specificPolicy}, + defaultPublicAutomationPolicy: defaultPolicy, + defaultInternalAutomationPolicy: defaultPolicy, + }, + } + + got := tlsApp.getAutomationPolicyForName("[" + ipv6Addr + "]") + if got == specificPolicy { + t.Errorf("getAutomationPolicyForName with bracketed IPv6 host should NOT match the specific policy (bug: brackets prevent matching)") + } + + got = tlsApp.getAutomationPolicyForName(ipv6Addr) + if got != specificPolicy { + t.Errorf("getAutomationPolicyForName with un-bracketed IPv6 host should return specific policy, got %v", got) + } +} + func TestAutomationPolicyMakeCertMagicConfigImplicitTailscaleManagersOnly(t *testing.T) { ap := AutomationPolicy{ Managers: []certmagic.Manager{Tailscale{}}, diff --git a/modules/caddytls/tls.go b/modules/caddytls/tls.go index 34ffbf62d27..fb0c2cdb078 100644 --- a/modules/caddytls/tls.go +++ b/modules/caddytls/tls.go @@ -684,15 +684,32 @@ func (t *TLS) HandleHTTPChallenge(w http.ResponseWriter, r *http.Request) bool { return false } + // Normalize r.Host for lookup: Go's HTTP server wraps IPv6 addresses in + // brackets per RFC 7230 (e.g. "[::1]" or "[::1]:80"), but automation policy + // subjects and ACME challenge keys use bare addresses without brackets. + hostForLookup, _, err := net.SplitHostPort(r.Host) + if err != nil { + hostForLookup = strings.TrimPrefix(r.Host, "[") + hostForLookup = strings.TrimSuffix(hostForLookup, "]") + } + // try all the issuers until we find the one that initiated the challenge - ap := t.getAutomationPolicyForName(r.Host) + ap := t.getAutomationPolicyForName(hostForLookup) + + // Clone the request with the normalized host so that certmagic handlers + // (which call hostOnly(r.Host) internally) receive a bare address without + // brackets. hostOnly uses net.SplitHostPort, which cannot strip brackets + // from a host-only IPv6 literal like "[::1]" (no port), so we must + // normalize before passing the request down. + reqWithNormalizedHost := r.Clone(r.Context()) + reqWithNormalizedHost.Host = hostForLookup if acmeChallenge { type acmeCapable interface{ GetACMEIssuer() *ACMEIssuer } for _, iss := range ap.magic.Issuers { if acmeIssuer, ok := iss.(acmeCapable); ok { - if acmeIssuer.GetACMEIssuer().issuer.HandleHTTPChallenge(w, r) { + if acmeIssuer.GetACMEIssuer().issuer.HandleHTTPChallenge(w, reqWithNormalizedHost) { return true } } @@ -703,13 +720,13 @@ func (t *TLS) HandleHTTPChallenge(w http.ResponseWriter, r *http.Request) bool { // so that users can proxy the others through to their backends; but we // might not have an automation policy for all identifiers that are trying // to get certificates (e.g. the admin endpoint), so we do this manual check - if challenge, ok := certmagic.GetACMEChallenge(r.Host); ok { - return certmagic.SolveHTTPChallenge(t.logger, w, r, challenge.Challenge) + if challenge, ok := certmagic.GetACMEChallenge(hostForLookup); ok { + return certmagic.SolveHTTPChallenge(t.logger, w, reqWithNormalizedHost, challenge.Challenge) } } else if zerosslValidation { for _, iss := range ap.magic.Issuers { if ziss, ok := iss.(*ZeroSSLIssuer); ok { - if ziss.issuer.HandleZeroSSLHTTPValidation(w, r) { + if ziss.issuer.HandleZeroSSLHTTPValidation(w, reqWithNormalizedHost) { return true } } diff --git a/modules/caddytls/tls_test.go b/modules/caddytls/tls_test.go new file mode 100644 index 00000000000..d9d8a474653 --- /dev/null +++ b/modules/caddytls/tls_test.go @@ -0,0 +1,95 @@ +package caddytls + +import ( + "net" + "strings" + "testing" +) + +// TestHandleHTTPChallengeIPv6HostNormalization verifies the host normalization +// that HandleHTTPChallenge must apply before calling getAutomationPolicyForName +// and certmagic.GetACMEChallenge. +func TestHandleHTTPChallengeIPv6HostNormalization(t *testing.T) { + tests := []struct { + name string + rHost string + wantHost string + }{ + { + name: "IPv6 without port", + rHost: "[2a12:4944:efe4::]", + wantHost: "2a12:4944:efe4::", + }, + { + name: "IPv6 with port", + rHost: "[2a12:4944:efe4::]:80", + wantHost: "2a12:4944:efe4::", + }, + { + name: "IPv6 loopback without port", + rHost: "[::1]", + wantHost: "::1", + }, + { + name: "IPv6 loopback with port", + rHost: "[::1]:80", + wantHost: "::1", + }, + { + name: "IPv4 without port (no change expected)", + rHost: "192.0.2.1", + wantHost: "192.0.2.1", + }, + { + name: "IPv4 with port", + rHost: "192.0.2.1:80", + wantHost: "192.0.2.1", + }, + { + name: "domain without port (no change expected)", + rHost: "example.com", + wantHost: "example.com", + }, + { + name: "domain with port", + rHost: "example.com:80", + wantHost: "example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, _, err := net.SplitHostPort(tt.rHost) + if err != nil { + got = tt.rHost + got = strings.TrimPrefix(got, "[") + got = strings.TrimSuffix(got, "]") + } + + if got != tt.wantHost { + t.Errorf("normalized host = %q, want %q", got, tt.wantHost) + } + + if strings.HasPrefix(tt.rHost, "[") { + specificPolicy := &AutomationPolicy{subjects: []string{tt.wantHost}} + defaultPolicy := &AutomationPolicy{} + tlsApp := &TLS{ + Automation: &AutomationConfig{ + Policies: []*AutomationPolicy{specificPolicy}, + defaultPublicAutomationPolicy: defaultPolicy, + defaultInternalAutomationPolicy: defaultPolicy, + }, + } + + // BUG: raw bracketed r.Host does not match the registered subject. + if tlsApp.getAutomationPolicyForName(tt.rHost) == specificPolicy { + t.Errorf("getAutomationPolicyForName(%q): should NOT match specific policy (demonstrates the bug)", tt.rHost) + } + // FIXED: normalized host correctly matches the registered subject. + if tlsApp.getAutomationPolicyForName(tt.wantHost) != specificPolicy { + t.Errorf("getAutomationPolicyForName(%q): should match specific policy", tt.wantHost) + } + } + }) + } +}