From 10f85558ead15e119f8e9abd81c8ad55eb865f8b Mon Sep 17 00:00:00 2001 From: Tristan Swadell Date: Wed, 22 Jun 2022 15:53:46 -0700 Subject: [PATCH] Expose several Caddy HTTP Matchers to the CEL Matcher (#4715) Co-authored-by: Francis Lavoie --- go.mod | 10 +- go.sum | 22 +- modules/caddyhttp/celmatcher.go | 481 ++++++++++++++++- modules/caddyhttp/celmatcher_test.go | 518 ++++++++++++++++--- modules/caddyhttp/fileserver/matcher.go | 218 +++++++- modules/caddyhttp/fileserver/matcher_test.go | 109 ++++ modules/caddyhttp/matchers.go | 290 ++++++++++- 7 files changed, 1552 insertions(+), 96 deletions(-) diff --git a/go.mod b/go.mod index 1422649b..077e8343 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/caddyserver/certmagic v0.16.1 github.com/dustin/go-humanize v1.0.1-0.20200219035652-afde56e7acac github.com/go-chi/chi v4.1.2+incompatible - github.com/google/cel-go v0.7.3 + github.com/google/cel-go v0.11.4 github.com/google/uuid v1.3.0 github.com/klauspost/compress v1.15.6 github.com/klauspost/cpuid/v2 v2.0.13 @@ -32,8 +32,8 @@ require ( golang.org/x/crypto v0.0.0-20220210151621-f4118a5b28e2 golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 - google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf - google.golang.org/protobuf v1.27.1 + google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21 + google.golang.org/protobuf v1.28.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -43,7 +43,7 @@ require ( github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.1.1 // indirect - github.com/antlr/antlr4 v0.0.0-20200503195918-621b933c7a7f // indirect + github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220418222510-f25a4f6275ed // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.1.2 // indirect github.com/cespare/xxhash v1.1.0 // indirect @@ -125,7 +125,7 @@ require ( golang.org/x/text v0.3.8-0.20211004125949-5bd84dd9b33b // indirect golang.org/x/tools v0.1.7 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect - google.golang.org/grpc v1.44.0 // indirect + google.golang.org/grpc v1.46.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect howett.net/plist v1.0.0 // indirect diff --git a/go.sum b/go.sum index ba48313c..56ae7cab 100644 --- a/go.sum +++ b/go.sum @@ -149,8 +149,8 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk5 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/antlr/antlr4 v0.0.0-20200503195918-621b933c7a7f h1:0cEys61Sr2hUBEXfNV8eyQP01oZuBgoMeHunebPirK8= -github.com/antlr/antlr4 v0.0.0-20200503195918-621b933c7a7f/go.mod h1:T7PbCXFs94rrTttyxjbyT5+/1V8T2TYDejxUfHJjw1Y= +github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220418222510-f25a4f6275ed h1:ue9pVfIcP+QMEjfgo/Ez4ZjNZfonGgR6NgjMaJMu1Cg= +github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220418222510-f25a4f6275ed/go.mod h1:F7bn7fEU90QkQ3tnmaTx3LTKLEDqnwWODIYppRQ5hnY= github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= github.com/apache/beam v2.28.0+incompatible/go.mod h1:/8NX3Qi8vGstDLLaeaU7+lzVEu/ACaQhYjeefzQ0y1o= github.com/apache/beam v2.30.0+incompatible/go.mod h1:/8NX3Qi8vGstDLLaeaU7+lzVEu/ACaQhYjeefzQ0y1o= @@ -238,6 +238,7 @@ github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XP github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= @@ -311,6 +312,7 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.3.0-java/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/etcd-io/gofail v0.0.0-20190801230047-ad7f989257ca/go.mod h1:49H/RkXP8pKaZy4h0d+NW16rSLhyVBt4o6VLJbmOqDE= @@ -390,6 +392,7 @@ github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXP github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -433,9 +436,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA= -github.com/google/cel-go v0.7.3 h1:8v9BSN0avuGwrHFKNCjfiQ/CE6+D6sW+BDyOVoEeP6o= -github.com/google/cel-go v0.7.3/go.mod h1:4EtyFAHT5xNr0Msu0MJjyGxPUgdr9DlcaPyzLt/kkt8= -github.com/google/cel-spec v0.5.0/go.mod h1:Nwjgxy5CbjlPrtCWjeDjUyKMl8w41YBYGjsyDdqk0xA= +github.com/google/cel-go v0.11.4 h1:wWOnKmLxALl3l9Av221MfIOWRiR01sDVljzg6LZ6Zn0= +github.com/google/cel-go v0.11.4/go.mod h1:Av7CU6r6X3YmcHR9GXqVDaEJYfEtSxl6wvIjUQTriCw= github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= github.com/google/certificate-transparency-go v1.1.2-0.20210422104406-9f33727a7a18/go.mod h1:6CKh9dscIRoqc2kC6YUFICHZMT9NrClyPrRVFrdw1QQ= github.com/google/certificate-transparency-go v1.1.2-0.20210512142713-bed466244fa6/go.mod h1:aF2dp7Dh81mY8Y/zpzyXps4fQW5zQbDu2CxfpJB6NkI= @@ -1714,7 +1716,6 @@ google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201102152239-715cce707fb0/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= @@ -1754,8 +1755,9 @@ google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= -google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf h1:SVYXkUz2yZS9FWb2Gm8ivSlbNQzL2Z/NpPKE3RG2jWk= google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21 h1:hrbNEivu7Zn1pxvHk6MBrq9iE22woVILTHqexqBxe6I= +google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= @@ -1793,8 +1795,9 @@ google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9K google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k= google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= -google.golang.org/grpc v1.44.0 h1:weqSxi/TMs1SqFRMHCtBgXRs8k3X39QIDEZ0pRcttUg= google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.46.0 h1:oCjezcn6g6A75TGoKYBPgKmVBLexhYLM6MebdrPApP8= +google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1809,8 +1812,9 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.25.1-0.20200805231151-a709e31e5d12/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/modules/caddyhttp/celmatcher.go b/modules/caddyhttp/celmatcher.go index ee640fd3..4938cd5c 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: (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: (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: (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 d71fc421..3604562b 100644 --- a/modules/caddyhttp/celmatcher_test.go +++ b/modules/caddyhttp/celmatcher_test.go @@ -19,12 +19,462 @@ import ( "crypto/tls" "crypto/x509" "encoding/pem" + "net/http" "net/http/httptest" "testing" "github.com/caddyserver/caddy/v2" ) +var ( + clientCert = []byte(`-----BEGIN CERTIFICATE----- +MIIB9jCCAV+gAwIBAgIBAjANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA1DYWRk +eSBUZXN0IENBMB4XDTE4MDcyNDIxMzUwNVoXDTI4MDcyMTIxMzUwNVowHTEbMBkG +A1UEAwwSY2xpZW50LmxvY2FsZG9tYWluMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB +iQKBgQDFDEpzF0ew68teT3xDzcUxVFaTII+jXH1ftHXxxP4BEYBU4q90qzeKFneF +z83I0nC0WAQ45ZwHfhLMYHFzHPdxr6+jkvKPASf0J2v2HDJuTM1bHBbik5Ls5eq+ +fVZDP8o/VHKSBKxNs8Goc2NTsr5b07QTIpkRStQK+RJALk4x9QIDAQABo0swSTAJ +BgNVHRMEAjAAMAsGA1UdDwQEAwIHgDAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8A +AAEwEwYDVR0lBAwwCgYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADgYEANSjz2Sk+ +eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV +3Q9fgDkiUod+uIK0IynzIKvw+Cjg+3nx6NQ0IM0zo8c7v398RzB4apbXKZyeeqUH +9fNwfEi+OoXR6s+upSKobCmLGLGi9Na5s5g= +-----END CERTIFICATE-----`) + + matcherTests = []struct { + name string + expression *MatchExpression + urlTarget string + httpMethod string + httpHeader *http.Header + wantErr bool + wantResult bool + clientCertificate []byte + }{ + { + name: "boolean matches succeed for placeholder http.request.tls.client.subject", + expression: &MatchExpression{ + 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, + }, + } +) + +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(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 { + t.Fatalf("failed to decode PEM certificate") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to decode PEM certificate: %v", err) + } + + req.TLS = &tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{cert}, + } + } + + 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 @@ -54,71 +504,3 @@ func TestMatchExpressionProvision(t *testing.T) { }) } } - -func TestMatchExpressionMatch(t *testing.T) { - - clientCert := []byte(`-----BEGIN CERTIFICATE----- -MIIB9jCCAV+gAwIBAgIBAjANBgkqhkiG9w0BAQsFADAYMRYwFAYDVQQDDA1DYWRk -eSBUZXN0IENBMB4XDTE4MDcyNDIxMzUwNVoXDTI4MDcyMTIxMzUwNVowHTEbMBkG -A1UEAwwSY2xpZW50LmxvY2FsZG9tYWluMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCB -iQKBgQDFDEpzF0ew68teT3xDzcUxVFaTII+jXH1ftHXxxP4BEYBU4q90qzeKFneF -z83I0nC0WAQ45ZwHfhLMYHFzHPdxr6+jkvKPASf0J2v2HDJuTM1bHBbik5Ls5eq+ -fVZDP8o/VHKSBKxNs8Goc2NTsr5b07QTIpkRStQK+RJALk4x9QIDAQABo0swSTAJ -BgNVHRMEAjAAMAsGA1UdDwQEAwIHgDAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8A -AAEwEwYDVR0lBAwwCgYIKwYBBQUHAwIwDQYJKoZIhvcNAQELBQADgYEANSjz2Sk+ -eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV -3Q9fgDkiUod+uIK0IynzIKvw+Cjg+3nx6NQ0IM0zo8c7v398RzB4apbXKZyeeqUH -9fNwfEi+OoXR6s+upSKobCmLGLGi9Na5s5g= ------END CERTIFICATE-----`) - - tests := []struct { - name string - expression *MatchExpression - wantErr bool - wantResult bool - clientCertificate []byte - }{ - { - name: "boolean matches succeed for placeholder http.request.tls.client.subject", - expression: &MatchExpression{ - Expr: "{http.request.tls.client.subject} == 'CN=client.localdomain'", - }, - clientCertificate: clientCert, - wantResult: 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) - } - - req := httptest.NewRequest("GET", "https://example.com/foo", nil) - repl := caddy.NewReplacer() - ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl) - req = req.WithContext(ctx) - addHTTPVarsToReplacer(repl, req, httptest.NewRecorder()) - - if tt.clientCertificate != nil { - block, _ := pem.Decode(clientCert) - if block == nil { - t.Fatalf("failed to decode PEM certificate") - } - - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - t.Fatalf("failed to decode PEM certificate: %v", err) - } - - req.TLS = &tls.ConnectionState{ - PeerCertificates: []*x509.Certificate{cert}, - } - } - - if tt.expression.Match(req) != tt.wantResult { - t.Errorf("MatchExpression.Match() expected to return '%t', for expression : '%s'", tt.wantResult, tt.expression.Expr) - } - - }) - } -} diff --git a/modules/caddyhttp/fileserver/matcher.go b/modules/caddyhttp/fileserver/matcher.go index f8e9ce0f..4f3ffefa 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 5b6078ad..fd109e65 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 f8953ef5..268b936b 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) )