Skip to content
Merged
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
23 changes: 23 additions & 0 deletions caddytest/integration/autohttps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,26 @@ func TestAutoHTTPRedirectsInsertedBeforeUserDefinedCatchAllWithNoExplicitHTTPSit
tester.AssertGetResponse("http://foo.localhost:9080/", 200, "Foo")
tester.AssertGetResponse("http://baz.localhost:9080/", 200, "Foo")
}

func TestAutoHTTPSRedirectSortingExactMatchOverWildcard(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
local_certs
}
*.localhost:10443 {
respond "Wildcard"
}
dev.localhost {
respond "Exact"
}
`, "caddyfile")

tester.AssertRedirect("http://dev.localhost:9080/", "https://dev.localhost/", http.StatusPermanentRedirect)

tester.AssertRedirect("http://foo.localhost:9080/", "https://foo.localhost:10443/", http.StatusPermanentRedirect)
}
57 changes: 57 additions & 0 deletions modules/caddyhttp/autohttps.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,40 @@ redirServersLoop:
// we'll create a new server for all the listener addresses
// that are unused and serve the remaining redirects from it

// Sort redirect routes by host specificity to ensure exact matches
// take precedence over wildcards, preventing ambiguous routing.
slices.SortFunc(routes, func(a, b Route) int {
hostA := getFirstHostFromRoute(a)
hostB := getFirstHostFromRoute(b)

// Catch-all routes (empty host) have the lowest priority
if hostA == "" && hostB != "" {
return 1
}
if hostB == "" && hostA != "" {
return -1
}

hasWildcardA := strings.Contains(hostA, "*")
hasWildcardB := strings.Contains(hostB, "*")

// Exact domains take precedence over wildcards
if !hasWildcardA && hasWildcardB {
return -1
}
if hasWildcardA && !hasWildcardB {
return 1
}

// If both are exact or both are wildcards, the longer one is more specific
if len(hostA) != len(hostB) {
return len(hostB) - len(hostA)
}

// Tie-breaker: alphabetical order to ensure determinism
return strings.Compare(hostA, hostB)
})

// Use the sorted srvNames to consistently find the target server
for _, srvName := range srvNames {
srv := app.Servers[srvName]
Expand Down Expand Up @@ -793,3 +827,26 @@ func isTailscaleDomain(name string) bool {
}

type acmeCapable interface{ GetACMEIssuer() *caddytls.ACMEIssuer }

// getFirstHostFromRoute traverses a route's matchers to find the Host rule.
// Since we are dealing with internally generated redirect routes, the host
// is typically the first string within the MatchHost.
func getFirstHostFromRoute(r Route) string {
for _, matcherSet := range r.MatcherSets {
for _, m := range matcherSet {
// Check if the matcher is of type MatchHost (value or pointer)
switch hm := m.(type) {
case MatchHost:
if len(hm) > 0 {
return hm[0]
}
case *MatchHost:
if len(*hm) > 0 {
return (*hm)[0]
}
}
}
}
// Return an empty string if it's a catch-all route (no specific host)
return ""
}
Loading