Files
Matthew HoltandCopilot ebfc03a7b6 caddyhttp: preserve invalid UTF-8 bytes throughout path matching
Lowercasing and slash-preserving path cleaning rewrote invalid UTF-8
bytes as U+FFFD, so the path matcher could evaluate a different path
than the file server opens. Preserve the original bytes in both, keeping
case-insensitive matching for valid Unicode.

Addresses scan report 18c02f60a5b43529.

Code written by GitHub Copilot (GPT-6.1 Sol, Claude Opus 5.5); reviewed by mholt.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-11 11:06:40 -07:00

1687 lines
42 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) {
t.Setenv("CADDY_TEST_PATH_PREFIX", "admin")
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
headers http.Header // request headers, for header placeholders in the match
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,
},
{
// a lone trailing wildcard is a fast prefix match in normalized
// space, so it does match an encoded separator
match: MatchPath{"/bands/*"},
input: "/bands/AC%2FDC",
expect: true,
},
{
match: MatchPath{"/bands/%*/"},
input: "/bands/AC%2FDC/",
expect: true,
},
{
match: MatchPath{"/bands/*/"},
input: "/bands/AC%2FDC/",
expect: false, // because * operates in normalized space
},
{
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,
},
{
match: MatchPath{"/{env.CADDY_TEST_PATH_PREFIX}/*"},
input: "/admin/panel",
expect: true,
},
{
match: MatchPath{"/{env.CADDY_TEST_PATH_PREFIX}/*"},
input: "/other/panel",
expect: false,
},
{
match: MatchPath{"/{http.request.header.X-Prefix}/*"},
input: "/admin/panel",
headers: http.Header{"X-Prefix": []string{"Admin"}},
expect: true,
},
{
match: MatchPath{"/{http.request.header.X-Prefix}/*"},
input: "/ADMIN/panel",
headers: http.Header{"X-Prefix": []string{"Admin"}},
expect: true,
},
{
match: MatchPath{"/{http.request.header.X-Prefix}%2f*"},
input: "/admin%2fpanel",
headers: http.Header{"X-Prefix": []string{"Admin"}},
expect: true,
},
} {
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, Header: tc.headers}
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 '%s'", i, tc.match, tc.expect, actual, tc.input)
continue
}
}
}
func TestEscapedPathWildcardCursor(t *testing.T) {
for _, tc := range []struct {
pattern, target string
want bool
}{
{"/foo*bar%2f", "/fooXbar%2f", true},
{"/foo*bar%2f", "/foobar%2f", true},
{"/foo*bar%2f", "/foo%41bar%2f", true},
{"/foo*bar%2f", "/foo%252fbar%2f", true},
{"/foo*bar%2f", "/foo/bar/", false},
{"/foo*bar%2f", "/foo/bar%2f", false},
{"/foo*bar%2f", "/foo%2fbar%2f", false},
{"/foo*bar%2f", "/foo%2Fbar%2F", false},
{"/foo%2f*bar%2f", "/foo%2fXbar%2f", true},
{"/foo%2f*bar%2f", "/foo%2f%2fbar%2f", false},
{"/foo%2f*", "/foo%2f%41", true},
{"/foo%2f*", "/foo%2f%2f", false},
{"/foo%*bar%2f", "/foo%2fbar%2f", true},
{"/foo%*bar%2f", "/foo/bar%2f", false},
} {
t.Run(tc.pattern+" "+tc.target, func(t *testing.T) {
m := MatchPath{tc.pattern}
if err := m.Provision(caddy.Context{}); err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodGet, tc.target, nil)
NewTestReplacer(r)
got, err := m.MatchWithError(r)
if err != nil || got != tc.want {
t.Errorf("match = %t, %v; want %t", got, err, tc.want)
}
})
}
}
func TestLowerPathPreservesInvalidUTF8(t *testing.T) {
for b := 128; b < 256; b++ {
pathByte := string([]byte{byte(b)})
input := "Ä" + pathByte + "Σ"
want := "ä" + pathByte + "σ"
if got := lowerPath(input); got != want {
t.Errorf("lowerPath(%q) = %q; want %q", input, got, want)
}
}
}
func TestPathMatcherPreservesInvalidUTF8(t *testing.T) {
for _, tc := range []struct {
pattern, target string
want bool
}{
{"/public//�", "/public/%ff", false},
{"/public//�", "/public//%EF%BF%BD", true},
{"/public//�", "/public//%ff", false},
{"/public/�", "/public/%ff", false},
{"/public//�", "/public//%80", false},
{"/public//�", "/public//%c0%af", false},
{"/public//�", "/public//%ef%bf", false},
{"/public//�", "/public//%ed%a0%80", false},
{"/public//�%2fend", "/public//%ff%2fend", false},
{"/public//*�%2f", "/public//%ff%2f", false},
{"/public//�%2fend", "/public//%ef%bf%bd%2fend", true},
{"/ÄPFEL//Σ/İ", "/%c3%a4pfel//%cf%83/i", true},
{"/Ä*%2f", "/%C3%84pfel%2f", true},
{"/Ä//\xff", "/%c3%a4//%ff", true},
{"/Ä//\xff", "/%c3%a4//%ef%bf%bd", false},
} {
t.Run(tc.pattern+" "+tc.target, func(t *testing.T) {
for _, dynamic := range []bool{false, true} {
m := MatchPath{tc.pattern}
r := httptest.NewRequest(http.MethodGet, tc.target, nil)
repl := NewTestReplacer(r)
if dynamic {
m[0] = "{test.path}"
repl.Set("test.path", tc.pattern)
}
if err := m.Provision(caddy.Context{}); err != nil {
t.Fatal(err)
}
got, err := m.MatchWithError(r)
if err != nil || got != tc.want {
t.Errorf("dynamic=%t: match = %t, %v; want %t", dynamic, got, err, tc.want)
}
}
})
}
}
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)
}
}
func TestHasWindowsShortName(t *testing.T) {
for _, tc := range []struct {
name string
path string
want bool
}{
{
name: "final component",
path: "/DIRECT~1.TXT",
want: true,
},
{
name: "parent component with long final filename",
path: "/PROTEC~1/this-is-a-long-final-filename.txt",
want: true,
},
{
name: "middle component",
path: "/public/PROTEC~1/file.txt",
want: true,
},
{
name: "backslash separator",
path: `\public\PROTEC~1\file.txt`,
want: true,
},
{
name: "two digit short name suffix",
path: "/PROTE~12/file.txt",
want: true,
},
{
name: "trailing dots and spaces",
path: "/PROTEC~1. /file.txt",
want: true,
},
{
name: "permitted punctuation",
path: "/$%'-_~1.!#&",
want: true,
},
{
name: "ordinary tilde name",
path: "/ordinary~name/file.txt",
want: false,
},
{
name: "space in short name shape",
path: "/my f~1.txt",
want: false,
},
{
name: "space in long tilde name",
path: "/my file~1.txt",
want: false,
},
{
name: "non-ASCII short name shape in parent component",
path: "/public/café~1/this-is-a-long-final-filename.txt",
want: true,
},
{
name: "non-ASCII ordinary tilde name",
path: "/café~name.txt",
want: false,
},
{
name: "forbidden punctuation",
path: "/MY+F~1.TXT",
want: false,
},
{
name: "tilde followed by non-digit",
path: "/SHORT~A/file.txt",
want: false,
},
{
name: "characters after numeric suffix",
path: "/SHORT~1-file.txt",
want: false,
},
{
name: "base too long for 8.3",
path: "/TOOLONG~1/file.txt",
want: false,
},
{
name: "extension too long for 8.3",
path: "/SHORT~1.LONG/file.txt",
want: false,
},
{
name: "multiple extension separators",
path: "/SHORT~1.T.X/file.txt",
want: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := HasWindowsShortName(tc.path); got != tc.want {
t.Fatalf("HasWindowsShortName(%q) = %t, want %t", tc.path, got, tc.want)
}
})
}
}