mirror of
https://github.com/caddyserver/caddy.git
synced 2026-09-15 15:17:25 -04:00
* 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.
298 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|