diff --git a/caddytest/integration/autohttps_test.go b/caddytest/integration/autohttps_test.go index 1dbdbcee2..fdfb5a93e 100644 --- a/caddytest/integration/autohttps_test.go +++ b/caddytest/integration/autohttps_test.go @@ -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) +} diff --git a/modules/caddyhttp/autohttps.go b/modules/caddyhttp/autohttps.go index 8bcaebe69..2ae50f725 100644 --- a/modules/caddyhttp/autohttps.go +++ b/modules/caddyhttp/autohttps.go @@ -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] @@ -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 "" +}