summaryrefslogtreecommitdiff
path: root/modules/caddyhttp
diff options
context:
space:
mode:
authorTristan Swadell <tswadell@google.com>2022-06-22 15:53:46 -0700
committerGitHub <noreply@github.com>2022-06-22 18:53:46 -0400
commit10f85558ead15e119f8e9abd81c8ad55eb865f8b (patch)
treeeac30b8c4d91b8a9f8332d4733038d6d2e7235cd /modules/caddyhttp
parent98468af8b6224d29431576fe30a7d92a8676030d (diff)
Expose several Caddy HTTP Matchers to the CEL Matcher (#4715)
Co-authored-by: Francis Lavoie <lavofr@gmail.com>
Diffstat (limited to 'modules/caddyhttp')
-rw-r--r--modules/caddyhttp/celmatcher.go481
-rw-r--r--modules/caddyhttp/celmatcher_test.go466
-rw-r--r--modules/caddyhttp/fileserver/matcher.go218
-rw-r--r--modules/caddyhttp/fileserver/matcher_test.go109
-rw-r--r--modules/caddyhttp/matchers.go290
5 files changed, 1508 insertions, 56 deletions
diff --git a/modules/caddyhttp/celmatcher.go b/modules/caddyhttp/celmatcher.go
index ee640fd..4938cd5 100644
--- a/modules/caddyhttp/celmatcher.go
+++ b/modules/caddyhttp/celmatcher.go
@@ -17,6 +17,7 @@ package caddyhttp
import (
"crypto/x509/pkix"
"encoding/json"
+ "errors"
"fmt"
"net/http"
"reflect"
@@ -28,11 +29,15 @@ import (
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/checker/decls"
+ "github.com/google/cel-go/common"
+ "github.com/google/cel-go/common/operators"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
"github.com/google/cel-go/common/types/traits"
"github.com/google/cel-go/ext"
+ "github.com/google/cel-go/interpreter"
"github.com/google/cel-go/interpreter/functions"
+ "github.com/google/cel-go/parser"
"go.uber.org/zap"
exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
"google.golang.org/protobuf/proto"
@@ -96,6 +101,29 @@ func (m *MatchExpression) Provision(ctx caddy.Context) error {
// our type adapter expands CEL's standard type support
m.ta = celTypeAdapter{}
+ // initialize the CEL libraries from the Matcher implementations which
+ // have been configured to support CEL.
+ matcherLibProducers := []CELLibraryProducer{}
+ for _, info := range caddy.GetModules("http.matchers") {
+ p, ok := info.New().(CELLibraryProducer)
+ if ok {
+ matcherLibProducers = append(matcherLibProducers, p)
+ }
+ }
+ // Assemble the compilation and program options from the different library
+ // producers into a single cel.Library implementation.
+ matcherEnvOpts := []cel.EnvOption{}
+ matcherProgramOpts := []cel.ProgramOption{}
+ for _, producer := range matcherLibProducers {
+ l, err := producer.CELLibrary(ctx)
+ if err != nil {
+ return fmt.Errorf("error initializing CEL library for %T: %v", producer, err)
+ }
+ matcherEnvOpts = append(matcherEnvOpts, l.CompileOptions()...)
+ matcherProgramOpts = append(matcherProgramOpts, l.ProgramOptions()...)
+ }
+ matcherLib := cel.Lib(NewMatcherCELLibrary(matcherEnvOpts, matcherProgramOpts))
+
// create the CEL environment
env, err := cel.NewEnv(
cel.Declarations(
@@ -107,6 +135,7 @@ func (m *MatchExpression) Provision(ctx caddy.Context) error {
),
cel.CustomTypeAdapter(m.ta),
ext.Strings(),
+ matcherLib,
)
if err != nil {
return fmt.Errorf("setting up CEL environment: %v", err)
@@ -114,7 +143,7 @@ func (m *MatchExpression) Provision(ctx caddy.Context) error {
// parse and type-check the expression
checked, issues := env.Compile(m.expandedExpr)
- if issues != nil && issues.Err() != nil {
+ if issues.Err() != nil {
return fmt.Errorf("compiling CEL program: %s", issues.Err())
}
@@ -126,6 +155,7 @@ func (m *MatchExpression) Provision(ctx caddy.Context) error {
// compile the "program"
m.prg, err = env.Program(checked,
+ cel.EvalOptions(cel.OptOptimize),
cel.Functions(
&functions.Overload{
Operator: placeholderFuncName,
@@ -133,7 +163,6 @@ func (m *MatchExpression) Provision(ctx caddy.Context) error {
},
),
)
-
if err != nil {
return fmt.Errorf("compiling CEL program: %s", err)
}
@@ -142,18 +171,17 @@ func (m *MatchExpression) Provision(ctx caddy.Context) error {
// Match returns true if r matches m.
func (m MatchExpression) Match(r *http.Request) bool {
- out, _, err := m.prg.Eval(map[string]interface{}{
- "request": celHTTPRequest{r},
- })
+ celReq := celHTTPRequest{r}
+ out, _, err := m.prg.Eval(celReq)
if err != nil {
m.log.Error("evaluating expression", zap.Error(err))
+ SetVar(r.Context(), MatcherErrorVarKey, err)
return false
}
if outBool, ok := out.Value().(bool); ok {
return outBool
}
return false
-
}
// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
@@ -175,13 +203,15 @@ func (m MatchExpression) caddyPlaceholderFunc(lhs, rhs ref.Val) ref.Val {
if !ok {
return types.NewErr(
"invalid request of type '%v' to "+placeholderFuncName+"(request, placeholderVarName)",
- lhs.Type())
+ lhs.Type(),
+ )
}
phStr, ok := rhs.(types.String)
if !ok {
return types.NewErr(
"invalid placeholder variable name of type '%v' to "+placeholderFuncName+"(request, placeholderVarName)",
- rhs.Type())
+ rhs.Type(),
+ )
}
repl := celReq.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
@@ -193,10 +223,23 @@ func (m MatchExpression) caddyPlaceholderFunc(lhs, rhs ref.Val) ref.Val {
// httpRequestCELType is the type representation of a native HTTP request.
var httpRequestCELType = types.NewTypeValue("http.Request", traits.ReceiverType)
-// cellHTTPRequest wraps an http.Request with
-// methods to satisfy the ref.Val interface.
+// celHTTPRequest wraps an http.Request with ref.Val interface methods.
+//
+// This type also implements the interpreter.Activation interface which
+// drops allocation costs for CEL expression evaluations by roughly half.
type celHTTPRequest struct{ *http.Request }
+func (cr celHTTPRequest) ResolveName(name string) (interface{}, bool) {
+ if name == "request" {
+ return cr, true
+ }
+ return nil, false
+}
+
+func (cr celHTTPRequest) Parent() interpreter.Activation {
+ return nil
+}
+
func (cr celHTTPRequest) ConvertToNative(typeDesc reflect.Type) (interface{}, error) {
return cr.Request, nil
}
@@ -250,12 +293,428 @@ func (celTypeAdapter) NativeToValue(value interface{}) ref.Val {
return types.DefaultTypeAdapter.NativeToValue(value)
}
+// CELLibraryProducer provide CEL libraries that expose a Matcher
+// implementation as a first class function within the CEL expression
+// matcher.
+type CELLibraryProducer interface {
+ // CELLibrary creates a cel.Library which makes it possible to use the
+ // target object within CEL expression matchers.
+ CELLibrary(caddy.Context) (cel.Library, error)
+}
+
+// CELMatcherImpl creates a new cel.Library based on the following pieces of
+// data:
+//
+// - macroName: the function name to be used within CEL. This will be a macro
+// and not a function proper.
+// - funcName: the function overload name generated by the CEL macro used to
+// represent the matcher.
+// - matcherDataTypes: the argument types to the macro.
+// - fac: a matcherFactory implementation which converts from CEL constant
+// values to a Matcher instance.
+//
+// Note, macro names and function names must not collide with other macros or
+// functions exposed within CEL expressions, or an error will be produced
+// during the expression matcher plan time.
+//
+// The existing CELMatcherImpl support methods are configured to support a
+// limited set of function signatures. For strong type validation you may need
+// to provide a custom macro which does a more detailed analysis of the CEL
+// literal provided to the macro as an argument.
+func CELMatcherImpl(macroName, funcName string, matcherDataTypes []*exprpb.Type, fac CELMatcherFactory) (cel.Library, error) {
+ requestType := decls.NewObjectType("http.Request")
+ var macro parser.Macro
+ switch len(matcherDataTypes) {
+ case 1:
+ matcherDataType := matcherDataTypes[0]
+ if isCELStringListType(matcherDataType) {
+ macro = parser.NewGlobalVarArgMacro(macroName, celMatcherStringListMacroExpander(funcName))
+ } else if isCELStringType(matcherDataType) {
+ macro = parser.NewGlobalMacro(macroName, 1, celMatcherStringMacroExpander(funcName))
+ } else if isCELJSONType(matcherDataType) {
+ macro = parser.NewGlobalMacro(macroName, 1, celMatcherJSONMacroExpander(funcName))
+ } else {
+ return nil, fmt.Errorf("unsupported matcher data type: %s", cel.FormatType(matcherDataType))
+ }
+ case 2:
+ if isCELStringType(matcherDataTypes[0]) && isCELStringType(matcherDataTypes[1]) {
+ macro = parser.NewGlobalMacro(macroName, 2, celMatcherStringListMacroExpander(funcName))
+ matcherDataTypes = []*exprpb.Type{CelTypeListString}
+ } else {
+ return nil, fmt.Errorf(
+ "unsupported matcher data type: %s, %s",
+ cel.FormatType(matcherDataTypes[0]), cel.FormatType(matcherDataTypes[1]),
+ )
+ }
+ case 3:
+ if isCELStringType(matcherDataTypes[0]) && isCELStringType(matcherDataTypes[1]) && isCELStringType(matcherDataTypes[2]) {
+ macro = parser.NewGlobalMacro(macroName, 3, celMatcherStringListMacroExpander(funcName))
+ matcherDataTypes = []*exprpb.Type{CelTypeListString}
+ } else {
+ return nil, fmt.Errorf(
+ "unsupported matcher data type: %s, %s, %s",
+ cel.FormatType(matcherDataTypes[0]), cel.FormatType(matcherDataTypes[1]), cel.FormatType(matcherDataTypes[2]),
+ )
+ }
+ }
+ envOptions := []cel.EnvOption{
+ cel.Macros(macro),
+ cel.Declarations(
+ decls.NewFunction(funcName,
+ decls.NewOverload(
+ funcName,
+ append([]*exprpb.Type{requestType}, matcherDataTypes...),
+ decls.Bool,
+ ),
+ ),
+ ),
+ }
+ programOptions := []cel.ProgramOption{
+ cel.CustomDecorator(CELMatcherDecorator(funcName, fac)),
+ cel.Functions(
+ &functions.Overload{
+ Operator: funcName,
+ Binary: CELMatcherRuntimeFunction(funcName, fac),
+ },
+ ),
+ }
+ return NewMatcherCELLibrary(envOptions, programOptions), nil
+}
+
+// CELMatcherFactory converts a constant CEL value into a RequestMatcher.
+type CELMatcherFactory func(data ref.Val) (RequestMatcher, error)
+
+// matcherCELLibrary is a simplistic configurable cel.Library implementation.
+type matcherCELLibary struct {
+ envOptions []cel.EnvOption
+ programOptions []cel.ProgramOption
+}
+
+// NewMatcherCELLibrary creates a matcherLibrary from option setes.
+func NewMatcherCELLibrary(envOptions []cel.EnvOption, programOptions []cel.ProgramOption) cel.Library {
+ return &matcherCELLibary{
+ envOptions: envOptions,
+ programOptions: programOptions,
+ }
+}
+
+func (lib *matcherCELLibary) CompileOptions() []cel.EnvOption {
+ return lib.envOptions
+}
+
+func (lib *matcherCELLibary) ProgramOptions() []cel.ProgramOption {
+ return lib.programOptions
+}
+
+// CELMatcherDecorator matches a call overload generated by a CEL macro
+// that takes a single argument, and optimizes the implementation to precompile
+// the matcher and return a function that references the precompiled and
+// provisioned matcher.
+func CELMatcherDecorator(funcName string, fac CELMatcherFactory) interpreter.InterpretableDecorator {
+ return func(i interpreter.Interpretable) (interpreter.Interpretable, error) {
+ call, ok := i.(interpreter.InterpretableCall)
+ if !ok {
+ return i, nil
+ }
+ if call.OverloadID() != funcName {
+ return i, nil
+ }
+ callArgs := call.Args()
+ reqAttr, ok := callArgs[0].(interpreter.InterpretableAttribute)
+ if !ok {
+ return nil, errors.New("missing 'request' argument")
+ }
+ nsAttr, ok := reqAttr.Attr().(interpreter.NamespacedAttribute)
+ if !ok {
+ return nil, errors.New("missing 'request' argument")
+ }
+ varNames := nsAttr.CandidateVariableNames()
+ if len(varNames) != 1 || len(varNames) == 1 && varNames[0] != "request" {
+ return nil, errors.New("missing 'request' argument")
+ }
+ matcherData, ok := callArgs[1].(interpreter.InterpretableConst)
+ if !ok {
+ // If the matcher arguments are not constant, then this means
+ // they contain a Caddy placeholder reference and the evaluation
+ // and matcher provisioning should be handled at dynamically.
+ return i, nil
+ }
+ matcher, err := fac(matcherData.Value())
+ if err != nil {
+ return nil, err
+ }
+ return interpreter.NewCall(
+ i.ID(), funcName, funcName+"_opt",
+ []interpreter.Interpretable{reqAttr},
+ func(args ...ref.Val) ref.Val {
+ // The request value, guaranteed to be of type celHTTPRequest
+ celReq := args[0]
+ // If needed this call could be changed to convert the value
+ // to a *http.Request using CEL's ConvertToNative method.
+ httpReq := celReq.Value().(celHTTPRequest)
+ return types.Bool(matcher.Match(httpReq.Request))
+ },
+ ), nil
+ }
+}
+
+// CELMatcherRuntimeFunction creates a function binding for when the input to the matcher
+// is dynamically resolved rather than a set of static constant values.
+func CELMatcherRuntimeFunction(funcName string, fac CELMatcherFactory) functions.BinaryOp {
+ return func(celReq, matcherData ref.Val) ref.Val {
+ matcher, err := fac(matcherData)
+ if err != nil {
+ return types.NewErr(err.Error())
+ }
+ httpReq := celReq.Value().(celHTTPRequest)
+ return types.Bool(matcher.Match(httpReq.Request))
+ }
+}
+
+// celMatcherStringListMacroExpander validates that the macro is called
+// with a variable number of string arguments (at least one).
+//
+// The arguments are collected into a single list argument the following
+// function call returned: <funcName>(request, [args])
+func celMatcherStringListMacroExpander(funcName string) parser.MacroExpander {
+ return func(eh parser.ExprHelper, target *exprpb.Expr, args []*exprpb.Expr) (*exprpb.Expr, *common.Error) {
+ matchArgs := []*exprpb.Expr{}
+ if len(args) == 0 {
+ return nil, &common.Error{
+ Message: "matcher requires at least one argument",
+ }
+ }
+ for _, arg := range args {
+ if isCELStringExpr(arg) {
+ matchArgs = append(matchArgs, arg)
+ } else {
+ return nil, &common.Error{
+ Location: eh.OffsetLocation(arg.GetId()),
+ Message: "matcher arguments must be string constants",
+ }
+ }
+ }
+ return eh.GlobalCall(funcName, eh.Ident("request"), eh.NewList(matchArgs...)), nil
+ }
+}
+
+// celMatcherStringMacroExpander validates that the macro is called a single
+// string argument.
+//
+// The following function call is returned: <funcName>(request, arg)
+func celMatcherStringMacroExpander(funcName string) parser.MacroExpander {
+ return func(eh parser.ExprHelper, target *exprpb.Expr, args []*exprpb.Expr) (*exprpb.Expr, *common.Error) {
+ if len(args) != 1 {
+ return nil, &common.Error{
+ Message: "matcher requires one argument",
+ }
+ }
+ if isCELStringExpr(args[0]) {
+ return eh.GlobalCall(funcName, eh.Ident("request"), args[0]), nil
+ }
+ return nil, &common.Error{
+ Location: eh.OffsetLocation(args[0].GetId()),
+ Message: "matcher argument must be a string literal",
+ }
+ }
+}
+
+// celMatcherStringMacroExpander validates that the macro is called a single
+// map literal argument.
+//
+// The following function call is returned: <funcName>(request, arg)
+func celMatcherJSONMacroExpander(funcName string) parser.MacroExpander {
+ return func(eh parser.ExprHelper, target *exprpb.Expr, args []*exprpb.Expr) (*exprpb.Expr, *common.Error) {
+ if len(args) != 1 {
+ return nil, &common.Error{
+ Message: "matcher requires a map literal argument",
+ }
+ }
+ arg := args[0]
+ switch arg.GetExprKind().(type) {
+ case *exprpb.Expr_StructExpr:
+ structExpr := arg.GetStructExpr()
+ if structExpr.GetMessageName() != "" {
+ return nil, &common.Error{
+ Location: eh.OffsetLocation(arg.GetId()),
+ Message: fmt.Sprintf(
+ "matcher input must be a map literal, not a %s",
+ structExpr.GetMessageName(),
+ ),
+ }
+ }
+ for _, entry := range structExpr.GetEntries() {
+ isStringPlaceholder := isCELStringExpr(entry.GetMapKey())
+ if !isStringPlaceholder {
+ return nil, &common.Error{
+ Location: eh.OffsetLocation(entry.GetId()),
+ Message: "matcher map keys must be string literals",
+ }
+ }
+ isStringListPlaceholder := isCELStringExpr(entry.GetValue()) ||
+ isCELStringListLiteral(entry.GetValue())
+ if !isStringListPlaceholder {
+ return nil, &common.Error{
+ Location: eh.OffsetLocation(entry.GetValue().GetId()),
+ Message: "matcher map values must be string or list literals",
+ }
+ }
+ }
+ return eh.GlobalCall(funcName, eh.Ident("request"), arg), nil
+ }
+
+ return nil, &common.Error{
+ Location: eh.OffsetLocation(arg.GetId()),
+ Message: "matcher requires a map literal argument",
+ }
+ }
+}
+
+// CELValueToMapStrList converts a CEL value to a map[string][]string
+//
+// Earlier validation stages should guarantee that the value has this type
+// at compile time, and that the runtime value type is map[string]interface{}.
+// The reason for the slight difference in value type is that CEL allows for
+// map literals containing heterogeneous values, in this case string and list
+// of string.
+func CELValueToMapStrList(data ref.Val) (map[string][]string, error) {
+ mapStrType := reflect.TypeOf(map[string]interface{}{})
+ mapStrRaw, err := data.ConvertToNative(mapStrType)
+ if err != nil {
+ return nil, err
+ }
+ mapStrIface := mapStrRaw.(map[string]interface{})
+ mapStrListStr := make(map[string][]string, len(mapStrIface))
+ for k, v := range mapStrIface {
+ switch val := v.(type) {
+ case string:
+ mapStrListStr[k] = []string{val}
+ case types.String:
+ mapStrListStr[k] = []string{string(val)}
+ case []string:
+ mapStrListStr[k] = val
+ case []ref.Val:
+ convVals := make([]string, len(val))
+ for i, elem := range val {
+ strVal, ok := elem.(types.String)
+ if !ok {
+ return nil, fmt.Errorf("unsupported value type in header match: %T", val)
+ }
+ convVals[i] = string(strVal)
+ }
+ mapStrListStr[k] = convVals
+ default:
+ return nil, fmt.Errorf("unsupported value type in header match: %T", val)
+ }
+ }
+ return mapStrListStr, nil
+}
+
+// isCELJSONType returns whether the type corresponds to JSON input.
+func isCELJSONType(t *exprpb.Type) bool {
+ switch t.GetTypeKind().(type) {
+ case *exprpb.Type_MapType_:
+ mapType := t.GetMapType()
+ return isCELStringType(mapType.GetKeyType()) && mapType.GetValueType().GetDyn() != nil
+ }
+ return false
+}
+
+// isCELStringType returns whether the type corresponds to a string.
+func isCELStringType(t *exprpb.Type) bool {
+ switch t.GetTypeKind().(type) {
+ case *exprpb.Type_Primitive:
+ return t.GetPrimitive() == exprpb.Type_STRING
+ }
+ return false
+}
+
+// isCELStringExpr indicates whether the expression is a supported string expression
+func isCELStringExpr(e *exprpb.Expr) bool {
+ return isCELStringLiteral(e) || isCELCaddyPlaceholderCall(e) || isCELConcatCall(e)
+}
+
+// isCELStringLiteral returns whether the expression is a CEL string literal.
+func isCELStringLiteral(e *exprpb.Expr) bool {
+ switch e.GetExprKind().(type) {
+ case *exprpb.Expr_ConstExpr:
+ constant := e.GetConstExpr()
+ switch constant.GetConstantKind().(type) {
+ case *exprpb.Constant_StringValue:
+ return true
+ }
+ }
+ return false
+}
+
+// isCELCaddyPlaceholderCall returns whether the expression is a caddy placeholder call.
+func isCELCaddyPlaceholderCall(e *exprpb.Expr) bool {
+ switch e.GetExprKind().(type) {
+ case *exprpb.Expr_CallExpr:
+ call := e.GetCallExpr()
+ if call.GetFunction() == "caddyPlaceholder" {
+ return true
+ }
+ }
+ return false
+}
+
+// isCELConcatCall tests whether the expression is a concat function (+) with string, placeholder, or
+// other concat call arguments.
+func isCELConcatCall(e *exprpb.Expr) bool {
+ switch e.GetExprKind().(type) {
+ case *exprpb.Expr_CallExpr:
+ call := e.GetCallExpr()
+ if call.GetTarget() != nil {
+ return false
+ }
+ if call.GetFunction() != operators.Add {
+ return false
+ }
+ for _, arg := range call.GetArgs() {
+ if !isCELStringExpr(arg) {
+ return false
+ }
+ }
+ return true
+ }
+ return false
+}
+
+// isCELStringListType returns whether the type corresponds to a list of strings.
+func isCELStringListType(t *exprpb.Type) bool {
+ switch t.GetTypeKind().(type) {
+ case *exprpb.Type_ListType_:
+ return isCELStringType(t.GetListType().GetElemType())
+ }
+ return false
+}
+
+// isCELStringListLiteral returns whether the expression resolves to a list literal
+// containing only string constants or a placeholder call.
+func isCELStringListLiteral(e *exprpb.Expr) bool {
+ switch e.GetExprKind().(type) {
+ case *exprpb.Expr_ListExpr:
+ list := e.GetListExpr()
+ for _, elem := range list.GetElements() {
+ if !isCELStringExpr(elem) {
+ return false
+ }
+ }
+ return true
+ }
+ return false
+}
+
// Variables used for replacing Caddy placeholders in CEL
// expressions with a proper CEL function call; this is
// just for syntactic sugar.
var (
- placeholderRegexp = regexp.MustCompile(`{([\w.-]+)}`)
+ placeholderRegexp = regexp.MustCompile(`{([a-zA-Z][\w.-]+)}`)
placeholderExpansion = `caddyPlaceholder(request, "${1}")`
+
+ CelTypeListString = decls.NewListType(decls.String)
+ CelTypeJson = decls.NewMapType(decls.String, decls.Dyn)
)
var httpRequestObjectType = decls.NewObjectType("http.Request")
diff --git a/modules/caddyhttp/celmatcher_test.go b/modules/caddyhttp/celmatcher_test.go
index d71fc42..3604562 100644
--- a/modules/caddyhttp/celmatcher_test.go
+++ b/modules/caddyhttp/celmatcher_test.go
@@ -19,45 +19,15 @@ import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
+ "net/http"
"net/http/httptest"
"testing"
"github.com/caddyserver/caddy/v2"
)
-func TestMatchExpressionProvision(t *testing.T) {
- tests := []struct {
- name string
- expression *MatchExpression
- wantErr bool
- }{
- {
- name: "boolean matches succeed",
- expression: &MatchExpression{
- Expr: "{http.request.uri.query} != ''",
- },
- wantErr: false,
- },
- {
- name: "reject expressions with non-boolean results",
- expression: &MatchExpression{
- Expr: "{http.request.uri.query}",
- },
- wantErr: true,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if err := tt.expression.Provision(caddy.Context{}); (err != nil) != tt.wantErr {
- t.Errorf("MatchExpression.Provision() error = %v, wantErr %v", err, tt.wantErr)
- }
- })
- }
-}
-
-func TestMatchExpressionMatch(t *testing.T) {
-
- clientCert := []byte(`-----BEGIN CERTIFICATE-----
+var (
+ clientCert = []byte(`-----BEGIN CERTIFICATE-----
MIIB9jCCAV+gAwIBAgIBAjANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA1DYWRk
eSBUZXN0IENBMB4XDTE4MDcyNDIxMzUwNVoXDTI4MDcyMTIxMzUwNVowHTEbMBkG
A1UEAwwSY2xpZW50LmxvY2FsZG9tYWluMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB
@@ -71,9 +41,12 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
9fNwfEi+OoXR6s+upSKobCmLGLGi9Na5s5g=
-----END CERTIFICATE-----`)
- tests := []struct {
+ matcherTests = []struct {
name string
expression *MatchExpression
+ urlTarget string
+ httpMethod string
+ httpHeader *http.Header
wantErr bool
wantResult bool
clientCertificate []byte
@@ -84,22 +57,363 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
Expr: "{http.request.tls.client.subject} == 'CN=client.localdomain'",
},
clientCertificate: clientCert,
+ urlTarget: "https://example.com/foo",
wantResult: true,
},
+ {
+ name: "header matches (MatchHeader)",
+ expression: &MatchExpression{
+ Expr: `header({'Field': 'foo'})`,
+ },
+ urlTarget: "https://example.com/foo",
+ httpHeader: &http.Header{"Field": []string{"foo", "bar"}},
+ wantResult: true,
+ },
+ {
+ name: "header error (MatchHeader)",
+ expression: &MatchExpression{
+ Expr: `header('foo')`,
+ },
+ urlTarget: "https://example.com/foo",
+ httpHeader: &http.Header{"Field": []string{"foo", "bar"}},
+ wantErr: true,
+ },
+ {
+ name: "header_regexp matches (MatchHeaderRE)",
+ expression: &MatchExpression{
+ Expr: `header_regexp('Field', 'fo{2}')`,
+ },
+ urlTarget: "https://example.com/foo",
+ httpHeader: &http.Header{"Field": []string{"foo", "bar"}},
+ wantResult: true,
+ },
+ {
+ name: "header_regexp matches with name (MatchHeaderRE)",
+ expression: &MatchExpression{
+ Expr: `header_regexp('foo', 'Field', 'fo{2}')`,
+ },
+ urlTarget: "https://example.com/foo",
+ httpHeader: &http.Header{"Field": []string{"foo", "bar"}},
+ wantResult: true,
+ },
+ {
+ name: "header_regexp does not match (MatchHeaderRE)",
+ expression: &MatchExpression{
+ Expr: `header_regexp('foo', 'Nope', 'fo{2}')`,
+ },
+ urlTarget: "https://example.com/foo",
+ httpHeader: &http.Header{"Field": []string{"foo", "bar"}},
+ wantResult: false,
+ },
+ {
+ name: "header_regexp error (MatchHeaderRE)",
+ expression: &MatchExpression{
+ Expr: `header_regexp('foo')`,
+ },
+ urlTarget: "https://example.com/foo",
+ httpHeader: &http.Header{"Field": []string{"foo", "bar"}},
+ wantErr: true,
+ },
+ {
+ name: "host matches localhost (MatchHost)",
+ expression: &MatchExpression{
+ Expr: `host('localhost')`,
+ },
+ urlTarget: "http://localhost",
+ wantResult: true,
+ },
+ {
+ name: "host matches (MatchHost)",
+ expression: &MatchExpression{
+ Expr: `host('*.example.com')`,
+ },
+ urlTarget: "https://foo.example.com",
+ wantResult: true,
+ },
+ {
+ name: "host does not match (MatchHost)",
+ expression: &MatchExpression{
+ Expr: `host('example.net', '*.example.com')`,
+ },
+ urlTarget: "https://foo.example.org",
+ wantResult: false,
+ },
+ {
+ name: "host error (MatchHost)",
+ expression: &MatchExpression{
+ Expr: `host(80)`,
+ },
+ urlTarget: "http://localhost:80",
+ wantErr: true,
+ },
+ {
+ name: "method does not match (MatchMethod)",
+ expression: &MatchExpression{
+ Expr: `method('PUT')`,
+ },
+ urlTarget: "https://foo.example.com",
+ httpMethod: "GET",
+ wantResult: false,
+ },
+ {
+ name: "method matches (MatchMethod)",
+ expression: &MatchExpression{
+ Expr: `method('DELETE', 'PUT', 'POST')`,
+ },
+ urlTarget: "https://foo.example.com",
+ httpMethod: "PUT",
+ wantResult: true,
+ },
+ {
+ name: "method error not enough arguments (MatchMethod)",
+ expression: &MatchExpression{
+ Expr: `method()`,
+ },
+ urlTarget: "https://foo.example.com",
+ httpMethod: "PUT",
+ wantErr: true,
+ },
+ {
+ name: "path matches substring (MatchPath)",
+ expression: &MatchExpression{
+ Expr: `path('*substring*')`,
+ },
+ urlTarget: "https://example.com/foo/substring/bar.txt",
+ wantResult: true,
+ },
+ {
+ name: "path does not match (MatchPath)",
+ expression: &MatchExpression{
+ Expr: `path('/foo')`,
+ },
+ urlTarget: "https://example.com/foo/bar",
+ wantResult: false,
+ },
+ {
+ name: "path matches end url fragment (MatchPath)",
+ expression: &MatchExpression{
+ Expr: `path('/foo')`,
+ },
+ urlTarget: "https://example.com/FOO",
+ wantResult: true,
+ },
+ {
+ name: "path matches end fragment with substring prefix (MatchPath)",
+ expression: &MatchExpression{
+ Expr: `path('/foo*')`,
+ },
+ urlTarget: "https://example.com/FOOOOO",
+ wantResult: true,
+ },
+ {
+ name: "path matches one of multiple (MatchPath)",
+ expression: &MatchExpression{
+ Expr: `path('/foo', '/foo/*', '/bar', '/bar/*', '/baz', '/baz*')`,
+ },
+ urlTarget: "https://example.com/foo",
+ wantResult: true,
+ },
+ {
+ name: "path_regexp with empty regex matches empty path (MatchPathRE)",
+ expression: &MatchExpression{
+ Expr: `path_regexp('')`,
+ },
+ urlTarget: "https://example.com/",
+ wantResult: true,
+ },
+ {
+ name: "path_regexp with slash regex matches empty path (MatchPathRE)",
+ expression: &MatchExpression{
+ Expr: `path_regexp('/')`,
+ },
+ urlTarget: "https://example.com/",
+ wantResult: true,
+ },
+ {
+ name: "path_regexp matches end url fragment (MatchPathRE)",
+ expression: &MatchExpression{
+ Expr: `path_regexp('^/foo')`,
+ },
+ urlTarget: "https://example.com/foo/",
+ wantResult: true,
+ },
+ {
+ name: "path_regexp does not match fragment at end (MatchPathRE)",
+ expression: &MatchExpression{
+ Expr: `path_regexp('bar_at_start', '^/bar')`,
+ },
+ urlTarget: "https://example.com/foo/bar",
+ wantResult: false,
+ },
+ {
+ name: "protocol matches (MatchProtocol)",
+ expression: &MatchExpression{
+ Expr: `protocol('HTTPs')`,
+ },
+ urlTarget: "https://example.com",
+ wantResult: true,
+ },
+ {
+ name: "protocol does not match (MatchProtocol)",
+ expression: &MatchExpression{
+ Expr: `protocol('grpc')`,
+ },
+ urlTarget: "https://example.com",
+ wantResult: false,
+ },
+ {
+ name: "protocol invocation error no args (MatchProtocol)",
+ expression: &MatchExpression{
+ Expr: `protocol()`,
+ },
+ urlTarget: "https://example.com",
+ wantErr: true,
+ },
+ {
+ name: "protocol invocation error too many args (MatchProtocol)",
+ expression: &MatchExpression{
+ Expr: `protocol('grpc', 'https')`,
+ },
+ urlTarget: "https://example.com",
+ wantErr: true,
+ },
+ {
+ name: "protocol invocation error wrong arg type (MatchProtocol)",
+ expression: &MatchExpression{
+ Expr: `protocol(true)`,
+ },
+ urlTarget: "https://example.com",
+ wantErr: true,
+ },
+ {
+ name: "query does not match against a specific value (MatchQuery)",
+ expression: &MatchExpression{
+ Expr: `query({"debug": "1"})`,
+ },
+ urlTarget: "https://example.com/foo",
+ wantResult: false,
+ },
+ {
+ name: "query matches against a specific value (MatchQuery)",
+ expression: &MatchExpression{
+ Expr: `query({"debug": "1"})`,
+ },
+ urlTarget: "https://example.com/foo/?debug=1",
+ wantResult: true,
+ },
+ {
+ name: "query matches against multiple values (MatchQuery)",
+ expression: &MatchExpression{
+ Expr: `query({"debug": ["0", "1", {http.request.uri.query.debug}+"1"]})`,
+ },
+ urlTarget: "https://example.com/foo/?debug=1",
+ wantResult: true,
+ },
+ {
+ name: "query matches against a wildcard (MatchQuery)",
+ expression: &MatchExpression{
+ Expr: `query({"debug": ["*"]})`,
+ },
+ urlTarget: "https://example.com/foo/?debug=something",
+ wantResult: true,
+ },
+ {
+ name: "query matches against a placeholder value (MatchQuery)",
+ expression: &MatchExpression{
+ Expr: `query({"debug": {http.request.uri.query.debug}})`,
+ },
+ urlTarget: "https://example.com/foo/?debug=1",
+ wantResult: true,
+ },
+ {
+ name: "query error bad map key type (MatchQuery)",
+ expression: &MatchExpression{
+ Expr: `query({1: "1"})`,
+ },
+ urlTarget: "https://example.com/foo",
+ wantErr: true,
+ },
+ {
+ name: "query error typed struct instead of map (MatchQuery)",
+ expression: &MatchExpression{
+ Expr: `query(Message{field: "1"})`,
+ },
+ urlTarget: "https://example.com/foo",
+ wantErr: true,
+ },
+ {
+ name: "query error bad map value type (MatchQuery)",
+ expression: &MatchExpression{
+ Expr: `query({"debug": 1})`,
+ },
+ urlTarget: "https://example.com/foo/?debug=1",
+ wantErr: true,
+ },
+ {
+ name: "query error no args (MatchQuery)",
+ expression: &MatchExpression{
+ Expr: `query()`,
+ },
+ urlTarget: "https://example.com/foo/?debug=1",
+ wantErr: true,
+ },
+ {
+ name: "remote_ip error no args (MatchRemoteIP)",
+ expression: &MatchExpression{
+ Expr: `remote_ip()`,
+ },
+ urlTarget: "https://example.com/foo",
+ wantErr: true,
+ },
+ {
+ name: "remote_ip single IP match (MatchRemoteIP)",
+ expression: &MatchExpression{
+ Expr: `remote_ip('192.0.2.1')`,
+ },
+ urlTarget: "https://example.com/foo",
+ wantResult: true,
+ },
+ {
+ name: "remote_ip forwarded (MatchRemoteIP)",
+ expression: &MatchExpression{
+ Expr: `remote_ip('forwarded', '192.0.2.1')`,
+ },
+ urlTarget: "https://example.com/foo",
+ wantResult: true,
+ },
+ {
+ name: "remote_ip forwarded not first (MatchRemoteIP)",
+ expression: &MatchExpression{
+ Expr: `remote_ip('192.0.2.1', 'forwarded')`,
+ },
+ urlTarget: "https://example.com/foo",
+ wantErr: true,
+ },
}
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if err := tt.expression.Provision(caddy.Context{}); (err != nil) != tt.wantErr {
- t.Errorf("MatchExpression.Provision() error = %v, wantErr %v", err, tt.wantErr)
+)
+
+func TestMatchExpressionMatch(t *testing.T) {
+ for _, tst := range matcherTests {
+ tc := tst
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.expression.Provision(caddy.Context{})
+ if err != nil {
+ if !tc.wantErr {
+ t.Errorf("MatchExpression.Provision() error = %v, wantErr %v", err, tc.wantErr)
+ }
+ return
}
- req := httptest.NewRequest("GET", "https://example.com/foo", nil)
+ req := httptest.NewRequest(tc.httpMethod, tc.urlTarget, nil)
+ if tc.httpHeader != nil {
+ req.Header = *tc.httpHeader
+ }
repl := caddy.NewReplacer()
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)
addHTTPVarsToReplacer(repl, req, httptest.NewRecorder())
- if tt.clientCertificate != nil {
+ if tc.clientCertificate != nil {
block, _ := pem.Decode(clientCert)
if block == nil {
t.Fatalf("failed to decode PEM certificate")
@@ -115,10 +429,78 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
}
}
- if tt.expression.Match(req) != tt.wantResult {
- t.Errorf("MatchExpression.Match() expected to return '%t', for expression : '%s'", tt.wantResult, tt.expression.Expr)
+ if tc.expression.Match(req) != tc.wantResult {
+ t.Errorf("MatchExpression.Match() expected to return '%t', for expression : '%s'", tc.wantResult, tc.expression.Expr)
+ }
+ })
+ }
+}
+
+func BenchmarkMatchExpressionMatch(b *testing.B) {
+ for _, tst := range matcherTests {
+ tc := tst
+ if tc.wantErr {
+ continue
+ }
+ b.Run(tst.name, func(b *testing.B) {
+ tc.expression.Provision(caddy.Context{})
+ req := httptest.NewRequest(tc.httpMethod, tc.urlTarget, nil)
+ if tc.httpHeader != nil {
+ req.Header = *tc.httpHeader
}
+ repl := caddy.NewReplacer()
+ ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
+ req = req.WithContext(ctx)
+ addHTTPVarsToReplacer(repl, req, httptest.NewRecorder())
+ if tc.clientCertificate != nil {
+ block, _ := pem.Decode(clientCert)
+ if block == nil {
+ b.Fatalf("failed to decode PEM certificate")
+ }
+ cert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ b.Fatalf("failed to decode PEM certificate: %v", err)
+ }
+
+ req.TLS = &tls.ConnectionState{
+ PeerCertificates: []*x509.Certificate{cert},
+ }
+ }
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ tc.expression.Match(req)
+ }
+ })
+ }
+}
+
+func TestMatchExpressionProvision(t *testing.T) {
+ tests := []struct {
+ name string
+ expression *MatchExpression
+ wantErr bool
+ }{
+ {
+ name: "boolean matches succeed",
+ expression: &MatchExpression{
+ Expr: "{http.request.uri.query} != ''",
+ },
+ wantErr: false,
+ },
+ {
+ name: "reject expressions with non-boolean results",
+ expression: &MatchExpression{
+ Expr: "{http.request.uri.query}",
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if err := tt.expression.Provision(caddy.Context{}); (err != nil) != tt.wantErr {
+ t.Errorf("MatchExpression.Provision() error = %v, wantErr %v", err, tt.wantErr)
+ }
})
}
}
diff --git a/modules/caddyhttp/fileserver/matcher.go b/modules/caddyhttp/fileserver/matcher.go
index f8e9ce0..4f3ffef 100644
--- a/modules/caddyhttp/fileserver/matcher.go
+++ b/modules/caddyhttp/fileserver/matcher.go
@@ -26,6 +26,14 @@ import (
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
+ "github.com/google/cel-go/cel"
+ "github.com/google/cel-go/checker/decls"
+ "github.com/google/cel-go/common"
+ "github.com/google/cel-go/common/operators"
+ "github.com/google/cel-go/common/types/ref"
+ "github.com/google/cel-go/interpreter/functions"
+ "github.com/google/cel-go/parser"
+ exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
)
func init() {
@@ -139,6 +147,110 @@ func (m *MatchFile) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return nil
}
+// CELLibrary produces options that expose this matcher for use in CEL
+// expression matchers.
+//
+// Example:
+// expression file({'root': '/srv', 'try_files': [{http.request.uri.path}, '/index.php'], 'try_policy': 'first_exist', 'split_path': ['.php']})
+func (MatchFile) CELLibrary(ctx caddy.Context) (cel.Library, error) {
+ requestType := decls.NewObjectType("http.Request")
+ envOptions := []cel.EnvOption{
+ cel.Macros(parser.NewGlobalVarArgMacro("file", celFileMatcherMacroExpander())),
+ cel.Declarations(
+ decls.NewFunction("file",
+ decls.NewOverload("file_request_map",
+ []*exprpb.Type{requestType, caddyhttp.CelTypeJson},
+ decls.Bool,
+ ),
+ ),
+ ),
+ }
+
+ matcherFactory := func(data ref.Val) (caddyhttp.RequestMatcher, error) {
+ values, err := caddyhttp.CELValueToMapStrList(data)
+ if err != nil {
+ return nil, err
+ }
+
+ var root string
+ if len(values["root"]) > 0 {
+ root = values["root"][0]
+ }
+
+ var try_policy string
+ if len(values["try_policy"]) > 0 {
+ root = values["try_policy"][0]
+ }
+
+ m := MatchFile{
+ Root: root,
+ TryFiles: values["try_files"],
+ TryPolicy: try_policy,
+ SplitPath: values["split_path"],
+ }
+
+ err = m.Provision(ctx)
+ return m, err
+ }
+
+ programOptions := []cel.ProgramOption{
+ cel.CustomDecorator(caddyhttp.CELMatcherDecorator("file_request_map", matcherFactory)),
+ cel.Functions(
+ &functions.Overload{
+ Operator: "file_request_map",
+ Binary: caddyhttp.CELMatcherRuntimeFunction("file_request_map", matcherFactory),
+ },
+ ),
+ }
+
+ return caddyhttp.NewMatcherCELLibrary(envOptions, programOptions), nil
+}
+
+func celFileMatcherMacroExpander() parser.MacroExpander {
+ return func(eh parser.ExprHelper, target *exprpb.Expr, args []*exprpb.Expr) (*exprpb.Expr, *common.Error) {
+ if len(args) == 0 {
+ return nil, &common.Error{
+ Message: "matcher requires at least one argument",
+ }
+ }
+ if len(args) == 1 {
+ arg := args[0]
+ if isCELStringLiteral(arg) || isCELCaddyPlaceholderCall(arg) {
+ return eh.GlobalCall("file",
+ eh.Ident("request"),
+ eh.NewMap(
+ eh.NewMapEntry(eh.LiteralString("try_files"), eh.NewList(arg)),
+ ),
+ ), nil
+ }
+ if isCELTryFilesLiteral(arg) {
+ return eh.GlobalCall("file", eh.Ident("request"), arg), nil
+ }
+ return nil, &common.Error{
+ Location: eh.OffsetLocation(arg.GetId()),
+ Message: "matcher requires either a map or string literal argument",
+ }
+ }
+
+ for _, arg := range args {
+ if !(isCELStringLiteral(arg) || isCELCaddyPlaceholderCall(arg)) {
+ return nil, &common.Error{
+ Location: eh.OffsetLocation(arg.GetId()),
+ Message: "matcher only supports repeated string literal arguments",
+ }
+ }
+ }
+ return eh.GlobalCall("file",
+ eh.Ident("request"),
+ eh.NewMap(
+ eh.NewMapEntry(
+ eh.LiteralString("try_files"), eh.NewList(args...),
+ ),
+ ),
+ ), nil
+ }
+}
+
// Provision sets up m's defaults.
func (m *MatchFile) Provision(_ caddy.Context) error {
if m.Root == "" {
@@ -359,6 +471,107 @@ func indexFold(haystack, needle string) int {
return -1
}
+// isCELMapLiteral returns whether the expression resolves to a map literal containing
+// only string keys with or a placeholder call.
+func isCELTryFilesLiteral(e *exprpb.Expr) bool {
+ switch e.GetExprKind().(type) {
+ case *exprpb.Expr_StructExpr:
+ structExpr := e.GetStructExpr()
+ if structExpr.GetMessageName() != "" {
+ return false
+ }
+ for _, entry := range structExpr.GetEntries() {
+ mapKey := entry.GetMapKey()
+ mapVal := entry.GetValue()
+ if !isCELStringLiteral(mapKey) {
+ return false
+ }
+ mapKeyStr := mapKey.GetConstExpr().GetStringValue()
+ if mapKeyStr == "try_files" || mapKeyStr == "split_path" {
+ if !isCELStringListLiteral(mapVal) {
+ return false
+ }
+ } else if mapKeyStr == "try_policy" || mapKeyStr == "root" {
+ if !(isCELStringExpr(mapVal)) {
+ return false
+ }
+ } else {
+ return false
+ }
+ }
+ return true
+ }
+ return false
+}
+
+// isCELStringExpr indicates whether the expression is a supported string expression
+func isCELStringExpr(e *exprpb.Expr) bool {
+ return isCELStringLiteral(e) || isCELCaddyPlaceholderCall(e) || isCELConcatCall(e)
+}
+
+// isCELStringLiteral returns whether the expression is a CEL string literal.
+func isCELStringLiteral(e *exprpb.Expr) bool {
+ switch e.GetExprKind().(type) {
+ case *exprpb.Expr_ConstExpr:
+ constant := e.GetConstExpr()
+ switch constant.GetConstantKind().(type) {
+ case *exprpb.Constant_StringValue:
+ return true
+ }
+ }
+ return false
+}
+
+// isCELCaddyPlaceholderCall returns whether the expression is a caddy placeholder call.
+func isCELCaddyPlaceholderCall(e *exprpb.Expr) bool {
+ switch e.GetExprKind().(type) {
+ case *exprpb.Expr_CallExpr:
+ call := e.GetCallExpr()
+ if call.GetFunction() == "caddyPlaceholder" {
+ return true
+ }
+ }
+ return false
+}
+
+// isCELConcatCall tests whether the expression is a concat function (+) with string, placeholder, or
+// other concat call arguments.
+func isCELConcatCall(e *exprpb.Expr) bool {
+ switch e.GetExprKind().(type) {
+ case *exprpb.Expr_CallExpr:
+ call := e.GetCallExpr()
+ if call.GetTarget() != nil {
+ return false
+ }
+ if call.GetFunction() != operators.Add {
+ return false
+ }
+ for _, arg := range call.GetArgs() {
+ if !isCELStringExpr(arg) {
+ return false
+ }
+ }
+ return true
+ }
+ return false
+}
+
+// isCELStringListLiteral returns whether the expression resolves to a list literal
+// containing only string constants or a placeholder call.
+func isCELStringListLiteral(e *exprpb.Expr) bool {
+ switch e.GetExprKind().(type) {
+ case *exprpb.Expr_ListExpr:
+ list := e.GetListExpr()
+ for _, elem := range list.GetElements() {
+ if !isCELStringExpr(elem) {
+ return false
+ }
+ }
+ return true
+ }
+ return false
+}
+
const (
tryPolicyFirstExist = "first_exist"
tryPolicyLargestSize = "largest_size"
@@ -368,6 +581,7 @@ const (
// Interface guards
var (
- _ caddy.Validator = (*MatchFile)(nil)
- _ caddyhttp.RequestMatcher = (*MatchFile)(nil)
+ _ caddy.Validator = (*MatchFile)(nil)
+ _ caddyhttp.RequestMatcher = (*MatchFile)(nil)
+ _ caddyhttp.CELLibraryProducer = (*MatchFile)(nil)
)
diff --git a/modules/caddyhttp/fileserver/matcher_test.go b/modules/caddyhttp/fileserver/matcher_test.go
index 5b6078a..fd109e6 100644
--- a/modules/caddyhttp/fileserver/matcher_test.go
+++ b/modules/caddyhttp/fileserver/matcher_test.go
@@ -15,12 +15,15 @@
package fileserver
import (
+ "context"
"net/http"
+ "net/http/httptest"
"net/url"
"os"
"runtime"
"testing"
+ "github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
@@ -259,3 +262,109 @@ func TestFirstSplit(t *testing.T) {
t.Errorf("Expected remainder %s but got %s", expectedRemainder, remainder)
}
}
+
+var (
+ expressionTests = []struct {
+ name string
+ expression *caddyhttp.MatchExpression
+ urlTarget string
+ httpMethod string
+ httpHeader *http.Header
+ wantErr bool
+ wantResult bool
+ clientCertificate []byte
+ }{
+ {
+ name: "file error no args (MatchFile)",
+ expression: &caddyhttp.MatchExpression{
+ Expr: `file()`,
+ },
+ urlTarget: "https://example.com/foo",
+ wantErr: true,
+ },
+ {
+ name: "file error bad try files (MatchFile)",
+ expression: &caddyhttp.MatchExpression{
+ Expr: `file({"try_file": ["bad_arg"]})`,
+ },
+ urlTarget: "https://example.com/foo",
+ wantErr: true,
+ },
+ {
+ name: "file match short pattern index.php (MatchFile)",
+ expression: &caddyhttp.MatchExpression{
+ Expr: `file("index.php")`,
+ },
+ urlTarget: "https://example.com/foo",
+ wantResult: true,
+ },
+ {
+ name: "file match short pattern foo.txt (MatchFile)",
+ expression: &caddyhttp.MatchExpression{
+ Expr: `file({http.request.uri.path})`,
+ },
+ urlTarget: "https://example.com/foo.txt",
+ wantResult: true,
+ },
+ {
+ name: "file match index.php (MatchFile)",
+ expression: &caddyhttp.MatchExpression{
+ Expr: `file({"root": "./testdata", "try_files": [{http.request.uri.path}, "/index.php"]})`,
+ },
+ urlTarget: "https://example.com/foo",
+ wantResult: true,
+ },
+ {
+ name: "file match long pattern foo.txt (MatchFile)",
+ expression: &caddyhttp.MatchExpression{
+ Expr: `file({"root": "./testdata", "try_files": [{http.request.uri.path}]})`,
+ },
+ urlTarget: "https://example.com/foo.txt",
+ wantResult: true,
+ },
+ {
+ name: "file match long pattern foo.txt with concatenation (MatchFile)",
+ expression: &caddyhttp.MatchExpression{
+ Expr: `file({"root": ".", "try_files": ["./testdata" + {http.request.uri.path}]})`,
+ },
+ urlTarget: "https://example.com/foo.txt",
+ wantResult: true,
+ },
+ {
+ name: "file not match long pattern (MatchFile)",
+ expression: &caddyhttp.MatchExpression{
+ Expr: `file({"root": "./testdata", "try_files": [{http.request.uri.path}]})`,
+ },
+ urlTarget: "https://example.com/nopenope.txt",
+ wantResult: false,
+ },
+ }
+)
+
+func TestMatchExpressionMatch(t *testing.T) {
+ for _, tst := range expressionTests {
+ tc := tst
+ t.Run(tc.name, func(t *testing.T) {
+ err := tc.expression.Provision(caddy.Context{})
+ if err != nil {
+ if !tc.wantErr {
+ t.Errorf("MatchExpression.Provision() error = %v, wantErr %v", err, tc.wantErr)
+ }
+ return
+ }
+
+ req := httptest.NewRequest(tc.httpMethod, tc.urlTarget, nil)
+ if tc.httpHeader != nil {
+ req.Header = *tc.httpHeader
+ }
+ repl := caddyhttp.NewTestReplacer(req)
+ repl.Set("http.vars.root", "./testdata")
+ ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
+ req = req.WithContext(ctx)
+
+ if tc.expression.Match(req) != tc.wantResult {
+ t.Errorf("MatchExpression.Match() expected to return '%t', for expression : '%s'", tc.wantResult, tc.expression.Expr)
+ }
+ })
+ }
+}
diff --git a/modules/caddyhttp/matchers.go b/modules/caddyhttp/matchers.go
index f8953ef..268b936 100644
--- a/modules/caddyhttp/matchers.go
+++ b/modules/caddyhttp/matchers.go
@@ -16,6 +16,7 @@ package caddyhttp
import (
"encoding/json"
+ "errors"
"fmt"
"net"
"net/http"
@@ -23,6 +24,7 @@ import (
"net/url"
"path"
"path/filepath"
+ "reflect"
"regexp"
"sort"
"strconv"
@@ -30,7 +32,12 @@ import (
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
+ "github.com/google/cel-go/cel"
+ "github.com/google/cel-go/checker/decls"
+ "github.com/google/cel-go/common/types"
+ "github.com/google/cel-go/common/types/ref"
"go.uber.org/zap"
+ exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
)
type (
@@ -291,6 +298,29 @@ outer:
return false
}
+// CELLibrary produces options that expose this matcher for use in CEL
+// expression matchers.
+//
+// Example:
+// expression host('localhost')
+func (MatchHost) CELLibrary(ctx caddy.Context) (cel.Library, error) {
+ return CELMatcherImpl(
+ "host",
+ "host_match_request_list",
+ []*exprpb.Type{CelTypeListString},
+ func(data ref.Val) (RequestMatcher, error) {
+ refStringList := reflect.TypeOf([]string{})
+ strList, err := data.ConvertToNative(refStringList)
+ if err != nil {
+ return nil, err
+ }
+ matcher := MatchHost(strList.([]string))
+ err = matcher.Provision(ctx)
+ return matcher, err
+ },
+ )
+}
+
// fuzzy returns true if the given hostname h is not a specific
// hostname, e.g. has placeholders or wildcards.
func (MatchHost) fuzzy(h string) bool { return strings.ContainsAny(h, "{*") }
@@ -396,6 +426,33 @@ func (m MatchPath) Match(r *http.Request) bool {
return false
}
+// CELLibrary produces options that expose this matcher for use in CEL
+// expression matchers.
+//
+// Example:
+// expression path('*substring*', '*suffix')
+func (MatchPath) CELLibrary(ctx caddy.Context) (cel.Library, error) {
+ return CELMatcherImpl(
+ // name of the macro, this is the function name that users see when writing expressions.
+ "path",
+ // name of the function that the macro will be rewritten to call.
+ "path_match_request_list",
+ // internal data type of the MatchPath value.
+ []*exprpb.Type{CelTypeListString},
+ // function to convert a constant list of strings to a MatchPath instance.
+ func(data ref.Val) (RequestMatcher, error) {
+ refStringList := reflect.TypeOf([]string{})
+ strList, err := data.ConvertToNative(refStringList)
+ if err != nil {
+ return nil, err
+ }
+ matcher := MatchPath(strList.([]string))
+ err = matcher.Provision(ctx)
+ return matcher, err
+ },
+ )
+}
+
// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
func (m *MatchPath) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() {
@@ -440,6 +497,50 @@ func (m MatchPathRE) Match(r *http.Request) bool {
return m.MatchRegexp.Match(cleanedPath, repl)
}
+// CELLibrary produces options that expose this matcher for use in CEL
+// expression matchers.
+//
+// Example:
+// expression path_regexp('^/bar')
+func (MatchPathRE) CELLibrary(ctx caddy.Context) (cel.Library, error) {
+ unnamedPattern, err := CELMatcherImpl(
+ "path_regexp",
+ "path_regexp_request_string",
+ []*exprpb.Type{decls.String},
+ func(data ref.Val) (RequestMatcher, error) {
+ pattern := data.(types.String)
+ matcher := MatchPathRE{MatchRegexp{Pattern: string(pattern)}}
+ err := matcher.Provision(ctx)
+ return matcher, err
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+ namedPattern, err := CELMatcherImpl(
+ "path_regexp",
+ "path_regexp_request_string_string",
+ []*exprpb.Type{decls.String, decls.String},
+ func(data ref.Val) (RequestMatcher, error) {
+ refStringList := reflect.TypeOf([]string{})
+ params, err := data.ConvertToNative(refStringList)
+ if err != nil {
+ return nil, err
+ }
+ strParams := params.([]string)
+ matcher := MatchPathRE{MatchRegexp{Name: strParams[0], Pattern: strParams[1]}}
+ err = matcher.Provision(ctx)
+ return matcher, err
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+ envOpts := append(unnamedPattern.CompileOptions(), namedPattern.CompileOptions()...)
+ prgOpts := append(unnamedPattern.ProgramOptions(), namedPattern.ProgramOptions()...)
+ return NewMatcherCELLibrary(envOpts, prgOpts), nil
+}
+
// CaddyModule returns the Caddy module information.
func (MatchMethod) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
@@ -469,6 +570,27 @@ func (m MatchMethod) Match(r *http.Request) bool {
return false
}
+// CELLibrary produces options that expose this matcher for use in CEL
+// expression matchers.
+//
+// Example:
+// expression method('PUT', 'POST')
+func (MatchMethod) CELLibrary(_ caddy.Context) (cel.Library, error) {
+ return CELMatcherImpl(
+ "method",
+ "method_request_list",
+ []*exprpb.Type{CelTypeListString},
+ func(data ref.Val) (RequestMatcher, error) {
+ refStringList := reflect.TypeOf([]string{})
+ strList, err := data.ConvertToNative(refStringList)
+ if err != nil {
+ return nil, err
+ }
+ return MatchMethod(strList.([]string)), nil
+ },
+ )
+}
+
// CaddyModule returns the Caddy module information.
func (MatchQuery) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
@@ -518,6 +640,26 @@ func (m MatchQuery) Match(r *http.Request) bool {
return len(m) == 0 && len(r.URL.Query()) == 0
}
+// CELLibrary produces options that expose this matcher for use in CEL
+// expression matchers.
+//
+// Example:
+// expression query({'sort': 'asc'}) || query({'foo': ['*bar*', 'baz']})
+func (MatchQuery) CELLibrary(_ caddy.Context) (cel.Library, error) {
+ return CELMatcherImpl(
+ "query",
+ "query_matcher_request_map",
+ []*exprpb.Type{CelTypeJson},
+ func(data ref.Val) (RequestMatcher, error) {
+ mapStrListStr, err := CELValueToMapStrList(data)
+ if err != nil {
+ return nil, err
+ }
+ return MatchQuery(url.Values(mapStrListStr)), nil
+ },
+ )
+}
+
// CaddyModule returns the Caddy module information.
func (MatchHeader) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
@@ -573,6 +715,27 @@ func (m MatchHeader) Match(r *http.Request) bool {
return matchHeaders(r.Header, http.Header(m), r.Host, repl)
}
+// CELLibrary produces options that expose this matcher for use in CEL
+// expression matchers.
+//
+// Example:
+// expression header({'content-type': 'image/png'})
+// expression header({'foo': ['bar', 'baz']}) // match bar or baz
+func (MatchHeader) CELLibrary(_ caddy.Context) (cel.Library, error) {
+ return CELMatcherImpl(
+ "header",
+ "header_matcher_request_map",
+ []*exprpb.Type{CelTypeJson},
+ func(data ref.Val) (RequestMatcher, error) {
+ mapStrListStr, err := CELValueToMapStrList(data)
+ if err != nil {
+ return nil, err
+ }
+ return MatchHeader(http.Header(mapStrListStr)), nil
+ },
+ )
+}
+
// getHeaderFieldVals returns the field values for the given fieldName from input.
// The host parameter should be obtained from the http.Request.Host field since
// net/http removes it from the header map.
@@ -710,6 +873,57 @@ func (m MatchHeaderRE) Validate() error {
return nil
}
+// CELLibrary produces options that expose this matcher for use in CEL
+// expression matchers.
+//
+// Example:
+// expression header_regexp('foo', 'Field', 'fo+')
+func (MatchHeaderRE) CELLibrary(ctx caddy.Context) (cel.Library, error) {
+ unnamedPattern, err := CELMatcherImpl(
+ "header_regexp",
+ "header_regexp_request_string_string",
+ []*exprpb.Type{decls.String, decls.String},
+ func(data ref.Val) (RequestMatcher, error) {
+ refStringList := reflect.TypeOf([]string{})
+ params, err := data.ConvertToNative(refStringList)
+ if err != nil {
+ return nil, err
+ }
+ strParams := params.([]string)
+ matcher := MatchHeaderRE{}
+ matcher[strParams[0]] = &MatchRegexp{Pattern: strParams[1], Name: ""}
+ err = matcher.Provision(ctx)
+ return matcher, err
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+ namedPattern, err := CELMatcherImpl(
+ "header_regexp",
+ "header_regexp_request_string_string_string",
+ []*exprpb.Type{decls.String, decls.String, decls.String},
+ func(data ref.Val) (RequestMatcher, error) {
+ refStringList := reflect.TypeOf([]string{})
+ params, err := data.ConvertToNative(refStringList)
+ if err != nil {
+ return nil, err
+ }
+ strParams := params.([]string)
+ matcher := MatchHeaderRE{}
+ matcher[strParams[1]] = &MatchRegexp{Pattern: strParams[2], Name: strParams[0]}
+ err = matcher.Provision(ctx)
+ return matcher, err
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+ envOpts := append(unnamedPattern.CompileOptions(), namedPattern.CompileOptions()...)
+ prgOpts := append(unnamedPattern.ProgramOptions(), namedPattern.ProgramOptions()...)
+ return NewMatcherCELLibrary(envOpts, prgOpts), nil
+}
+
// CaddyModule returns the Caddy module information.
func (MatchProtocol) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
@@ -743,6 +957,26 @@ func (m *MatchProtocol) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return nil
}
+// CELLibrary produces options that expose this matcher for use in CEL
+// expression matchers.
+//
+// Example:
+// expression protocol('https')
+func (MatchProtocol) CELLibrary(_ caddy.Context) (cel.Library, error) {
+ return CELMatcherImpl(
+ "protocol",
+ "protocol_request_string",
+ []*exprpb.Type{decls.String},
+ func(data ref.Val) (RequestMatcher, error) {
+ protocolStr, ok := data.(types.String)
+ if !ok {
+ return nil, errors.New("protocol argument was not a string")
+ }
+ return MatchProtocol(strings.ToLower(string(protocolStr))), nil
+ },
+ )
+}
+
// CaddyModule returns the Caddy module information.
func (MatchNot) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
@@ -887,6 +1121,46 @@ func (m *MatchRemoteIP) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return nil
}
+// CELLibrary produces options that expose this matcher for use in CEL
+// expression matchers.
+//
+// Example:
+// expression remote_ip('forwarded', '192.168.0.0/16', '172.16.0.0/12', '10.0.0.0/8')
+func (MatchRemoteIP) CELLibrary(ctx caddy.Context) (cel.Library, error) {
+ return CELMatcherImpl(
+ // name of the macro, this is the function name that users see when writing expressions.
+ "remote_ip",
+ // name of the function that the macro will be rewritten to call.
+ "remote_ip_match_request_list",
+ // internal data type of the MatchPath value.
+ []*exprpb.Type{CelTypeListString},
+ // function to convert a constant list of strings to a MatchPath instance.
+ func(data ref.Val) (RequestMatcher, error) {
+ refStringList := reflect.TypeOf([]string{})
+ strList, err := data.ConvertToNative(refStringList)
+ if err != nil {
+ return nil, err
+ }
+
+ m := MatchRemoteIP{}
+
+ for _, input := range strList.([]string) {
+ if input == "forwarded" {
+ if len(m.Ranges) > 0 {
+ return nil, errors.New("if used, 'forwarded' must be first argument")
+ }
+ m.Forwarded = true
+ continue
+ }
+ m.Ranges = append(m.Ranges, input)
+ }
+
+ err = m.Provision(ctx)
+ return m, err
+ },
+ )
+}
+
// Provision parses m's IP ranges, either from IP or CIDR expressions.
func (m *MatchRemoteIP) Provision(ctx caddy.Context) error {
m.logger = ctx.Logger(m)
@@ -1062,7 +1336,9 @@ func (mre *MatchRegexp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return nil
}
-var wordRE = regexp.MustCompile(`\w+`)
+var (
+ wordRE = regexp.MustCompile(`\w+`)
+)
const regexpPlaceholderPrefix = "http.regexp"
@@ -1103,6 +1379,18 @@ var (
_ caddyfile.Unmarshaler = (*VarsMatcher)(nil)
_ caddyfile.Unmarshaler = (*MatchVarsRE)(nil)
+ _ CELLibraryProducer = (*MatchHost)(nil)
+ _ CELLibraryProducer = (*MatchPath)(nil)
+ _ CELLibraryProducer = (*MatchPathRE)(nil)
+ _ CELLibraryProducer = (*MatchMethod)(nil)
+ _ CELLibraryProducer = (*MatchQuery)(nil)
+ _ CELLibraryProducer = (*MatchHeader)(nil)
+ _ CELLibraryProducer = (*MatchHeaderRE)(nil)
+ _ CELLibraryProducer = (*MatchProtocol)(nil)
+ _ CELLibraryProducer = (*MatchRemoteIP)(nil)
+ // _ CELLibraryProducer = (*VarsMatcher)(nil)
+ // _ CELLibraryProducer = (*MatchVarsRE)(nil)
+
_ json.Marshaler = (*MatchNot)(nil)
_ json.Unmarshaler = (*MatchNot)(nil)
)