diff options
Diffstat (limited to 'modules/caddyhttp')
-rw-r--r-- | modules/caddyhttp/celmatcher.go | 481 | ||||
-rw-r--r-- | modules/caddyhttp/celmatcher_test.go | 466 | ||||
-rw-r--r-- | modules/caddyhttp/fileserver/matcher.go | 218 | ||||
-rw-r--r-- | modules/caddyhttp/fileserver/matcher_test.go | 109 | ||||
-rw-r--r-- | modules/caddyhttp/matchers.go | 290 |
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) ) |