internal/server/route_specificity.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"sort"
"strings"
)
// v2.61.3 (issue #129): order host routes by specificity.
//
// Caddy runs the FIRST route whose matcher fits a request. CaddyUI emitted host
// routes in list order (manual sort order, then newest first), so a wildcard
// host such as *.example.com created after app.example.com came first — and a
// request for app.example.com was served by the wildcard's route, skipping
// everything on its own route: the IP allowlist, Basic Auth, forward auth, a
// middleware profile, the WAF. Verified on a real Caddy: an allowlisted host
// answered 200 to a client outside its allowlist. Caddy's own Caddyfile adapter
// always puts exact names before wildcards; this does the same.
//
// Only routes that match on hostnames move, and only among the positions such
// routes already occupy, so routes without a host matcher (global maintenance,
// a catch-all Advanced route, the fallback) keep their exact place. The sort is
// stable, so the manual order still decides between routes of equal specificity.
// routeSpecificity is the sort key: lower runs first. ok is false for a route
// that does not match on hostnames.
func routeSpecificity(route any) (key [3]int, ok bool) {
m, _ := route.(map[string]any)
if m == nil {
return key, false
}
var hosts []string
hasPath := false
collect := func(set map[string]any) {
switch h := set["host"].(type) {
case []any:
for _, v := range h {
if s, ok := v.(string); ok {
hosts = append(hosts, s)
}
}
case []string:
hosts = append(hosts, h...)
}
if _, ok := set["path"]; ok {
hasPath = true
}
if _, ok := set["path_regexp"]; ok {
hasPath = true
}
}
switch match := m["match"].(type) {
case []any:
for _, s := range match {
if set, ok := s.(map[string]any); ok {
collect(set)
}
}
case []map[string]any:
for _, set := range match {
collect(set)
}
}
if len(hosts) == 0 {
return key, false
}
// A route is as general as its least specific hostname: one that also
// carries *.example.com must not run before an exact name it would cover.
wildcard, minLabels := 0, 1<<30
for _, h := range hosts {
if strings.Contains(h, "*") {
wildcard = 1
if n := strings.Count(strings.Trim(h, "."), ".") + 1; n < minLabels {
minLabels = n
}
}
}
key[0] = wildcard
if wildcard == 1 {
key[1] = -minLabels // more labels (*.a.example.com) before fewer (*.example.com)
}
if !hasPath {
key[2] = 1 // a path-scoped route before the same host's catch-all route
}
return key, true
}
// sortRoutesBySpecificity returns routes with the host-matched ones reordered
// most-specific-first, in place of each other.
func sortRoutesBySpecificity(routes []any) []any {
type item struct {
route any
key [3]int
}
var slots []int
var items []item
for i, r := range routes {
if k, ok := routeSpecificity(r); ok {
slots = append(slots, i)
items = append(items, item{r, k})
}
}
if len(items) < 2 {
return routes
}
sort.SliceStable(items, func(a, b int) bool {
ka, kb := items[a].key, items[b].key
for i := range ka {
if ka[i] != kb[i] {
return ka[i] < kb[i]
}
}
return false
})
out := make([]any, len(routes))
copy(out, routes)
for i, slot := range slots {
out[slot] = items[i].route
}
return out
}