caddy/modules/caddyhttp/matchers_test.go
Ta Duc Thien c6180a0852
Some checks are pending
Tests / test (./cmd/caddy/caddy, ~1.26.0, macos-14, 0, 1.26, mac) (push) Waiting to run
Tests / test (./cmd/caddy/caddy, ~1.26.0, ubuntu-latest, 0, 1.26, linux) (push) Waiting to run
Tests / test (./cmd/caddy/caddy.exe, ~1.26.0, windows-latest, True, 1.26, windows) (push) Waiting to run
Tests / test (s390x on IBM Z) (push) Waiting to run
Tests / goreleaser-check (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, aix) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, darwin) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, dragonfly) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, freebsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, illumos) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, linux) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, netbsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, openbsd) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, solaris) (push) Waiting to run
Cross-Build / build (~1.26.0, 1.26, windows) (push) Waiting to run
Lint / lint (push) Waiting to run
Lint / lint-1 (push) Waiting to run
Lint / lint-2 (push) Waiting to run
Lint / govulncheck (push) Waiting to run
Lint / dependency-review (push) Waiting to run
OpenSSF Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
caddyhttp: fix path_regexp (MatchPathRE) Windows backslash bypass (#7858)
* caddyhttp: normalize Windows path in path_regexp matcher, shared with path matcher

Apply the same Windows path normalization to MatchPathRE that MatchPath already had, and factor it into a shared normalizeWindowsPath helper so both matchers use one implementation.

Also strip trailing dots and spaces per path component (not only at the end of the whole path), matching how Windows resolves paths; this fixes the same gap in the path matcher too. Adds regression tests for both matchers.

See #5613.

* caddyhttp: normalize trailing dots/spaces in escaped-path branch too

The MatchPath escaped-path branch (taken when a matcher pattern contains
'%') only folded backslash separators via windowsEscapedPathSeparatorRepl;
it skipped the per-component trailing dot/space normalization now applied
on the decoded branch. On Windows this left a bypass: a matcher such as
`path /private%2f*` could be evaded by GET /private.%5csecret.txt, since
`private.` and `private` resolve to the same directory on NTFS.

Add normalizeWindowsEscapedPath, which trims trailing dots and spaces —
literal ("." / " ") and percent-encoded ("%2e" / "%20") — from every
component in raw/escaped space, splitting on both '/' and encoded '%2f'
separators while preserving them, and leaving "." / ".." for CleanPath.
Regression tests cover the literal and percent-encoded variants against a
'%'-containing matcher.

---------

Co-authored-by: thientd <thien.taduc@ninhthanh.com>
2026-07-11 08:37:44 +10:00

1444 lines
36 KiB
Go

// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package caddyhttp
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"runtime"
"testing"
"github.com/caddyserver/caddy/v2"
)
func TestHostMatcher(t *testing.T) {
err := os.Setenv("GO_BENCHMARK_DOMAIN", "localhost")
if err != nil {
t.Errorf("error while setting up environment: %v", err)
}
for i, tc := range []struct {
match MatchHost
input string
expect bool
}{
{
match: MatchHost{},
input: "example.com",
expect: false,
},
{
match: MatchHost{"example.com"},
input: "example.com",
expect: true,
},
{
match: MatchHost{"EXAMPLE.COM"},
input: "example.com",
expect: true,
},
{
match: MatchHost{"example.com"},
input: "EXAMPLE.COM",
expect: true,
},
{
match: MatchHost{"example.com"},
input: "foo.example.com",
expect: false,
},
{
match: MatchHost{"example.com"},
input: "EXAMPLE.COM",
expect: true,
},
{
match: MatchHost{"foo.example.com"},
input: "foo.example.com",
expect: true,
},
{
match: MatchHost{"foo.example.com"},
input: "bar.example.com",
expect: false,
},
{
match: MatchHost{"éxàmplê.com"},
input: "xn--xmpl-0na6cm.com",
expect: true,
},
{
match: MatchHost{"*.example.com"},
input: "example.com",
expect: false,
},
{
match: MatchHost{"*.example.com"},
input: "SUB.EXAMPLE.COM",
expect: true,
},
{
match: MatchHost{"*.example.com"},
input: "foo.example.com",
expect: true,
},
{
match: MatchHost{"*.example.com"},
input: "foo.bar.example.com",
expect: false,
},
{
match: MatchHost{"*.example.com", "example.net"},
input: "example.net",
expect: true,
},
{
match: MatchHost{"example.net", "*.example.com"},
input: "foo.example.com",
expect: true,
},
{
match: MatchHost{"*.example.net", "*.*.example.com"},
input: "foo.bar.example.com",
expect: true,
},
{
match: MatchHost{"*.example.net", "sub.*.example.com"},
input: "sub.foo.example.com",
expect: true,
},
{
match: MatchHost{"*.example.net", "sub.*.example.com"},
input: "sub.foo.example.net",
expect: false,
},
{
match: MatchHost{"www.*.*"},
input: "www.example.com",
expect: true,
},
{
match: MatchHost{"example.com"},
input: "example.com:5555",
expect: true,
},
{
match: MatchHost{"{env.GO_BENCHMARK_DOMAIN}"},
input: "localhost",
expect: true,
},
{
match: MatchHost{"{env.GO_NONEXISTENT}"},
input: "localhost",
expect: false,
},
} {
req := &http.Request{Host: tc.input}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
if err := tc.match.Provision(caddy.Context{}); err != nil {
t.Errorf("Test %d %v: provisioning failed: %v", i, tc.match, err)
}
actual, err := tc.match.MatchWithError(req)
if err != nil {
t.Errorf("Test %d %v: matching failed: %v", i, tc.match, err)
}
if actual != tc.expect {
t.Errorf("Test %d %v: Expected %t, got %t for '%s'", i, tc.match, tc.expect, actual, tc.input)
continue
}
}
}
func TestPathMatcher(t *testing.T) {
for i, tc := range []struct {
match MatchPath // not URI-encoded because not parsing from a URI
input string // should be valid URI encoding (escaped) since it will become part of a request
expect bool
provisionErr bool
}{
{
match: MatchPath{},
input: "/",
expect: false,
},
{
match: MatchPath{"/"},
input: "/",
expect: true,
},
{
match: MatchPath{"/foo/bar"},
input: "/",
expect: false,
},
{
match: MatchPath{"/foo/bar"},
input: "/foo/bar",
expect: true,
},
{
match: MatchPath{"/foo/bar/"},
input: "/foo/bar",
expect: false,
},
{
match: MatchPath{"/foo/bar/"},
input: "/foo/bar/",
expect: true,
},
{
match: MatchPath{"/foo/bar/", "/other"},
input: "/other/",
expect: false,
},
{
match: MatchPath{"/foo/bar/", "/other"},
input: "/other",
expect: true,
},
{
match: MatchPath{"*.ext"},
input: "/foo/bar.ext",
expect: true,
},
{
match: MatchPath{"*.php"},
input: "/index.PHP",
expect: true,
},
{
match: MatchPath{"*.ext"},
input: "/foo/bar.ext",
expect: true,
},
{
match: MatchPath{"/foo/*/baz"},
input: "/foo/bar/baz",
expect: true,
},
{
match: MatchPath{"/foo/*/baz/bam"},
input: "/foo/bar/bam",
expect: false,
},
{
match: MatchPath{"*substring*"},
input: "/foo/substring/bar.txt",
expect: true,
},
{
match: MatchPath{"/foo"},
input: "/foo/bar",
expect: false,
},
{
match: MatchPath{"/foo"},
input: "/foo/bar",
expect: false,
},
{
match: MatchPath{"/foo"},
input: "/FOO",
expect: true,
},
{
match: MatchPath{"/foo*"},
input: "/FOOOO",
expect: true,
},
{
match: MatchPath{"/foo/bar.txt"},
input: "/foo/BAR.txt",
expect: true,
},
{
match: MatchPath{"/foo*"},
input: "//foo/bar",
expect: true,
},
{
match: MatchPath{"/foo"},
input: "//foo",
expect: true,
},
{
match: MatchPath{"//foo"},
input: "/foo",
expect: false,
},
{
match: MatchPath{"//foo"},
input: "//foo",
expect: true,
},
{
match: MatchPath{"/foo//*"},
input: "/foo//bar",
expect: true,
},
{
match: MatchPath{"/foo//*"},
input: "/foo/%2Fbar",
expect: true,
},
{
match: MatchPath{"/foo/%2F*"},
input: "/foo/%2Fbar",
expect: true,
},
{
match: MatchPath{"/foo/%2F*"},
input: "/foo//bar",
expect: false,
},
{
match: MatchPath{"/foo//bar"},
input: "/foo//bar",
expect: true,
},
{
match: MatchPath{"/foo/*//bar"},
input: "/foo///bar",
expect: true,
},
{
match: MatchPath{"/foo/%*//bar"},
input: "/foo///bar",
expect: true,
},
{
match: MatchPath{"/foo/%*//bar"},
input: "/foo//%2Fbar",
expect: true,
},
{
match: MatchPath{"/foo*"},
input: "/%2F/foo",
expect: true,
},
{
match: MatchPath{"*"},
input: "/",
expect: true,
},
{
match: MatchPath{"*"},
input: "/foo/bar",
expect: true,
},
{
match: MatchPath{"**"},
input: "/",
expect: true,
},
{
match: MatchPath{"**"},
input: "/foo/bar",
expect: true,
},
// notice these next three test cases are the same normalized path but are written differently
{
match: MatchPath{"/%25@.txt"},
input: "/%25@.txt",
expect: true,
},
{
match: MatchPath{"/%25@.txt"},
input: "/%25%40.txt",
expect: true,
},
{
match: MatchPath{"/%25%40.txt"},
input: "/%25%40.txt",
expect: true,
},
{
match: MatchPath{"/bands/*/*"},
input: "/bands/AC%2FDC/T.N.T",
expect: false, // because * operates in normalized space
},
{
match: MatchPath{"/bands/%*/%*"},
input: "/bands/AC%2FDC/T.N.T",
expect: true,
},
{
match: MatchPath{"/bands/%*/%*"},
input: "/bands/AC/DC/T.N.T",
expect: false,
},
{
match: MatchPath{"/bands/%*"},
input: "/bands/AC/DC",
expect: false, // not a suffix match
},
{
match: MatchPath{"/bands/%*"},
input: "/bands/AC%2FDC",
expect: true,
},
{
match: MatchPath{"/foo%2fbar/baz"},
input: "/foo%2Fbar/baz",
expect: true,
},
{
match: MatchPath{"/foo%2fbar/baz"},
input: "/foo/bar/baz",
expect: false,
},
{
match: MatchPath{"/foo/bar/baz"},
input: "/foo%2fbar/baz",
expect: true,
},
{
match: MatchPath{"/admin%2fpanel"},
input: "/ADMIN%2fpanel",
expect: true,
},
{
match: MatchPath{"/admin%2fpa*el"},
input: "/ADMIN%2fPaAzZLm123NEL",
expect: true,
},
{
match: MatchPath{"/foo%2fbar"},
input: "/foo%2fbarbaz",
expect: false,
},
{
match: MatchPath{"/foo%2f"},
input: "/foo%2fx",
expect: false,
},
{
match: MatchPath{"/admin%2fpanel"},
input: "/admin%2fpanelX",
expect: false,
},
} {
err := tc.match.Provision(caddy.Context{})
if err == nil && tc.provisionErr {
t.Errorf("Test %d %v: Expected error provisioning, but there was no error", i, tc.match)
}
if err != nil && !tc.provisionErr {
t.Errorf("Test %d %v: Expected no error provisioning, but there was an error: %v", i, tc.match, err)
}
if tc.provisionErr {
continue // if it's not supposed to provision properly, pointless to test it
}
u, err := url.ParseRequestURI(tc.input)
if err != nil {
t.Fatalf("Test %d (%v): Invalid request URI (should be rejected by Go's HTTP server): %v", i, tc.input, err)
}
req := &http.Request{URL: u}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
actual, err := tc.match.MatchWithError(req)
if err != nil {
t.Errorf("Test %d %v: matching failed: %v", i, tc.match, err)
}
if actual != tc.expect {
t.Errorf("Test %d %v: Expected %t, got %t for '%s'", i, tc.match, tc.expect, actual, tc.input)
continue
}
}
}
func TestPathMatcherWindows(t *testing.T) {
// only Windows has this bug where it will ignore
// trailing dots and spaces in a filename
if runtime.GOOS != "windows" {
return
}
repl := caddy.NewReplacer()
for _, tc := range []struct {
name string
path string
requestTarget string
match MatchPath
}{
{
name: "trailing dots and spaces",
path: "/index.php . . ..",
match: MatchPath{"*.php"},
},
{
name: "encoded backslash path separator",
requestTarget: `/private%5csecret.txt`,
match: MatchPath{"/private/*"},
},
{
name: "encoded backslash path separator with escaped wildcard",
requestTarget: `/private%5csecret.txt`,
match: MatchPath{"/private/%*"},
},
{
name: "uppercase encoded backslash path separator with escaped wildcard",
requestTarget: `/private%5Csecret.txt`,
match: MatchPath{"/private/%*"},
},
{
name: "encoded backslash in escaped pattern",
requestTarget: `/private%5csecret.txt`,
match: MatchPath{"/private%5c%*"},
},
{
name: "trailing dot on a middle path component",
path: "/private./secret.txt",
match: MatchPath{"/private/*"},
},
{
name: "trailing space on a middle path component",
path: "/private /secret.txt",
match: MatchPath{"/private/*"},
},
{
// escaped-space matcher (pattern contains '%'): the trailing
// dot/space normalization must also apply on the escaped-path
// branch, otherwise /private.%5csecret.txt bypasses /private%2f*.
name: "trailing dot before encoded backslash, escaped-space matcher",
requestTarget: `/private.%5csecret.txt`,
match: MatchPath{"/private%2f*"},
},
{
name: "encoded trailing dot before encoded backslash, escaped-space matcher",
requestTarget: `/private%2e%5csecret.txt`,
match: MatchPath{"/private%2f*"},
},
{
name: "encoded trailing space before encoded backslash, escaped-space matcher",
requestTarget: `/private%20%5csecret.txt`,
match: MatchPath{"/private%2f*"},
},
} {
t.Run(tc.name, func(t *testing.T) {
u := &url.URL{Path: tc.path}
if tc.requestTarget != "" {
var err error
u, err = url.ParseRequestURI(tc.requestTarget)
if err != nil {
t.Fatalf("Parsing request target: %v", err)
}
}
req := &http.Request{URL: u}
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
matched, err := tc.match.MatchWithError(req)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
if !matched {
t.Errorf("Expected %q to match %v", req.URL.Path, tc.match)
}
})
}
}
func TestPathREMatcherWindows(t *testing.T) {
// Windows treats backslashes as path separators and ignores trailing
// dots and spaces per path component, so the path_regexp matcher must
// normalize them the same way the path matcher does (see #5613);
// otherwise a guard such as `path_regexp ^/private/` is bypassed by e.g.
// GET /private\secret.txt (or %5c) or GET /private./secret.txt.
if runtime.GOOS != "windows" {
return
}
repl := caddy.NewReplacer()
for _, tc := range []struct {
name string
path string
requestTarget string
match MatchPathRE
}{
{
name: "literal backslash path separator",
path: `/private\secret.txt`,
match: MatchPathRE{MatchRegexp{Pattern: "^/private/"}},
},
{
name: "encoded backslash path separator",
requestTarget: `/private%5csecret.txt`,
match: MatchPathRE{MatchRegexp{Pattern: "^/private/"}},
},
{
name: "uppercase encoded backslash path separator",
requestTarget: `/private%5Csecret.txt`,
match: MatchPathRE{MatchRegexp{Pattern: "^/private/"}},
},
{
name: "trailing dot on a middle path component",
path: `/private./secret.txt`,
match: MatchPathRE{MatchRegexp{Pattern: "^/private/"}},
},
{
name: "trailing space on a middle path component",
path: `/private /secret.txt`,
match: MatchPathRE{MatchRegexp{Pattern: "^/private/"}},
},
} {
t.Run(tc.name, func(t *testing.T) {
if err := tc.match.Provision(caddy.Context{}); err != nil {
t.Fatalf("Provisioning: %v", err)
}
u := &url.URL{Path: tc.path}
if tc.requestTarget != "" {
var err error
u, err = url.ParseRequestURI(tc.requestTarget)
if err != nil {
t.Fatalf("Parsing request target: %v", err)
}
}
req := &http.Request{URL: u}
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
matched, err := tc.match.MatchWithError(req)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
if !matched {
t.Errorf("Expected %q to match %v", req.URL.Path, tc.match.Pattern)
}
})
}
}
func TestPathREMatcher(t *testing.T) {
for i, tc := range []struct {
match MatchPathRE
input string
expect bool
expectRepl map[string]string
}{
{
match: MatchPathRE{},
input: "/",
expect: true,
},
{
match: MatchPathRE{MatchRegexp{Pattern: "/"}},
input: "/",
expect: true,
},
{
match: MatchPathRE{MatchRegexp{Pattern: "^/foo"}},
input: "/foo",
expect: true,
},
{
match: MatchPathRE{MatchRegexp{Pattern: "^/foo"}},
input: "/foo/",
expect: true,
},
{
match: MatchPathRE{MatchRegexp{Pattern: "^/foo"}},
input: "//foo",
expect: true,
},
{
match: MatchPathRE{MatchRegexp{Pattern: "^/foo"}},
input: "//foo/",
expect: true,
},
{
match: MatchPathRE{MatchRegexp{Pattern: "^/foo"}},
input: "/%2F/foo/",
expect: true,
},
{
match: MatchPathRE{MatchRegexp{Pattern: "/bar"}},
input: "/foo/",
expect: false,
},
{
match: MatchPathRE{MatchRegexp{Pattern: "^/bar"}},
input: "/foo/bar",
expect: false,
},
{
match: MatchPathRE{MatchRegexp{Pattern: "^/foo/(.*)/baz$", Name: "name"}},
input: "/foo/bar/baz",
expect: true,
expectRepl: map[string]string{"name.1": "bar"},
},
{
match: MatchPathRE{MatchRegexp{Pattern: "^/foo/(?P<myparam>.*)/baz$", Name: "name"}},
input: "/foo/bar/baz",
expect: true,
expectRepl: map[string]string{"name.myparam": "bar"},
},
{
match: MatchPathRE{MatchRegexp{Pattern: "^/%@.txt"}},
input: "/%25@.txt",
expect: true,
},
{
match: MatchPathRE{MatchRegexp{Pattern: "^/%25@.txt"}},
input: "/%25@.txt",
expect: false,
},
} {
// compile the regexp and validate its name
err := tc.match.Provision(caddy.Context{})
if err != nil {
t.Errorf("Test %d %v: Provisioning: %v", i, tc.match, err)
continue
}
err = tc.match.Validate()
if err != nil {
t.Errorf("Test %d %v: Validating: %v", i, tc.match, err)
continue
}
// set up the fake request and its Replacer
u, err := url.ParseRequestURI(tc.input)
if err != nil {
t.Fatalf("Test %d: Bad input URI: %v", i, err)
}
req := &http.Request{URL: u}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
addHTTPVarsToReplacer(repl, req, httptest.NewRecorder())
actual, err := tc.match.MatchWithError(req)
if err != nil {
t.Errorf("Test %d %v: matching failed: %v", i, tc.match, err)
}
if actual != tc.expect {
t.Errorf("Test %d [%v]: Expected %t, got %t for input '%s'",
i, tc.match.Pattern, tc.expect, actual, tc.input)
continue
}
for key, expectVal := range tc.expectRepl {
placeholder := fmt.Sprintf("{http.regexp.%s}", key)
actualVal := repl.ReplaceAll(placeholder, "<empty>")
if actualVal != expectVal {
t.Errorf("Test %d [%v]: Expected placeholder {http.regexp.%s} to be '%s' but got '%s'",
i, tc.match.Pattern, key, expectVal, actualVal)
continue
}
}
}
}
func TestHeaderMatcher(t *testing.T) {
repl := caddy.NewReplacer()
repl.Set("a", "foobar")
for i, tc := range []struct {
match MatchHeader
input http.Header // make sure these are canonical cased (std lib will do that in a real request)
host string
expect bool
}{
{
match: MatchHeader{"Field": []string{"foo"}},
input: http.Header{"Field": []string{"foo"}},
expect: true,
},
{
match: MatchHeader{"Field": []string{"foo", "bar"}},
input: http.Header{"Field": []string{"bar"}},
expect: true,
},
{
match: MatchHeader{"Field": []string{"foo", "bar"}},
input: http.Header{"Alakazam": []string{"kapow"}},
expect: false,
},
{
match: MatchHeader{"Field": []string{"foo", "bar"}},
input: http.Header{"Field": []string{"kapow"}},
expect: false,
},
{
match: MatchHeader{"Field": []string{"foo", "bar"}},
input: http.Header{"Field": []string{"kapow", "foo"}},
expect: true,
},
{
match: MatchHeader{"Field1": []string{"foo"}, "Field2": []string{"bar"}},
input: http.Header{"Field1": []string{"foo"}, "Field2": []string{"bar"}},
expect: true,
},
{
match: MatchHeader{"field1": []string{"foo"}, "field2": []string{"bar"}},
input: http.Header{"Field1": []string{"foo"}, "Field2": []string{"bar"}},
expect: true,
},
{
match: MatchHeader{"field1": []string{"foo"}, "field2": []string{"bar"}},
input: http.Header{"Field1": []string{"foo"}, "Field2": []string{"kapow"}},
expect: false,
},
{
match: MatchHeader{"field1": []string{"*"}},
input: http.Header{"Field1": []string{"foo"}},
expect: true,
},
{
match: MatchHeader{"field1": []string{"*"}},
input: http.Header{"Field2": []string{"foo"}},
expect: false,
},
{
match: MatchHeader{"Field1": []string{"foo*"}},
input: http.Header{"Field1": []string{"foo"}},
expect: true,
},
{
match: MatchHeader{"Field1": []string{"foo*"}},
input: http.Header{"Field1": []string{"asdf", "foobar"}},
expect: true,
},
{
match: MatchHeader{"Field1": []string{"*bar"}},
input: http.Header{"Field1": []string{"asdf", "foobar"}},
expect: true,
},
{
match: MatchHeader{"host": []string{"localhost"}},
input: http.Header{},
host: "localhost",
expect: true,
},
{
match: MatchHeader{"host": []string{"localhost"}},
input: http.Header{},
host: "caddyserver.com",
expect: false,
},
{
match: MatchHeader{"Must-Not-Exist": nil},
input: http.Header{},
expect: true,
},
{
match: MatchHeader{"Must-Not-Exist": nil},
input: http.Header{"Must-Not-Exist": []string{"do not match"}},
expect: false,
},
{
match: MatchHeader{"Foo": []string{"{a}"}},
input: http.Header{"Foo": []string{"foobar"}},
expect: true,
},
{
match: MatchHeader{"Foo": []string{"{a}"}},
input: http.Header{"Foo": []string{"asdf"}},
expect: false,
},
{
match: MatchHeader{"Foo": []string{"{a}*"}},
input: http.Header{"Foo": []string{"foobar-baz"}},
expect: true,
},
} {
req := &http.Request{Header: tc.input, Host: tc.host}
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
actual, err := tc.match.MatchWithError(req)
if err != nil {
t.Errorf("Test %d %v: matching failed: %v", i, tc.match, err)
}
if actual != tc.expect {
t.Errorf("Test %d %v: Expected %t, got %t for '%s'", i, tc.match, tc.expect, actual, tc.input)
continue
}
}
}
func TestQueryMatcher(t *testing.T) {
for i, tc := range []struct {
scenario string
match MatchQuery
input string
expect bool
}{
{
scenario: "non match against a specific value",
match: MatchQuery{"debug": []string{"1"}},
input: "/",
expect: false,
},
{
scenario: "match against a specific value",
match: MatchQuery{"debug": []string{"1"}},
input: "/?debug=1",
expect: true,
},
{
scenario: "match against a wildcard",
match: MatchQuery{"debug": []string{"*"}},
input: "/?debug=something",
expect: true,
},
{
scenario: "non match against a wildcarded",
match: MatchQuery{"debug": []string{"*"}},
input: "/?other=something",
expect: false,
},
{
scenario: "match against an empty value",
match: MatchQuery{"debug": []string{""}},
input: "/?debug",
expect: true,
},
{
scenario: "non match against an empty value",
match: MatchQuery{"debug": []string{""}},
input: "/?someparam",
expect: false,
},
{
scenario: "empty matcher value should match empty query",
match: MatchQuery{},
input: "/?",
expect: true,
},
{
scenario: "nil matcher value should NOT match a non-empty query",
match: MatchQuery{},
input: "/?foo=bar",
expect: false,
},
{
scenario: "non-nil matcher should NOT match an empty query",
match: MatchQuery{"": nil},
input: "/?",
expect: false,
},
{
scenario: "match against a placeholder value",
match: MatchQuery{"debug": []string{"{http.vars.debug}"}},
input: "/?debug=1",
expect: true,
},
{
scenario: "match against a placeholder key",
match: MatchQuery{"{http.vars.key}": []string{"1"}},
input: "/?somekey=1",
expect: true,
},
{
scenario: "do not match when not all query params are present",
match: MatchQuery{"debug": []string{"1"}, "foo": []string{"bar"}},
input: "/?debug=1",
expect: false,
},
{
scenario: "match when all query params are present",
match: MatchQuery{"debug": []string{"1"}, "foo": []string{"bar"}},
input: "/?debug=1&foo=bar",
expect: true,
},
{
scenario: "do not match when the value of a query param does not match",
match: MatchQuery{"debug": []string{"1"}, "foo": []string{"bar"}},
input: "/?debug=2&foo=bar",
expect: false,
},
{
scenario: "do not match when all the values the query params do not match",
match: MatchQuery{"debug": []string{"1"}, "foo": []string{"bar"}},
input: "/?debug=2&foo=baz",
expect: false,
},
{
scenario: "match against two values for the same key",
match: MatchQuery{"debug": []string{"1"}},
input: "/?debug=1&debug=2",
expect: true,
},
{
scenario: "match against two values for the same key",
match: MatchQuery{"debug": []string{"2", "1"}},
input: "/?debug=2&debug=1",
expect: true,
},
} {
u, _ := url.Parse(tc.input)
req := &http.Request{URL: u}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
repl.Set("http.vars.debug", "1")
repl.Set("http.vars.key", "somekey")
req = req.WithContext(ctx)
actual, err := tc.match.MatchWithError(req)
if err != nil {
t.Errorf("Test %d %v: matching failed: %v", i, tc.match, err)
}
if actual != tc.expect {
t.Errorf("Test %d %v: Expected %t, got %t for '%s'", i, tc.match, tc.expect, actual, tc.input)
continue
}
}
}
func TestHeaderREMatcher(t *testing.T) {
for i, tc := range []struct {
match MatchHeaderRE
input http.Header // make sure these are canonical cased (std lib will do that in a real request)
host string
expect bool
expectRepl map[string]string
}{
{
match: MatchHeaderRE{"Field": &MatchRegexp{Pattern: "foo"}},
input: http.Header{"Field": []string{"foo"}},
expect: true,
},
{
match: MatchHeaderRE{"Field": &MatchRegexp{Pattern: "$foo^"}},
input: http.Header{"Field": []string{"foobar"}},
expect: false,
},
{
match: MatchHeaderRE{"Field": &MatchRegexp{Pattern: "^foo(.*)$", Name: "name"}},
input: http.Header{"Field": []string{"foobar"}},
expect: true,
expectRepl: map[string]string{"name.1": "bar"},
},
{
match: MatchHeaderRE{"Field": &MatchRegexp{Pattern: "^foo.*$", Name: "name"}},
input: http.Header{"Field": []string{"barfoo", "foobar"}},
expect: true,
},
{
match: MatchHeaderRE{"host": &MatchRegexp{Pattern: "^localhost$", Name: "name"}},
input: http.Header{},
host: "localhost",
expect: true,
},
{
match: MatchHeaderRE{"host": &MatchRegexp{Pattern: "^local$", Name: "name"}},
input: http.Header{},
host: "localhost",
expect: false,
},
} {
// compile the regexp and validate its name
err := tc.match.Provision(caddy.Context{})
if err != nil {
t.Errorf("Test %d %v: Provisioning: %v", i, tc.match, err)
continue
}
err = tc.match.Validate()
if err != nil {
t.Errorf("Test %d %v: Validating: %v", i, tc.match, err)
continue
}
// set up the fake request and its Replacer
req := &http.Request{Header: tc.input, URL: new(url.URL), Host: tc.host}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
addHTTPVarsToReplacer(repl, req, httptest.NewRecorder())
actual, err := tc.match.MatchWithError(req)
if err != nil {
t.Errorf("Test %d %v: matching failed: %v", i, tc.match, err)
}
if actual != tc.expect {
t.Errorf("Test %d [%v]: Expected %t, got %t for input '%s'",
i, tc.match, tc.expect, actual, tc.input)
continue
}
for key, expectVal := range tc.expectRepl {
placeholder := fmt.Sprintf("{http.regexp.%s}", key)
actualVal := repl.ReplaceAll(placeholder, "<empty>")
if actualVal != expectVal {
t.Errorf("Test %d [%v]: Expected placeholder {http.regexp.%s} to be '%s' but got '%s'",
i, tc.match, key, expectVal, actualVal)
continue
}
}
}
}
func BenchmarkHeaderREMatcher(b *testing.B) {
i := 0
match := MatchHeaderRE{"Field": &MatchRegexp{Pattern: "^foo(.*)$", Name: "name"}}
input := http.Header{"Field": []string{"foobar"}}
var host string
err := match.Provision(caddy.Context{})
if err != nil {
b.Errorf("Test %d %v: Provisioning: %v", i, match, err)
}
err = match.Validate()
if err != nil {
b.Errorf("Test %d %v: Validating: %v", i, match, err)
}
// set up the fake request and its Replacer
req := &http.Request{Header: input, URL: new(url.URL), Host: host}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
addHTTPVarsToReplacer(repl, req, httptest.NewRecorder())
for b.Loop() {
match.MatchWithError(req)
}
}
func TestVarREMatcher(t *testing.T) {
for i, tc := range []struct {
desc string
match MatchVarsRE
input VarsMiddleware
headers http.Header
expect bool
expectRepl map[string]string
}{
{
desc: "match static value within var set by the VarsMiddleware succeeds",
match: MatchVarsRE{"Var1": &MatchRegexp{Pattern: "foo"}},
input: VarsMiddleware{"Var1": "here is foo val"},
expect: true,
},
{
desc: "value set by VarsMiddleware not satisfying regexp matcher fails to match",
match: MatchVarsRE{"Var1": &MatchRegexp{Pattern: "$foo^"}},
input: VarsMiddleware{"Var1": "foobar"},
expect: false,
},
{
desc: "successfully matched value is captured and its placeholder is added to replacer",
match: MatchVarsRE{"Var1": &MatchRegexp{Pattern: "^foo(.*)$", Name: "name"}},
input: VarsMiddleware{"Var1": "foobar"},
expect: true,
expectRepl: map[string]string{"name.1": "bar"},
},
{
desc: "matching against a value of standard variables succeeds",
match: MatchVarsRE{"{http.request.method}": &MatchRegexp{Pattern: "^G.[tT]$"}},
input: VarsMiddleware{},
expect: true,
},
{
desc: "matching against value of var set by the VarsMiddleware and referenced by its placeholder succeeds",
match: MatchVarsRE{"{http.vars.Var1}": &MatchRegexp{Pattern: "[vV]ar[0-9]"}},
input: VarsMiddleware{"Var1": "var1Value"},
expect: true,
},
{
desc: "placeholder key value containing braces is not double-expanded",
match: MatchVarsRE{"{http.request.header.X-Input}": &MatchRegexp{Pattern: ".+", Name: "val"}},
input: VarsMiddleware{},
headers: http.Header{"X-Input": []string{"{env.HOME}"}},
expect: true,
expectRepl: map[string]string{"val.0": "{env.HOME}"},
},
} {
t.Run(tc.desc, func(t *testing.T) {
t.Parallel()
// compile the regexp and validate its name
err := tc.match.Provision(caddy.Context{})
if err != nil {
t.Errorf("Test %d %v: Provisioning: %v", i, tc.match, err)
return
}
err = tc.match.Validate()
if err != nil {
t.Errorf("Test %d %v: Validating: %v", i, tc.match, err)
return
}
// set up the fake request and its Replacer
req := &http.Request{URL: new(url.URL), Method: http.MethodGet, Header: tc.headers}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
ctx = context.WithValue(ctx, VarsCtxKey, make(map[string]any))
req = req.WithContext(ctx)
addHTTPVarsToReplacer(repl, req, httptest.NewRecorder())
tc.input.ServeHTTP(httptest.NewRecorder(), req, emptyHandler)
actual, err := tc.match.MatchWithError(req)
if err != nil {
t.Errorf("Test %d %v: matching failed: %v", i, tc.match, err)
}
if actual != tc.expect {
t.Errorf("Test %d [%v]: Expected %t, got %t for input '%s'",
i, tc.match, tc.expect, actual, tc.input)
return
}
for key, expectVal := range tc.expectRepl {
placeholder := fmt.Sprintf("{http.regexp.%s}", key)
actualVal := repl.ReplaceAll(placeholder, "<empty>")
if actualVal != expectVal {
t.Errorf("Test %d [%v]: Expected placeholder {http.regexp.%s} to be '%s' but got '%s'",
i, tc.match, key, expectVal, actualVal)
return
}
}
})
}
}
func TestNotMatcher(t *testing.T) {
for i, tc := range []struct {
host, path string
match MatchNot
expect bool
}{
{
host: "example.com", path: "/",
match: MatchNot{},
expect: true,
},
{
host: "example.com", path: "/foo",
match: MatchNot{
MatcherSets: []MatcherSet{
{
MatchPath{"/foo"},
},
},
},
expect: false,
},
{
host: "example.com", path: "/bar",
match: MatchNot{
MatcherSets: []MatcherSet{
{
MatchPath{"/foo"},
},
},
},
expect: true,
},
{
host: "example.com", path: "/bar",
match: MatchNot{
MatcherSets: []MatcherSet{
{
MatchPath{"/foo"},
},
{
MatchHost{"example.com"},
},
},
},
expect: false,
},
{
host: "example.com", path: "/bar",
match: MatchNot{
MatcherSets: []MatcherSet{
{
MatchPath{"/bar"},
},
{
MatchHost{"example.com"},
},
},
},
expect: false,
},
{
host: "example.com", path: "/foo",
match: MatchNot{
MatcherSets: []MatcherSet{
{
MatchPath{"/bar"},
},
{
MatchHost{"sub.example.com"},
},
},
},
expect: true,
},
{
host: "example.com", path: "/foo",
match: MatchNot{
MatcherSets: []MatcherSet{
{
MatchPath{"/foo"},
MatchHost{"example.com"},
},
},
},
expect: false,
},
{
host: "example.com", path: "/foo",
match: MatchNot{
MatcherSets: []MatcherSet{
{
MatchPath{"/bar"},
MatchHost{"example.com"},
},
},
},
expect: true,
},
} {
req := &http.Request{Host: tc.host, URL: &url.URL{Path: tc.path}}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
actual, err := tc.match.MatchWithError(req)
if err != nil {
t.Errorf("Test %d %v: matching failed: %v", i, tc.match, err)
}
if actual != tc.expect {
t.Errorf("Test %d %+v: Expected %t, got %t for: host=%s path=%s'", i, tc.match, tc.expect, actual, tc.host, tc.path)
continue
}
}
}
func TestMethodMatcher(t *testing.T) {
for i, tc := range []struct {
scenario string
match MatchMethod
input string
expect bool
}{
{
scenario: "match uppercase GET",
match: MatchMethod{"GET"},
input: http.MethodGet,
expect: true,
},
{
scenario: "match lowercase get after Provision uppercases it",
match: MatchMethod{"get"},
input: http.MethodGet,
expect: true,
},
{
scenario: "match mixed case Post after Provision uppercases it",
match: MatchMethod{"Post"},
input: http.MethodPost,
expect: true,
},
{
scenario: "no match for wrong method",
match: MatchMethod{"GET"},
input: http.MethodPost,
expect: false,
},
{
scenario: "match one of multiple methods",
match: MatchMethod{"get", "post"},
input: http.MethodPost,
expect: true,
},
} {
req := &http.Request{Method: tc.input}
if err := tc.match.Provision(caddy.Context{}); err != nil {
t.Errorf("Test %d %v: provisioning failed: %v", i, tc.scenario, err)
}
actual, err := tc.match.MatchWithError(req)
if err != nil {
t.Errorf("Test %d %v: matching failed: %v", i, tc.scenario, err)
}
if actual != tc.expect {
t.Errorf("Test %d %v: Expected %t, got %t for method=%s", i, tc.scenario, tc.expect, actual, tc.input)
continue
}
}
}
func BenchmarkLargeHostMatcher(b *testing.B) {
// this benchmark simulates a large host matcher (thousands of entries) where each
// value is an exact hostname (not a placeholder or wildcard) - compare the results
// of this with and without the binary search (comment out the various fast path
// sections in Match) to conduct experiments
const n = 10000
lastHost := fmt.Sprintf("%d.example.com", n-1)
req := &http.Request{Host: lastHost}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
matcher := make(MatchHost, n)
for i := 0; i < n; i++ {
matcher[i] = fmt.Sprintf("%d.example.com", i)
}
err := matcher.Provision(caddy.Context{})
if err != nil {
b.Fatal(err)
}
for b.Loop() {
matcher.MatchWithError(req)
}
}
func BenchmarkHostMatcherWithoutPlaceholder(b *testing.B) {
req := &http.Request{Host: "localhost"}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
match := MatchHost{"localhost"}
for b.Loop() {
match.MatchWithError(req)
}
}
func BenchmarkHostMatcherWithPlaceholder(b *testing.B) {
err := os.Setenv("GO_BENCHMARK_DOMAIN", "localhost")
if err != nil {
b.Errorf("error while setting up environment: %v", err)
}
req := &http.Request{Host: "localhost"}
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
match := MatchHost{"{env.GO_BENCHMARK_DOMAIN}"}
for b.Loop() {
match.MatchWithError(req)
}
}