http: Sort auto-HTTPS redirect routes by host specificity (fixes #7390) (#7502)
Some checks failed
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Failing after 1m49s
Tests / test (s390x on IBM Z) (push) Has been skipped
Tests / goreleaser-check (push) Has been skipped
Cross-Build / build (~1.26.0, 1.26, aix) (push) Successful in 1m46s
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Successful in 1m31s
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Successful in 1m26s
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Successful in 1m31s
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Successful in 1m38s
Cross-Build / build (~1.26.0, 1.26, linux) (push) Successful in 1m36s
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Successful in 2m22s
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Successful in 1m40s
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Successful in 1m57s
Cross-Build / build (~1.26.0, 1.26, windows) (push) Successful in 1m27s
Lint / lint (ubuntu-latest, linux) (push) Successful in 2m13s
Lint / govulncheck (push) Successful in 2m1s
Lint / dependency-review (push) Failing after 1m12s
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Failing after 50s
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Has been cancelled
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Has been cancelled
Lint / lint (macos-14, mac) (push) Has been cancelled
Lint / lint (windows-latest, windows) (push) Has been cancelled

This commit is contained in:
Paulo Henrique 2026-02-21 23:42:40 -03:00 committed by GitHub
parent 7ffb640a4d
commit 76b198f586
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 80 additions and 0 deletions

View File

@ -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)
}

View File

@ -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 ""
}