diff --git a/api/config/module/proxy/v1/proxy.proto b/api/config/module/proxy/v1/proxy.proto index 42004f0385..6693872925 100644 --- a/api/config/module/proxy/v1/proxy.proto +++ b/api/config/module/proxy/v1/proxy.proto @@ -22,6 +22,12 @@ message Service { } message AllowRequest { - string path = 1 [ (validate.rules).string.min_len = 1 ]; + oneof path_type { + option (validate.required) = true; + // Must match the request path exactly + string path = 1 [ (validate.rules).string.min_len = 1 ]; + // Request path must match the regex pattern + string path_regex = 3 [ (validate.rules).string.min_len = 1 ]; + } string method = 2 [ (validate.rules).string.min_len = 1 ]; } diff --git a/backend/api/config/module/proxy/v1/proxy.pb.go b/backend/api/config/module/proxy/v1/proxy.pb.go index 4b5a5ad541..8460e883f8 100644 --- a/backend/api/config/module/proxy/v1/proxy.pb.go +++ b/backend/api/config/module/proxy/v1/proxy.pb.go @@ -148,8 +148,12 @@ type AllowRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` - Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"` + // Types that are assignable to PathType: + // + // *AllowRequest_Path + // *AllowRequest_PathRegex + PathType isAllowRequest_PathType `protobuf_oneof:"path_type"` + Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"` } func (x *AllowRequest) Reset() { @@ -184,13 +188,27 @@ func (*AllowRequest) Descriptor() ([]byte, []int) { return file_config_module_proxy_v1_proxy_proto_rawDescGZIP(), []int{2} } +func (m *AllowRequest) GetPathType() isAllowRequest_PathType { + if m != nil { + return m.PathType + } + return nil +} + func (x *AllowRequest) GetPath() string { - if x != nil { + if x, ok := x.GetPathType().(*AllowRequest_Path); ok { return x.Path } return "" } +func (x *AllowRequest) GetPathRegex() string { + if x, ok := x.GetPathType().(*AllowRequest_PathRegex); ok { + return x.PathRegex + } + return "" +} + func (x *AllowRequest) GetMethod() string { if x != nil { return x.Method @@ -198,6 +216,24 @@ func (x *AllowRequest) GetMethod() string { return "" } +type isAllowRequest_PathType interface { + isAllowRequest_PathType() +} + +type AllowRequest_Path struct { + // Must match the request path exactly + Path string `protobuf:"bytes,1,opt,name=path,proto3,oneof"` +} + +type AllowRequest_PathRegex struct { + // Request path must match the regex pattern + PathRegex string `protobuf:"bytes,3,opt,name=path_regex,json=pathRegex,proto3,oneof"` +} + +func (*AllowRequest_Path) isAllowRequest_PathType() {} + +func (*AllowRequest_PathRegex) isAllowRequest_PathType() {} + var File_config_module_proxy_v1_proxy_proto protoreflect.FileDescriptor var file_config_module_proxy_v1_proxy_proto_rawDesc = []byte{ @@ -230,17 +266,21 @@ var file_config_module_proxy_v1_proxy_proto_rawDesc = []byte{ 0x73, 0x1a, 0x3a, 0x0a, 0x0c, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x4c, 0x0a, - 0x0c, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, - 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, - 0x72, 0x02, 0x10, 0x01, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x06, 0x6d, 0x65, - 0x74, 0x68, 0x6f, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, - 0x02, 0x10, 0x01, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x42, 0x43, 0x5a, 0x41, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x79, 0x66, 0x74, 0x2f, 0x63, - 0x6c, 0x75, 0x74, 0x63, 0x68, 0x2f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2f, 0x61, 0x70, - 0x69, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x2f, - 0x70, 0x72, 0x6f, 0x78, 0x79, 0x2f, 0x76, 0x31, 0x3b, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x76, 0x31, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x8a, 0x01, + 0x0a, 0x0c, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, + 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, + 0x04, 0x72, 0x02, 0x10, 0x01, 0x48, 0x00, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x28, 0x0a, + 0x0a, 0x70, 0x61, 0x74, 0x68, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, 0x48, 0x00, 0x52, 0x09, 0x70, 0x61, + 0x74, 0x68, 0x52, 0x65, 0x67, 0x65, 0x78, 0x12, 0x1f, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x72, 0x02, 0x10, 0x01, + 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x42, 0x10, 0x0a, 0x09, 0x70, 0x61, 0x74, 0x68, + 0x5f, 0x74, 0x79, 0x70, 0x65, 0x12, 0x03, 0xf8, 0x42, 0x01, 0x42, 0x43, 0x5a, 0x41, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x79, 0x66, 0x74, 0x2f, 0x63, 0x6c, + 0x75, 0x74, 0x63, 0x68, 0x2f, 0x62, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x2f, 0x61, 0x70, 0x69, + 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x75, 0x6c, 0x65, 0x2f, 0x70, + 0x72, 0x6f, 0x78, 0x79, 0x2f, 0x76, 0x31, 0x3b, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x76, 0x31, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -316,6 +356,10 @@ func file_config_module_proxy_v1_proxy_proto_init() { } } } + file_config_module_proxy_v1_proxy_proto_msgTypes[2].OneofWrappers = []interface{}{ + (*AllowRequest_Path)(nil), + (*AllowRequest_PathRegex)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/backend/api/config/module/proxy/v1/proxy.pb.validate.go b/backend/api/config/module/proxy/v1/proxy.pb.validate.go index 2a1d6af1d7..39b51b6459 100644 --- a/backend/api/config/module/proxy/v1/proxy.pb.validate.go +++ b/backend/api/config/module/proxy/v1/proxy.pb.validate.go @@ -356,9 +356,9 @@ func (m *AllowRequest) validate(all bool) error { var errors []error - if utf8.RuneCountInString(m.GetPath()) < 1 { + if utf8.RuneCountInString(m.GetMethod()) < 1 { err := AllowRequestValidationError{ - field: "Path", + field: "Method", reason: "value length must be at least 1 runes", } if !all { @@ -367,10 +367,63 @@ func (m *AllowRequest) validate(all bool) error { errors = append(errors, err) } - if utf8.RuneCountInString(m.GetMethod()) < 1 { + oneofPathTypePresent := false + switch v := m.PathType.(type) { + case *AllowRequest_Path: + if v == nil { + err := AllowRequestValidationError{ + field: "PathType", + reason: "oneof value cannot be a typed-nil", + } + if !all { + return err + } + errors = append(errors, err) + } + oneofPathTypePresent = true + + if utf8.RuneCountInString(m.GetPath()) < 1 { + err := AllowRequestValidationError{ + field: "Path", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + case *AllowRequest_PathRegex: + if v == nil { + err := AllowRequestValidationError{ + field: "PathType", + reason: "oneof value cannot be a typed-nil", + } + if !all { + return err + } + errors = append(errors, err) + } + oneofPathTypePresent = true + + if utf8.RuneCountInString(m.GetPathRegex()) < 1 { + err := AllowRequestValidationError{ + field: "PathRegex", + reason: "value length must be at least 1 runes", + } + if !all { + return err + } + errors = append(errors, err) + } + + default: + _ = v // ensures v is used + } + if !oneofPathTypePresent { err := AllowRequestValidationError{ - field: "Method", - reason: "value length must be at least 1 runes", + field: "PathType", + reason: "value is required", } if !all { return err diff --git a/backend/module/proxy/proxy.go b/backend/module/proxy/proxy.go index cf5efb19df..27eec7cf88 100644 --- a/backend/module/proxy/proxy.go +++ b/backend/module/proxy/proxy.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "regexp" "strings" "github.com/golang/protobuf/ptypes/any" @@ -35,14 +36,9 @@ func New(cfg *any.Any, log *zap.Logger, scope tally.Scope) (module.Module, error return nil, err } - // Validate that each services constructs a parsable URL - for _, service := range config.Services { - for _, ar := range service.AllowedRequests { - _, err := url.Parse(fmt.Sprintf("%s%s", service.Host, ar.Path)) - if err != nil { - return nil, fmt.Errorf("unable to parse the configured URL for service [%s]", service.Name) - } - } + err = validateConfigPaths(config) + if err != nil { + return nil, err } m := &mod{ @@ -205,13 +201,25 @@ func isAllowedRequest(services []*proxyv1cfg.Service, service, path, method stri for _, s := range services { if s.Name == service { for _, ar := range s.AllowedRequests { - parsedUrl, err := url.Parse(fmt.Sprintf("%s%s", s.Host, path)) - if err != nil { - return false, err - } - - if parsedUrl.Path == ar.Path && strings.EqualFold(method, ar.Method) { - return true, nil + switch t := ar.PathType.(type) { + case *proxyv1cfg.AllowRequest_Path: + parsedUrl, err := url.Parse(fmt.Sprintf("%s%s", s.Host, path)) + if err != nil { + return false, err + } + if parsedUrl.Path == t.Path && strings.EqualFold(method, ar.Method) { + return true, nil + } + case *proxyv1cfg.AllowRequest_PathRegex: + r, err := regexp.Compile(t.PathRegex) + if err != nil { + return false, err + } + if r.MatchString(path) { + return true, nil + } + default: + return false, fmt.Errorf("path type not supported: %T", t) } } // return early here as were done checking allowed request for this service @@ -235,3 +243,27 @@ func addExcludedHeaders(request *http.Request) { request.Host = hostHeader } } + +func validateConfigPaths(config *proxyv1cfg.Config) error { + for _, service := range config.Services { + for _, ar := range service.AllowedRequests { + switch t := ar.PathType.(type) { + case *proxyv1cfg.AllowRequest_Path: + // For exact path type, validate that string constructs a parsable URL + _, err := url.Parse(fmt.Sprintf("%s%s", service.Host, t.Path)) + if err != nil { + return fmt.Errorf("unable to parse the configured URL for service [%s]", service.Name) + } + case *proxyv1cfg.AllowRequest_PathRegex: + // For path regex type, validate that expression can be parsed + _, err := regexp.Compile(t.PathRegex) + if err != nil { + return err + } + default: + return fmt.Errorf("path type not supported: %T", t) + } + } + } + return nil +} diff --git a/backend/module/proxy/proxy_test.go b/backend/module/proxy/proxy_test.go index 7356a8e61d..e7f51d6670 100644 --- a/backend/module/proxy/proxy_test.go +++ b/backend/module/proxy/proxy_test.go @@ -28,16 +28,18 @@ func generateServicesConfig(host string) []*proxyv1cfg.Service { Name: "cat", Host: host, AllowedRequests: []*proxyv1cfg.AllowRequest{ - {Path: "/meow", Method: "GET"}, - {Path: "/nom", Method: "POST"}, + {PathType: &proxyv1cfg.AllowRequest_Path{Path: "/meow"}, Method: "GET"}, + {PathType: &proxyv1cfg.AllowRequest_Path{Path: "/nom"}, Method: "POST"}, + {PathType: &proxyv1cfg.AllowRequest_PathRegex{PathRegex: `/cat/\w+/[0-9]`}, Method: "GET"}, }, }, { Name: "meow", Host: host, AllowedRequests: []*proxyv1cfg.AllowRequest{ - {Path: "/meow", Method: "GET"}, - {Path: "/nom", Method: "POST"}, + {PathType: &proxyv1cfg.AllowRequest_Path{Path: "/meow"}, Method: "GET"}, + {PathType: &proxyv1cfg.AllowRequest_Path{Path: "/nom"}, Method: "POST"}, + {PathType: &proxyv1cfg.AllowRequest_PathRegex{PathRegex: `^/meow/\w+/\w+$`}, Method: "GET"}, }, }, } @@ -268,6 +270,62 @@ func TestIsAllowedRequest(t *testing.T) { expect: false, shouldError: false, }, + { + id: "Path string matches regex", + service: "cat", + path: "/cat/bengal/1", + method: "POST", + expect: true, + shouldError: false, + }, + { + id: "Regex expects number in path string", + service: "cat", + path: "/cat/bengal/abc", + method: "POST", + expect: false, + shouldError: false, + }, + { + id: "Regex expects third parameter in string", + service: "cat", + path: "/cat/bengal", + method: "POST", + expect: false, + shouldError: false, + }, + { + id: "Regex does not have a rule for end of string", + service: "cat", + path: "/cat/bengal/1/comments", + method: "POST", + expect: true, + shouldError: false, + }, + { + id: "Path string matches regex", + service: "meow", + path: "/meow/foo/sound", + method: "POST", + expect: true, + shouldError: false, + }, + { + id: "Path string does not match regex last character", + service: "meow", + path: "/meow/foo/sound/play", + method: "POST", + expect: false, + shouldError: false, + }, + { + id: "Path string does not match regex first character", + service: "meow", + path: "animals/meow/foo/sound/play", + method: "POST", + expect: false, + shouldError: false, + }, } services := generateServicesConfig("http://test.test") @@ -283,6 +341,72 @@ func TestIsAllowedRequest(t *testing.T) { } } +func TestValidateConfigPaths(t *testing.T) { + tests := []struct { + id string + config *proxyv1cfg.Config + shouldError bool + }{ + { + id: "All paths parsable", + config: &proxyv1cfg.Config{ + Services: []*proxyv1cfg.Service{ + { + Name: "cat", + Host: "http://test.test", + AllowedRequests: []*proxyv1cfg.AllowRequest{ + {PathType: &proxyv1cfg.AllowRequest_Path{Path: "/meow"}}, + {PathType: &proxyv1cfg.AllowRequest_PathRegex{PathRegex: `/cat/\w+/[0-9]`}}, + }, + }, + }, + }, + shouldError: false, + }, + { + id: "Exact path type not parsable", + config: &proxyv1cfg.Config{ + Services: []*proxyv1cfg.Service{ + { + Name: "cat", + Host: "http://test.test", + AllowedRequests: []*proxyv1cfg.AllowRequest{ + {PathType: &proxyv1cfg.AllowRequest_Path{Path: "^meow"}}, + {PathType: &proxyv1cfg.AllowRequest_PathRegex{PathRegex: `/cat/\w+/[0-9]`}}, + }, + }, + }, + }, + shouldError: true, + }, + { + id: "Regex path type not parsable", + config: &proxyv1cfg.Config{ + Services: []*proxyv1cfg.Service{ + { + Name: "cat", + Host: "http://test.test", + AllowedRequests: []*proxyv1cfg.AllowRequest{ + {PathType: &proxyv1cfg.AllowRequest_Path{Path: "/meow"}}, + {PathType: &proxyv1cfg.AllowRequest_PathRegex{PathRegex: `?:\/\/)?`}}, + }, + }, + }, + }, + shouldError: true, + }, + } + + for _, test := range tests { + err := validateConfigPaths(test.config) + if test.shouldError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + } +} + func TestAddExcludedHeaders(t *testing.T) { tests := []struct { id string diff --git a/frontend/api/src/index.d.ts b/frontend/api/src/index.d.ts index 11d0bf508c..4965b01acc 100644 --- a/frontend/api/src/index.d.ts +++ b/frontend/api/src/index.d.ts @@ -8562,6 +8562,9 @@ export namespace clutch { /** AllowRequest path */ path?: (string|null); + /** AllowRequest pathRegex */ + pathRegex?: (string|null); + /** AllowRequest method */ method?: (string|null); } @@ -8576,11 +8579,17 @@ export namespace clutch { constructor(properties?: clutch.config.module.proxy.v1.IAllowRequest); /** AllowRequest path. */ - public path: string; + public path?: (string|null); + + /** AllowRequest pathRegex. */ + public pathRegex?: (string|null); /** AllowRequest method. */ public method: string; + /** AllowRequest pathType. */ + public pathType?: ("path"|"pathRegex"); + /** * Verifies an AllowRequest message. * @param message Plain object to verify diff --git a/frontend/api/src/index.js b/frontend/api/src/index.js index 7426554764..bc4886ed5a 100644 --- a/frontend/api/src/index.js +++ b/frontend/api/src/index.js @@ -20536,6 +20536,7 @@ export const clutch = $root.clutch = (() => { * @memberof clutch.config.module.proxy.v1 * @interface IAllowRequest * @property {string|null} [path] AllowRequest path + * @property {string|null} [pathRegex] AllowRequest pathRegex * @property {string|null} [method] AllowRequest method */ @@ -20556,11 +20557,19 @@ export const clutch = $root.clutch = (() => { /** * AllowRequest path. - * @member {string} path + * @member {string|null|undefined} path * @memberof clutch.config.module.proxy.v1.AllowRequest * @instance */ - AllowRequest.prototype.path = ""; + AllowRequest.prototype.path = null; + + /** + * AllowRequest pathRegex. + * @member {string|null|undefined} pathRegex + * @memberof clutch.config.module.proxy.v1.AllowRequest + * @instance + */ + AllowRequest.prototype.pathRegex = null; /** * AllowRequest method. @@ -20570,6 +20579,20 @@ export const clutch = $root.clutch = (() => { */ AllowRequest.prototype.method = ""; + // OneOf field names bound to virtual getters and setters + let $oneOfFields; + + /** + * AllowRequest pathType. + * @member {"path"|"pathRegex"|undefined} pathType + * @memberof clutch.config.module.proxy.v1.AllowRequest + * @instance + */ + Object.defineProperty(AllowRequest.prototype, "pathType", { + get: $util.oneOfGetter($oneOfFields = ["path", "pathRegex"]), + set: $util.oneOfSetter($oneOfFields) + }); + /** * Verifies an AllowRequest message. * @function verify @@ -20581,9 +20604,19 @@ export const clutch = $root.clutch = (() => { AllowRequest.verify = function verify(message) { if (typeof message !== "object" || message === null) return "object expected"; - if (message.path != null && message.hasOwnProperty("path")) + let properties = {}; + if (message.path != null && message.hasOwnProperty("path")) { + properties.pathType = 1; if (!$util.isString(message.path)) return "path: string expected"; + } + if (message.pathRegex != null && message.hasOwnProperty("pathRegex")) { + if (properties.pathType === 1) + return "pathType: multiple values"; + properties.pathType = 1; + if (!$util.isString(message.pathRegex)) + return "pathRegex: string expected"; + } if (message.method != null && message.hasOwnProperty("method")) if (!$util.isString(message.method)) return "method: string expected"; @@ -20604,6 +20637,8 @@ export const clutch = $root.clutch = (() => { let message = new $root.clutch.config.module.proxy.v1.AllowRequest(); if (object.path != null) message.path = String(object.path); + if (object.pathRegex != null) + message.pathRegex = String(object.pathRegex); if (object.method != null) message.method = String(object.method); return message; @@ -20622,14 +20657,20 @@ export const clutch = $root.clutch = (() => { if (!options) options = {}; let object = {}; - if (options.defaults) { - object.path = ""; + if (options.defaults) object.method = ""; - } - if (message.path != null && message.hasOwnProperty("path")) + if (message.path != null && message.hasOwnProperty("path")) { object.path = message.path; + if (options.oneofs) + object.pathType = "path"; + } if (message.method != null && message.hasOwnProperty("method")) object.method = message.method; + if (message.pathRegex != null && message.hasOwnProperty("pathRegex")) { + object.pathRegex = message.pathRegex; + if (options.oneofs) + object.pathType = "pathRegex"; + } return object; };