diff options
author | Tristan Swadell <tswadell@google.com> | 2022-06-22 15:53:46 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-06-22 18:53:46 -0400 |
commit | 10f85558ead15e119f8e9abd81c8ad55eb865f8b (patch) | |
tree | eac30b8c4d91b8a9f8332d4733038d6d2e7235cd /modules/caddyhttp/fileserver | |
parent | 98468af8b6224d29431576fe30a7d92a8676030d (diff) |
Expose several Caddy HTTP Matchers to the CEL Matcher (#4715)
Co-authored-by: Francis Lavoie <lavofr@gmail.com>
Diffstat (limited to 'modules/caddyhttp/fileserver')
-rw-r--r-- | modules/caddyhttp/fileserver/matcher.go | 218 | ||||
-rw-r--r-- | modules/caddyhttp/fileserver/matcher_test.go | 109 |
2 files changed, 325 insertions, 2 deletions
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) + } + }) + } +} |