Files
caddy/modules/caddyhttp/urlpatternmatcher_test.go
Kévin Dunglas 789f60e337 caddyhttp: fix url_pattern authorization bypass via encoded-slash traversal (#7941)
* caddyhttp: match url_pattern against decoded, cleaned path

The url_pattern matcher evaluated the raw, percent-encoded request URI
while path-consuming handlers resolve the decoded, cleaned r.URL.Path.
An encoded-slash payload such as "..%2f" stayed a single opaque segment
for the WHATWG URLPattern parser, so "/public/..%2fadmin/secret" matched
"/public/*" while handlers decoded it to "/admin/secret", bypassing any
route-level access control built with url_pattern.

Match the same path model handlers resolve: decode the path, normalize
and clean it (mirroring the path matcher and #4407), then re-encode
through url.URL to a canonical escaped form before running the pattern.
The go-urlpattern library is spec-correct; the fix is in the integration.

* build(deps): bump github.com/dunglas/go-urlpattern to v1.0.0

Moves off the pseudo-version to the first tagged release.
2026-08-15 10:38:20 -06:00

298 lines
7.3 KiB
Go

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: "encoded slash traversal is rejected",
match: MatchURLPattern{Pattern: "/public/*"},
host: "example.com",
input: "/public/..%2fadmin/secret.txt",
expect: false,
},
{
name: "uppercase encoded slash traversal is rejected",
match: MatchURLPattern{Pattern: "/public/*"},
host: "example.com",
input: "/public/..%2Fadmin/secret.txt",
expect: false,
},
{
name: "encoded dot traversal is rejected",
match: MatchURLPattern{Pattern: "/public/*"},
host: "example.com",
input: "/public/%2e%2e/admin/secret.txt",
expect: false,
},
{
name: "raw dot traversal is rejected",
match: MatchURLPattern{Pattern: "/public/*"},
host: "example.com",
input: "/public/../admin/secret.txt",
expect: false,
},
{
name: "cleaned traversal still matches its real target",
match: MatchURLPattern{Pattern: "/admin/*"},
host: "example.com",
input: "/public/..%2fadmin/secret.txt",
expect: true,
},
{
name: "doubled slashes are collapsed before matching",
match: MatchURLPattern{Pattern: "/public/*"},
host: "example.com",
input: "/public//nested//file",
expect: true,
},
{
name: "pattern with empty segment preserves request slashes",
match: MatchURLPattern{Pattern: "/a//b"},
host: "example.com",
input: "/a//b",
expect: true,
},
{
name: "encoded characters match their decoded form",
match: MatchURLPattern{Pattern: "/caf%C3%A9"},
host: "example.com",
input: "/caf%C3%A9",
expect: true,
},
{
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)
}
}