Skip to content

Commit 4fd42c2

Browse files
committed
http: Sort auto-HTTPS redirect routes by host specificity (fixes #7390)
1 parent 03243e4 commit 4fd42c2

File tree

2 files changed

+80
-0
lines changed

2 files changed

+80
-0
lines changed

caddytest/integration/autohttps_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,26 @@ func TestAutoHTTPRedirectsInsertedBeforeUserDefinedCatchAllWithNoExplicitHTTPSit
143143
tester.AssertGetResponse("http://foo.localhost:9080/", 200, "Foo")
144144
tester.AssertGetResponse("http://baz.localhost:9080/", 200, "Foo")
145145
}
146+
147+
func TestAutoHTTPSRedirectSortingExactMatchOverWildcard(t *testing.T) {
148+
tester := caddytest.NewTester(t)
149+
tester.InitServer(`
150+
{
151+
skip_install_trust
152+
admin localhost:2999
153+
http_port 9080
154+
https_port 9443
155+
local_certs
156+
}
157+
*.localhost:10443 {
158+
respond "Wildcard"
159+
}
160+
dev.localhost {
161+
respond "Exact"
162+
}
163+
`, "caddyfile")
164+
165+
tester.AssertRedirect("http://dev.localhost:9080/", "https://dev.localhost/", http.StatusPermanentRedirect)
166+
167+
tester.AssertRedirect("http://foo.localhost:9080/", "https://foo.localhost:10443/", http.StatusPermanentRedirect)
168+
}

modules/caddyhttp/autohttps.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,40 @@ redirServersLoop:
424424
// we'll create a new server for all the listener addresses
425425
// that are unused and serve the remaining redirects from it
426426

427+
// Sort redirect routes by host specificity to ensure exact matches
428+
// take precedence over wildcards, preventing ambiguous routing.
429+
slices.SortFunc(routes, func(a, b Route) int {
430+
hostA := getFirstHostFromRoute(a)
431+
hostB := getFirstHostFromRoute(b)
432+
433+
// Catch-all routes (empty host) have the lowest priority
434+
if hostA == "" && hostB != "" {
435+
return 1
436+
}
437+
if hostB == "" && hostA != "" {
438+
return -1
439+
}
440+
441+
hasWildcardA := strings.Contains(hostA, "*")
442+
hasWildcardB := strings.Contains(hostB, "*")
443+
444+
// Exact domains take precedence over wildcards
445+
if !hasWildcardA && hasWildcardB {
446+
return -1
447+
}
448+
if hasWildcardA && !hasWildcardB {
449+
return 1
450+
}
451+
452+
// If both are exact or both are wildcards, the longer one is more specific
453+
if len(hostA) != len(hostB) {
454+
return len(hostB) - len(hostA)
455+
}
456+
457+
// Tie-breaker: alphabetical order to ensure determinism
458+
return strings.Compare(hostA, hostB)
459+
})
460+
427461
// Use the sorted srvNames to consistently find the target server
428462
for _, srvName := range srvNames {
429463
srv := app.Servers[srvName]
@@ -793,3 +827,26 @@ func isTailscaleDomain(name string) bool {
793827
}
794828

795829
type acmeCapable interface{ GetACMEIssuer() *caddytls.ACMEIssuer }
830+
831+
// getFirstHostFromRoute traverses a route's matchers to find the Host rule.
832+
// Since we are dealing with internally generated redirect routes, the host
833+
// is typically the first string within the MatchHost.
834+
func getFirstHostFromRoute(r Route) string {
835+
for _, matcherSet := range r.MatcherSets {
836+
for _, m := range matcherSet {
837+
// Check if the matcher is of type MatchHost (value or pointer)
838+
switch hm := m.(type) {
839+
case MatchHost:
840+
if len(hm) > 0 {
841+
return hm[0]
842+
}
843+
case *MatchHost:
844+
if len(*hm) > 0 {
845+
return (*hm)[0]
846+
}
847+
}
848+
}
849+
}
850+
// Return an empty string if it's a catch-all route (no specific host)
851+
return ""
852+
}

0 commit comments

Comments
 (0)