diff --git a/go.mod b/go.mod index 3230b47cf18..4c72169695a 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/caddyserver/certmagic v0.25.3 github.com/caddyserver/zerossl v0.1.5 github.com/cloudflare/circl v1.6.3 + github.com/dunglas/go-urlpattern v0.0.0-20260421141449-cbab7cf1e16d github.com/dustin/go-humanize v1.0.1 github.com/go-chi/chi/v5 v5.2.5 github.com/google/cel-go v0.28.1 @@ -58,6 +59,7 @@ require ( dario.cat/mergo v1.0.2 // indirect filippo.io/bigmod v0.1.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/ccoveille/go-safecast/v2 v2.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect @@ -76,6 +78,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nlnwa/whatwg-url v0.6.2 // indirect github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect @@ -107,7 +110,7 @@ require ( go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/oauth2 v0.36.0 // indirect google.golang.org/api v0.277.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect diff --git a/go.sum b/go.sum index 080d4c6f0fe..37993678c0a 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,9 @@ github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/caddyserver/certmagic v0.25.3 h1:mGf5ba8F7xA4c5jfDZZbK2buY1VEkbnwpMDixaju94A= github.com/caddyserver/certmagic v0.25.3/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA= github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= @@ -135,6 +138,8 @@ github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dunglas/go-urlpattern v0.0.0-20260421141449-cbab7cf1e16d h1:Mw8vvAx2b5bjIrQIJaVTO25vJY5DMBUO/hJGNLIl+6g= +github.com/dunglas/go-urlpattern v0.0.0-20260421141449-cbab7cf1e16d/go.mod h1:9qyjDljBPOWyWCGz7vo3Ek7cdnoG/DVk0Ucle7gWVS8= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -254,6 +259,8 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q= +github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= @@ -455,13 +462,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 h1:FmKxj9ocLKn45jiR2jQMwCVhDvaK7fKQFzfuT9GvyK8= golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -477,6 +485,7 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -487,6 +496,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= @@ -505,6 +515,7 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -516,6 +527,7 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= @@ -527,6 +539,7 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= diff --git a/modules/caddyhttp/celmatcher_test.go b/modules/caddyhttp/celmatcher_test.go index 459d5073392..3e44fbbc953 100644 --- a/modules/caddyhttp/celmatcher_test.go +++ b/modules/caddyhttp/celmatcher_test.go @@ -263,6 +263,38 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV urlTarget: "https://example.com/foo/bar", wantResult: false, }, + { + name: "url_pattern matches named group (MatchURLPattern)", + expression: &MatchExpression{ + Expr: `url_pattern('/books/:id')`, + }, + urlTarget: "https://example.com/books/123", + wantResult: true, + }, + { + name: "url_pattern does not match (MatchURLPattern)", + expression: &MatchExpression{ + Expr: `url_pattern('/books/:id')`, + }, + urlTarget: "https://example.com/movies/123", + wantResult: false, + }, + { + name: "url_pattern with base_url matches host (MatchURLPattern)", + expression: &MatchExpression{ + Expr: `url_pattern('/foo', 'https://example.com')`, + }, + urlTarget: "https://example.com/foo", + wantResult: true, + }, + { + name: "url_pattern with base_url rejects other host (MatchURLPattern)", + expression: &MatchExpression{ + Expr: `url_pattern('/foo', 'https://example.com')`, + }, + urlTarget: "https://other.com/foo", + wantResult: false, + }, { name: "protocol matches (MatchProtocol)", expression: &MatchExpression{ diff --git a/modules/caddyhttp/urlpatternmatcher.go b/modules/caddyhttp/urlpatternmatcher.go new file mode 100644 index 00000000000..10dc0123e4a --- /dev/null +++ b/modules/caddyhttp/urlpatternmatcher.go @@ -0,0 +1,213 @@ +package caddyhttp + +import ( + "fmt" + "net/http" + "strings" + + "github.com/dunglas/go-urlpattern" + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types/ref" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" +) + +// urlPatternPlaceholderPrefix namespaces placeholders for captured groups. +const urlPatternPlaceholderPrefix = "http.url_pattern" + +func init() { + caddy.RegisterModule(&MatchURLPattern{}) +} + +// MatchURLPattern matches requests against a [URLPattern], giving named +// groups, wildcards and regexp components beyond what the simpler path +// matcher offers. +// +// [URLPattern]: https://urlpattern.spec.whatwg.org/ +type MatchURLPattern struct { + // Pattern is the URLPattern to match against. A relative pattern (e.g. + // "/books/:id") matches the request path on any host; an absolute pattern + // (e.g. "https://example.com/books/:id") also constrains scheme and host. + Pattern string `json:"pattern,omitempty"` + + // BaseURL resolves a relative Pattern against a fixed origin, scoping the + // match to that scheme and host. Leave empty to match any origin. + BaseURL string `json:"base_url,omitempty"` + + // IgnoreCase matches the pattern case-insensitively. + IgnoreCase bool `json:"ignore_case,omitempty"` + + compiledPattern *urlpattern.URLPattern +} + +// CaddyModule returns the Caddy module information. +func (*MatchURLPattern) CaddyModule() caddy.ModuleInfo { + return caddy.ModuleInfo{ + ID: "http.matchers.url_pattern", + New: func() caddy.Module { return new(MatchURLPattern) }, + } +} + +// Provision compiles the URL pattern. +func (m *MatchURLPattern) Provision(_ caddy.Context) error { + input := m.Pattern + + // A relative pattern with no base matches any origin: prefix wildcard + // protocol, host and port so only the path, search and hash are + // constrained. The port wildcard is needed because a request Host may + // carry an explicit port. Host-scoped matching stays opt-in via an + // absolute pattern or base_url. + if m.BaseURL == "" && !strings.Contains(input, "://") { + input = "*://*:*" + input + } + + p, err := urlpattern.New(input, m.BaseURL, &urlpattern.Options{IgnoreCase: m.IgnoreCase}) + if err != nil { + return fmt.Errorf("unable to parse URL pattern: %w", err) + } + + m.compiledPattern = p + + return nil +} + +// Match returns true if the request matches the URL pattern. +func (m *MatchURLPattern) Match(r *http.Request) bool { + ok, _ := m.MatchWithError(r) + + return ok +} + +// MatchWithError returns true if the request matches the URL pattern. The +// request's origin (scheme://host) is the base against which the path is +// resolved, so an absolute pattern or base_url can match on scheme and host. +// +// On a match, captured groups are exposed as placeholders scoped by URL +// component, mirroring the URLPattern result object: a named group :id in the +// pathname becomes {http.url_pattern.pathname.id}, a group q in the query +// becomes {http.url_pattern.search.q}, and so on. +func (m *MatchURLPattern) MatchWithError(r *http.Request) (bool, error) { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + result := m.compiledPattern.Exec(r.URL.RequestURI(), scheme+"://"+r.Host) + if result == nil { + return false, nil + } + + if repl, ok := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer); ok { + setURLPatternGroups(repl, "protocol", result.Protocol) + setURLPatternGroups(repl, "username", result.Username) + setURLPatternGroups(repl, "password", result.Password) + setURLPatternGroups(repl, "hostname", result.Hostname) + setURLPatternGroups(repl, "port", result.Port) + setURLPatternGroups(repl, "pathname", result.Pathname) + setURLPatternGroups(repl, "search", result.Search) + setURLPatternGroups(repl, "hash", result.Hash) + } + + return true, nil +} + +// setURLPatternGroups publishes a component's captured groups as +// {http.url_pattern..} placeholders. +func setURLPatternGroups(repl *caddy.Replacer, component string, c urlpattern.URLPatternComponentResult) { + for name, value := range c.Groups { + repl.Set(urlPatternPlaceholderPrefix+"."+component+"."+name, value) + } +} + +// CELLibrary produces options that expose this matcher for use in CEL +// expression matchers. +// +// Example: +// +// expression url_pattern('/books/:id') +// expression url_pattern('/books/:id', 'https://example.com') +func (MatchURLPattern) CELLibrary(ctx caddy.Context) (cel.Library, error) { + pattern, err := CELMatcherImpl( + "url_pattern", + "url_pattern_request_string", + []*cel.Type{cel.StringType}, + func(data ref.Val) (RequestMatcherWithError, error) { + pattern, ok := data.Value().(string) + if !ok { + return nil, fmt.Errorf("url_pattern expects a string argument") + } + + matcher := MatchURLPattern{Pattern: pattern} + err := matcher.Provision(ctx) + + return &matcher, err + }, + ) + if err != nil { + return nil, err + } + + patternWithBase, err := CELMatcherImpl( + "url_pattern", + "url_pattern_request_string_string", + []*cel.Type{cel.StringType, cel.StringType}, + func(data ref.Val) (RequestMatcherWithError, error) { + params, err := data.ConvertToNative(stringSliceType) + if err != nil { + return nil, err + } + strParams := params.([]string) + matcher := MatchURLPattern{Pattern: strParams[0], BaseURL: strParams[1]} + err = matcher.Provision(ctx) + return &matcher, err + }, + ) + if err != nil { + return nil, err + } + + envOpts := append(pattern.CompileOptions(), patternWithBase.CompileOptions()...) + prgOpts := append(pattern.ProgramOptions(), patternWithBase.ProgramOptions()...) + return NewMatcherCELLibrary(envOpts, prgOpts), nil +} + +// UnmarshalCaddyfile implements caddyfile.Unmarshaler. Syntax: +// +// url_pattern { +// base_url +// ignore_case +// } +func (m *MatchURLPattern) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { + for d.Next() { + if !d.Args(&m.Pattern) { + return d.Err("expected exactly one URL pattern") + } + + for d.NextBlock(0) { + switch d.Val() { + case "base_url": + if !d.Args(&m.BaseURL) { + return d.ArgErr() + } + case "ignore_case": + if d.NextArg() { + return d.ArgErr() + } + m.IgnoreCase = true + default: + return d.Errf("unrecognized url_pattern option '%s'", d.Val()) + } + } + } + + return nil +} + +// Interface guards +var ( + _ RequestMatcherWithError = (*MatchURLPattern)(nil) + _ caddy.Provisioner = (*MatchURLPattern)(nil) + _ caddyfile.Unmarshaler = (*MatchURLPattern)(nil) + _ CELLibraryProducer = (*MatchURLPattern)(nil) +) diff --git a/modules/caddyhttp/urlpatternmatcher_test.go b/modules/caddyhttp/urlpatternmatcher_test.go new file mode 100644 index 00000000000..fee1ae1d03e --- /dev/null +++ b/modules/caddyhttp/urlpatternmatcher_test.go @@ -0,0 +1,241 @@ +package caddyhttp + +import ( + "context" + "crypto/tls" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" +) + +func TestURLPatternMatcher(t *testing.T) { + for _, tc := range []struct { + name string + match MatchURLPattern + host string + tls bool + input string + expect bool + provisionErr bool + }{ + { + name: "literal path matches", + match: MatchURLPattern{Pattern: "/foo"}, + host: "example.com", + input: "/foo", + expect: true, + }, + { + name: "literal path mismatch", + match: MatchURLPattern{Pattern: "/foo"}, + host: "example.com", + input: "/bar", + expect: false, + }, + { + name: "named group matches", + match: MatchURLPattern{Pattern: "/books/:id"}, + host: "example.com", + input: "/books/123", + expect: true, + }, + { + name: "named group requires segment", + match: MatchURLPattern{Pattern: "/books/:id"}, + host: "example.com", + input: "/books", + expect: false, + }, + { + name: "wildcard spans segments", + match: MatchURLPattern{Pattern: "/files/*"}, + host: "example.com", + input: "/files/a/b/c", + expect: true, + }, + { + name: "absolute pattern matches host and scheme", + match: MatchURLPattern{Pattern: "https://example.com/foo"}, + host: "example.com", + tls: true, + input: "/foo", + expect: true, + }, + { + name: "absolute pattern rejects scheme mismatch", + match: MatchURLPattern{Pattern: "https://example.com/foo"}, + host: "example.com", + input: "/foo", + expect: false, + }, + { + name: "absolute pattern rejects host mismatch", + match: MatchURLPattern{Pattern: "https://example.com/foo"}, + host: "other.com", + tls: true, + input: "/foo", + expect: false, + }, + { + name: "ignore_case matches mixed case", + match: MatchURLPattern{Pattern: "/foo", IgnoreCase: true}, + host: "example.com", + input: "/FOO", + expect: true, + }, + { + name: "case sensitive by default", + match: MatchURLPattern{Pattern: "/foo"}, + host: "example.com", + input: "/FOO", + expect: false, + }, + { + name: "base_url scopes to host", + match: MatchURLPattern{Pattern: "/search", BaseURL: "https://example.com"}, + host: "example.com", + tls: true, + input: "/search?q=caddy", + expect: true, + }, + { + name: "base_url rejects other host", + match: MatchURLPattern{Pattern: "/search", BaseURL: "https://example.com"}, + host: "other.com", + tls: true, + input: "/search", + expect: false, + }, + { + name: "invalid pattern fails provisioning", + match: MatchURLPattern{Pattern: "https://[invalid"}, + provisionErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := tc.match.Provision(caddy.Context{}) + if tc.provisionErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + u, err := url.ParseRequestURI(tc.input) + require.NoError(t, err) + + req := &http.Request{URL: u, Host: tc.host} + if tc.tls { + req.TLS = &tls.ConnectionState{} + } + + actual, err := tc.match.MatchWithError(req) + require.NoError(t, err) + assert.Equal(t, tc.expect, actual) + }) + } +} + +func TestURLPatternMatcherUnmarshalCaddyfile(t *testing.T) { + for _, tc := range []struct { + name string + input string + expect MatchURLPattern + expectErr bool + }{ + { + name: "pattern only", + input: `url_pattern /books/:id`, + expect: MatchURLPattern{Pattern: "/books/:id"}, + }, + { + name: "base_url and ignore_case", + input: `url_pattern /search { + base_url https://example.com + ignore_case + }`, + expect: MatchURLPattern{Pattern: "/search", BaseURL: "https://example.com", IgnoreCase: true}, + }, + { + name: "missing pattern", + input: `url_pattern`, + expectErr: true, + }, + { + name: "unknown option", + input: `url_pattern /foo { + nope + }`, + expectErr: true, + }, + { + name: "base_url without value", + input: `url_pattern /foo { + base_url + }`, + expectErr: true, + }, + { + name: "ignore_case with stray arg", + input: `url_pattern /foo { + ignore_case yes + }`, + expectErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var m MatchURLPattern + err := m.UnmarshalCaddyfile(caddyfile.NewTestDispenser(tc.input)) + if tc.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expect, m) + }) + } +} + +// TestURLPatternMatcherGroups checks that captured groups are exposed as +// component-scoped placeholders, mirroring the URLPattern result object. +func TestURLPatternMatcherGroups(t *testing.T) { + m := MatchURLPattern{Pattern: "/books/:id/chapters/:chapter"} + require.NoError(t, m.Provision(caddy.Context{})) + + u, err := url.ParseRequestURI("/books/42/chapters/7") + require.NoError(t, err) + + repl := caddy.NewReplacer() + ctx := context.WithValue(context.Background(), caddy.ReplacerCtxKey, repl) + req := (&http.Request{URL: u, Host: "example.com"}).WithContext(ctx) + + ok, err := m.MatchWithError(req) + require.NoError(t, err) + require.True(t, ok) + + id, _ := repl.GetString("http.url_pattern.pathname.id") + assert.Equal(t, "42", id) + chapter, _ := repl.GetString("http.url_pattern.pathname.chapter") + assert.Equal(t, "7", chapter) +} + +// TestURLPatternMatcherRelative checks that a relative pattern matches the +// request path regardless of the request's host. +func TestURLPatternMatcherRelative(t *testing.T) { + m := MatchURLPattern{Pattern: "/books/:id"} + require.NoError(t, m.Provision(caddy.Context{})) + + for _, host := range []string{"example.com", "other.org", "192.0.2.1:8080"} { + u, err := url.ParseRequestURI("/books/42") + require.NoError(t, err) + + ok, err := m.MatchWithError(&http.Request{URL: u, Host: host}) + require.NoError(t, err) + assert.Truef(t, ok, "expected match on host %q", host) + } +}