summaryrefslogtreecommitdiff
path: root/modules/caddyhttp/fileserver
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/fileserver
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/fileserver')
-rw-r--r--modules/caddyhttp/fileserver/matcher.go218
-rw-r--r--modules/caddyhttp/fileserver/matcher_test.go109
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)
+ }
+ })
+ }
+}