Skip to content
Closed
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
30 changes: 30 additions & 0 deletions modules/caddytls/automation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}},
Expand Down
27 changes: 22 additions & 5 deletions modules/caddytls/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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
}
}
Expand Down
95 changes: 95 additions & 0 deletions modules/caddytls/tls_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
Loading