diff --git a/cel/cel_test.go b/cel/cel_test.go index becb5beb..afd676e9 100644 --- a/cel/cel_test.go +++ b/cel/cel_test.go @@ -2504,6 +2504,96 @@ func TestRegexOptimizer(t *testing.T) { } } +func TestRegexProgramSizeLimit(t *testing.T) { + env, err := NewEnv( + Variable("pattern", StringType), + RegexProgramSizeLimit(5), + ) + if err != nil { + t.Fatalf("NewEnv failed: %v", err) + } + + tests := []struct { + name string + expr string + progOpts []ProgramOption + vars any + want ref.Val + compileErr string + progErr string + evalErr string + }{ + { + name: "constant_regex_exceeds_limit_ast_validation", + expr: `"123 abc 456".matches('(a|b)*[0-9]+')`, + compileErr: "regex program size 8 exceeds limit of 5", + }, + { + name: "dynamic_regex_exceeds_limit_runtime", + expr: `"123 abc 456".matches(pattern)`, + vars: map[string]any{"pattern": "(a|b)*[0-9]+"}, + evalErr: "regex program size 8 exceeds limit of 5", + }, + { + name: "dynamic_regex_within_limit", + expr: `"123 abc 456".matches(pattern)`, + vars: map[string]any{"pattern": "[0-9]+"}, + want: types.True, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(tt *testing.T) { + ast, iss := env.Compile(tc.expr) + if tc.compileErr != "" { + if iss.Err() == nil { + tt.Fatalf("env.Compile(%s) succeeded, wanted error %s", tc.expr, tc.compileErr) + } + if !strings.Contains(iss.Err().Error(), tc.compileErr) { + tt.Errorf("got compile error %v, wanted error containing %s", iss.Err(), tc.compileErr) + } + return + } + if iss.Err() != nil { + tt.Fatalf("env.Compile(%s) failed: %v", tc.expr, iss.Err()) + } + prg, err := env.Program(ast, tc.progOpts...) + if tc.progErr != "" { + if err == nil { + tt.Fatalf("env.Program(%s) succeeded, wanted error %s", tc.expr, tc.progErr) + } + if !strings.Contains(err.Error(), tc.progErr) { + tt.Errorf("got program error %v, wanted error containing %s", err, tc.progErr) + } + return + } + if err != nil { + tt.Fatalf("env.Program(%s) failed: %v", tc.expr, err) + } + vars := tc.vars + if vars == nil { + vars = NoVars() + } + res, _, err := prg.Eval(vars) + if tc.evalErr != "" { + if err == nil { + tt.Fatalf("prg.Eval(%s) succeeded, wanted error %s", tc.expr, tc.evalErr) + } + if !strings.Contains(err.Error(), tc.evalErr) { + tt.Errorf("got eval error %v, wanted error containing %s", err, tc.evalErr) + } + return + } + if err != nil { + tt.Fatalf("prg.Eval(%s) failed: %v", tc.expr, err) + } + if res != tc.want { + tt.Errorf("got %v, wanted %v", res, tc.want) + } + }) + } +} + func TestDefaultUTCTimeZoneDisabled(t *testing.T) { testEnvs := []struct { name string diff --git a/cel/options.go b/cel/options.go index 5000048c..69ed95af 100644 --- a/cel/options.go +++ b/cel/options.go @@ -112,6 +112,8 @@ const ( limitParseErrorRecovery // The maximum nesting depth permitted for ASTs loaded outside the parser. limitMaxASTDepth + // The maximum regex program plan size permitted. + limitRegexProgramSize ) // defaultMaxASTDepth mirrors the parser's default maxRecursionDepth (250) and @@ -124,6 +126,7 @@ var limitIDsToNames = map[limitID]string{ limitParseErrorRecovery: "cel.limit.parse_error_recovery", limitParseRecursionDepth: "cel.limit.parse_recursion_depth", limitMaxASTDepth: "cel.limit.max_ast_depth", + limitRegexProgramSize: "cel.limit.regex_program_size", } func limitNameByID(id limitID) (string, bool) { @@ -1012,6 +1015,25 @@ func ExpressionNestingDepthLimit(limit int) EnvOption { return setLimit(limitMaxASTDepth, limit) } +// RegexProgramSizeLimit caps the maximum regex program plan size permitted for regular expressions. +// A negative or zero value means unbounded. +func RegexProgramSizeLimit(limit int) EnvOption { + return func(e *Env) (*Env, error) { + var err error + e, err = setLimit(limitRegexProgramSize, limit)(e) + if err != nil { + return nil, err + } + if limit > 0 { + e, err = ASTValidators(ValidateRegexProgramSizeLimit(limit))(e) + if err != nil { + return nil, err + } + } + return e, nil + } +} + // EnableHiddenAccumulatorName sets the parser to use the identifier '@result' for accumulators // which is not normally accessible from CEL source. func EnableHiddenAccumulatorName(enabled bool) EnvOption { diff --git a/cel/program.go b/cel/program.go index 3a7589a7..c97d9921 100644 --- a/cel/program.go +++ b/cel/program.go @@ -295,6 +295,9 @@ func newProgram(e *Env, a *ast.AST, opts []ProgramOption) (Program, error) { if len(p.regexOptimizations) > 0 { plannerOptions = append(plannerOptions, interpreter.CompileRegexConstants(p.regexOptimizations...)) } + if limit := p.limits[limitRegexProgramSize]; limit > 0 { + plannerOptions = append(plannerOptions, interpreter.RegexProgramSizeLimit(limit)) + } // Enable exhaustive eval, state tracking and cost tracking last since they require a factory. if p.evalOpts&(OptExhaustiveEval|OptTrackState|OptTrackCost) != 0 { diff --git a/cel/validator.go b/cel/validator.go index cb7f4c29..229defe7 100644 --- a/cel/validator.go +++ b/cel/validator.go @@ -23,15 +23,17 @@ import ( "github.com/google/cel-go/common/ast" "github.com/google/cel-go/common/env" "github.com/google/cel-go/common/overloads" + "github.com/google/cel-go/common/types" ) const ( - durationValidatorName = "cel.validator.duration" - regexValidatorName = "cel.validator.matches" - timestampValidatorName = "cel.validator.timestamp" - homogeneousValidatorName = "cel.validator.homogeneous_literals" - nestingLimitValidatorName = "cel.validator.comprehension_nesting_limit" - bindNestingLimitValidatorName = "cel.validator.bind_nesting_limit" + durationValidatorName = "cel.validator.duration" + regexValidatorName = "cel.validator.matches" + timestampValidatorName = "cel.validator.timestamp" + homogeneousValidatorName = "cel.validator.homogeneous_literals" + nestingLimitValidatorName = "cel.validator.comprehension_nesting_limit" + bindNestingLimitValidatorName = "cel.validator.bind_nesting_limit" + regexProgramSizeLimitValidatorName = "cel.validator.regex_program_size_limit" // HomogeneousAggregateLiteralExemptFunctions is the ValidatorConfig key used to configure // the set of function names which are exempt from homogeneous type checks. The expected type @@ -46,38 +48,25 @@ const ( var ( astValidatorFactories = map[string]ASTValidatorFactory{ nestingLimitValidatorName: func(val *env.Validator) (ASTValidator, error) { - if limit, found := val.ConfigValue("limit"); found { - // In case of protos, config value is of type by google.protobuf.Value, which numeric values are always a double. - if val, isDouble := limit.(float64); isDouble { - if val != float64(int64(val)) { - return nil, fmt.Errorf("invalid validator: %s, limit value is not a whole number: %v", nestingLimitValidatorName, limit) - } - return ValidateComprehensionNestingLimit(int(val)), nil - } - - if val, isInt := limit.(int); isInt { - return ValidateComprehensionNestingLimit(val), nil - } - return nil, fmt.Errorf("invalid validator: %s unsupported limit type: %v", nestingLimitValidatorName, limit) + limit, err := validatorIntConfig(val, "limit") + if err != nil { + return nil, err } - return nil, fmt.Errorf("invalid validator: %s missing limit", nestingLimitValidatorName) + return ValidateComprehensionNestingLimit(limit), nil }, bindNestingLimitValidatorName: func(val *env.Validator) (ASTValidator, error) { - if limit, found := val.ConfigValue("limit"); found { - // In case of protos, config value is of type by google.protobuf.Value, which numeric values are always a double. - if val, isDouble := limit.(float64); isDouble { - if val != float64(int64(val)) { - return nil, fmt.Errorf("invalid validator: %s, limit value is not a whole number: %v", bindNestingLimitValidatorName, limit) - } - return ValidateBindNestingLimit(int(val)), nil - } - - if val, isInt := limit.(int); isInt { - return ValidateBindNestingLimit(val), nil - } - return nil, fmt.Errorf("invalid validator: %s unsupported limit type: %v", bindNestingLimitValidatorName, limit) + limit, err := validatorIntConfig(val, "limit") + if err != nil { + return nil, err } - return nil, fmt.Errorf("invalid validator: %s missing limit", bindNestingLimitValidatorName) + return ValidateBindNestingLimit(limit), nil + }, + regexProgramSizeLimitValidatorName: func(val *env.Validator) (ASTValidator, error) { + limit, err := validatorIntConfig(val, "limit") + if err != nil { + return nil, err + } + return ValidateRegexProgramSizeLimit(limit), nil }, durationValidatorName: func(*env.Validator) (ASTValidator, error) { return ValidateDurationLiterals(), nil @@ -266,6 +255,11 @@ func ValidateBindNestingLimit(limit int) ASTValidator { return bindNestingLimitValidator{limit: limit} } +// ValidateRegexProgramSizeLimit ensures that regex pattern literals do not exceed the specified regex program size limit. +func ValidateRegexProgramSizeLimit(limit int) ASTValidator { + return regexProgramSizeLimitValidator{limit: limit} +} + type argChecker func(env *Env, call, arg ast.Expr) error func newFormatValidator(funcName string, argNum int, check argChecker) formatValidator { @@ -495,6 +489,20 @@ func (v bindNestingLimitValidator) ToConfig() *env.Validator { return env.NewValidator(v.Name()).SetConfig(map[string]any{"limit": v.limit}) } +type regexProgramSizeLimitValidator struct { + limit int +} + +// Name returns the name of the regex program size limit validator. +func (v regexProgramSizeLimitValidator) Name() string { + return regexProgramSizeLimitValidatorName +} + +// ToConfig converts the ASTValidator to an env.Validator specifying the validator name and the limit. +func (v regexProgramSizeLimitValidator) ToConfig() *env.Validator { + return env.NewValidator(v.Name()).SetConfig(map[string]any{"limit": v.limit}) +} + // Validate implements the ASTValidator interface method. func (v bindNestingLimitValidator) Validate(e *Env, _ ValidatorConfig, a *ast.AST, iss *Issues) { root := ast.NavigateAST(a) @@ -525,6 +533,47 @@ func (v bindNestingLimitValidator) Validate(e *Env, _ ValidatorConfig, a *ast.AS } } +func (v regexProgramSizeLimitValidator) Validate(e *Env, _ ValidatorConfig, a *ast.AST, iss *Issues) { + if v.limit <= 0 { + return + } + root := ast.NavigateAST(a) + callExprs := ast.MatchDescendants(root, ast.KindMatcher(ast.CallKind)) + for _, call := range callExprs { + c := call.AsCall() + fn := c.FunctionName() + if !isRegexFunctionName(fn) { + continue + } + args := c.Args() + var regexArgIndex int + if (fn == overloads.Matches || fn == "matches") && c.Target() != nil { + regexArgIndex = 0 + } else { + regexArgIndex = 1 + } + if len(args) <= regexArgIndex { + continue + } + arg := args[regexArgIndex] + if arg.Kind() != ast.LiteralKind { + continue + } + pattern, ok := arg.AsLiteral().Value().(string) + if !ok { + continue + } + sz, err := types.RegexProgramSize(pattern) + if err != nil { + // Invalid regex literals are handled in a different validator. + continue + } + if sz > v.limit { + iss.ReportErrorAtID(arg.ID(), "regex program size %d exceeds limit of %d", sz, v.limit) + } + } +} + func isEmptyRangeComprehension(e ast.NavigableExpr) bool { if e.Kind() != ast.ComprehensionKind { return false @@ -544,3 +593,25 @@ func isCelBind(e ast.NavigableExpr) bool { loopCond.Kind() == ast.LiteralKind && loopCond.AsLiteral().Value() == false && loopStep.Kind() == ast.IdentKind && loopStep.AsIdent() == compre.AccuVar() } + +func isRegexFunctionName(fn string) bool { + return fn == overloads.Matches || fn == "matches" || fn == "regex.extract" || fn == "regex.extractAll" || fn == "regex.replace" +} + +func validatorIntConfig(val *env.Validator, configKey string) (int, error) { + if limit, found := val.ConfigValue(configKey); found { + // In case of protos, config value is of type google.protobuf.Value, which numeric values are always a double. + if v, isDouble := limit.(float64); isDouble { + if v != float64(int64(v)) { + return 0, fmt.Errorf("invalid validator: %s, %s value is not a whole number: %v", val.Name, configKey, limit) + } + return int(v), nil + } + + if v, isInt := limit.(int); isInt { + return v, nil + } + return 0, fmt.Errorf("invalid validator: %s unsupported %s type: %v", val.Name, configKey, limit) + } + return 0, fmt.Errorf("invalid validator: %s missing %s", val.Name, configKey) +} diff --git a/cel/validator_test.go b/cel/validator_test.go index 606a5b6d..99795da0 100644 --- a/cel/validator_test.go +++ b/cel/validator_test.go @@ -204,6 +204,77 @@ func TestValidateRegexLiterals(t *testing.T) { } } +func TestValidateRegexProgramSizeLimit(t *testing.T) { + opts := []EnvOption{ + Variable("x", types.StringType), + ASTValidators(ValidateRegexProgramSizeLimit(5)), + } + + tests := []struct { + expr string + iss string + }{ + { + expr: `'hello'.matches('el*')`, + }, + { + expr: `'hello'.matches('(a|b)*[0-9]+')`, + iss: ` + ERROR: :1:17: regex program size 8 exceeds limit of 5 + | 'hello'.matches('(a|b)*[0-9]+') + | ................^`, + }, + { + expr: `'hello'.matches(x)`, + }, + } + for _, tst := range tests { + tc := tst + t.Run(tc.expr, func(t *testing.T) { + _, err := Compile(tc.expr, opts...) + if tc.iss != "" { + if err == nil { + t.Fatalf("Compile(%v) returned ast, expected error: %v", tc.expr, tc.iss) + } + if !test.Compare(err.Error(), tc.iss) { + t.Fatalf("Compile(%v) returned %v, expected error: %v", tc.expr, err, tc.iss) + } + return + } + if err != nil { + t.Fatalf("Compile(%v) failed: %v", tc.expr, err) + } + }) + } +} + +func TestValidateRegexProgramSizeLimitToConfig(t *testing.T) { + val := ValidateRegexProgramSizeLimit(5) + cfg := val.(ConfigurableASTValidator).ToConfig() + if cfg.Name != regexProgramSizeLimitValidatorName { + t.Errorf("ToConfig().Name = %s, wanted %s", cfg.Name, regexProgramSizeLimitValidatorName) + } + if limit, ok := cfg.ConfigValue("limit"); !ok || limit != 5 { + t.Errorf("ToConfig().ConfigValue('limit') = %v, wanted 5", limit) + } +} + +func TestValidateRegexProgramSizeLimitFactory(t *testing.T) { + val := ValidateRegexProgramSizeLimit(5) + cfg := val.(ConfigurableASTValidator).ToConfig() + fac, ok := astValidatorFactories[regexProgramSizeLimitValidatorName] + if !ok { + t.Fatalf("missing factory for %s", regexProgramSizeLimitValidatorName) + } + vFromCfg, err := fac(cfg) + if err != nil { + t.Fatalf("fac(cfg) failed: %v", err) + } + if vFromCfg.Name() != regexProgramSizeLimitValidatorName { + t.Errorf("vFromCfg.Name() = %s, wanted %s", vFromCfg.Name(), regexProgramSizeLimitValidatorName) + } +} + func TestValidateHomogeneousAggregateLiterals(t *testing.T) { env, err := NewCustomEnv( Variable("name", StringType), diff --git a/common/types/BUILD.bazel b/common/types/BUILD.bazel index 37d4df49..7dda3ede 100644 --- a/common/types/BUILD.bazel +++ b/common/types/BUILD.bazel @@ -26,6 +26,7 @@ go_library( "optional.go", "overflow.go", "provider.go", + "regex.go", "string.go", "timestamp.go", "types.go", @@ -71,6 +72,7 @@ go_test( "object_test.go", "optional_test.go", "provider_test.go", + "regex_test.go", "string_test.go", "timestamp_test.go", "types_test.go", diff --git a/common/types/regex.go b/common/types/regex.go new file mode 100644 index 00000000..14173eb5 --- /dev/null +++ b/common/types/regex.go @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "fmt" + "regexp" + "regexp/syntax" +) + +// RegexProgramSize calculates the instruction count (program plan size) of a regex pattern. +func RegexProgramSize(pattern string) (int, error) { + re, err := syntax.Parse(pattern, syntax.Perl) + if err != nil { + return 0, err + } + prog, err := syntax.Compile(re) + if err != nil { + return 0, err + } + return len(prog.Inst), nil +} + +// CompileRegexWithLimit compiles a regex pattern and verifies that its program plan size does not exceed limit. +// A limit <= 0 means unbounded. +func CompileRegexWithLimit(pattern string, limit int) (*regexp.Regexp, error) { + if limit > 0 { + sz, err := RegexProgramSize(pattern) + if err != nil { + return nil, err + } + if sz > limit { + return nil, fmt.Errorf("regex program size %d exceeds limit of %d", sz, limit) + } + } + return regexp.Compile(pattern) +} diff --git a/common/types/regex_test.go b/common/types/regex_test.go new file mode 100644 index 00000000..033a4397 --- /dev/null +++ b/common/types/regex_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "testing" +) + +func TestRegexProgramSize(t *testing.T) { + tests := []struct { + pattern string + minSize int + hasError bool + }{ + {pattern: "a", minSize: 1}, + {pattern: "el*", minSize: 3}, + {pattern: "(a|b)*[0-9]+", minSize: 5}, + {pattern: "(", hasError: true}, + } + + for _, tc := range tests { + sz, err := RegexProgramSize(tc.pattern) + if tc.hasError { + if err == nil { + t.Errorf("RegexProgramSize(%q) expected error, got nil", tc.pattern) + } + continue + } + if err != nil { + t.Errorf("RegexProgramSize(%q) unexpected error: %v", tc.pattern, err) + continue + } + if sz < tc.minSize { + t.Errorf("RegexProgramSize(%q) = %d, expected >= %d", tc.pattern, sz, tc.minSize) + } + } +} + +func TestCompileRegexWithLimit(t *testing.T) { + tests := []struct { + pattern string + limit int + hasError bool + }{ + {pattern: "el*", limit: 10}, + {pattern: "el*", limit: 0}, + {pattern: "el*", limit: -1}, + {pattern: "(a|b)*[0-9]+", limit: 5, hasError: true}, + {pattern: "(", limit: 10, hasError: true}, + } + + for _, tc := range tests { + _, err := CompileRegexWithLimit(tc.pattern, tc.limit) + if tc.hasError { + if err == nil { + t.Errorf("CompileRegexWithLimit(%q, %d) expected error, got nil", tc.pattern, tc.limit) + } + } else { + if err != nil { + t.Errorf("CompileRegexWithLimit(%q, %d) unexpected error: %v", tc.pattern, tc.limit, err) + } + } + } +} diff --git a/ext/regex_test.go b/ext/regex_test.go index 24c5582e..9a61b794 100644 --- a/ext/regex_test.go +++ b/ext/regex_test.go @@ -415,3 +415,77 @@ func TestRegexCosts(t *testing.T) { }) } } + +func TestRegexProgramSizeLimit(t *testing.T) { + overloads := []struct { + name string + expr string + }{ + { + name: "matches", + expr: `'a1'.matches(pat)`, + }, + { + name: "regex.extract", + expr: `regex.extract('a1', pat)`, + }, + { + name: "regex.extractAll", + expr: `regex.extractAll('a1', pat)`, + }, + { + name: "regex.replace 3-arg", + expr: `regex.replace('a1', pat, 'x')`, + }, + { + name: "regex.replace 4-arg", + expr: `regex.replace('a1', pat, 'x', 1)`, + }, + } + + t.Run("ExceedsLimit", func(t *testing.T) { + for _, tc := range overloads { + t.Run(tc.name, func(t *testing.T) { + prg, err := cel.Compile(tc.expr, + cel.OptionalTypes(), + Regex(), + cel.RegexProgramSizeLimit(5), + cel.Variable("pat", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.Compile(%s) failed: %v", tc.expr, err) + } + _, _, err = prg.Eval(map[string]any{"pat": "(a|b)*[0-9]+"}) + if err == nil { + t.Fatalf("expected runtime error for regex program size exceeding limit") + } + if !strings.Contains(err.Error(), "regex program size 8 exceeds limit of 5") { + t.Fatalf("got error %v, expected error containing 'regex program size 8 exceeds limit of 5'", err) + } + }) + } + }) + + t.Run("WithinLimit", func(t *testing.T) { + for _, tc := range overloads { + t.Run(tc.name, func(t *testing.T) { + prg, err := cel.Compile(tc.expr, + cel.OptionalTypes(), + Regex(), + cel.RegexProgramSizeLimit(10), + cel.Variable("pat", cel.StringType), + ) + if err != nil { + t.Fatalf("cel.Compile(%s) failed: %v", tc.expr, err) + } + val, _, err := prg.Eval(map[string]any{"pat": "(a|b)*[0-9]+"}) + if err != nil { + t.Fatalf("prg.Eval(%s) unexpected error: %v", tc.expr, err) + } + if val == nil { + t.Fatalf("prg.Eval(%s) returned nil result", tc.expr) + } + }) + } + }) +} diff --git a/interpreter/decorators.go b/interpreter/decorators.go index 9c973664..6c48e5c1 100644 --- a/interpreter/decorators.go +++ b/interpreter/decorators.go @@ -15,6 +15,8 @@ package interpreter import ( + "fmt" + "github.com/google/cel-go/common/overloads" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" @@ -169,6 +171,77 @@ func decRegexOptimizer(regexOptimizations ...*RegexOptimization) InterpretableDe } } +func decRegexProgramSizeLimit(limit int) InterpretableDecoratorV2 { + return func(i InterpretableV2) (InterpretableV2, error) { + if limit <= 0 { + return i, nil + } + call, ok := i.(InterpretableCall) + if !ok { + return i, nil + } + if !isRegexFunction(call.Function(), call.OverloadID()) || len(call.Args()) < 2 { + return i, nil + } + regexArg := call.Args()[1] + if constVal, isConst := regexArg.(InterpretableConst); isConst { + if pattern, ok := constVal.Value().(types.String); ok { + sz, err := types.RegexProgramSize(string(pattern)) + if err != nil { + return i, nil + } + if sz > limit { + return nil, fmt.Errorf("regex program size %d exceeds limit of %d", sz, limit) + } + } + return i, nil + } + return ®exLimitCall{InterpretableCall: call, limit: limit}, nil + } +} + +func isRegexFunction(fn, overload string) bool { + switch fn { + case overloads.Matches, "regex.extract", "regex.extractAll", "regex.replace": + return true + } + switch overload { + case overloads.Matches, overloads.MatchesString, + "regex_extract_string_string", "regex_extractAll_string_string", + "regex_replace_string_string_string", "regex_replace_string_string_string_int": + return true + } + return false +} + +type regexLimitCall struct { + InterpretableCall + limit int +} + +func (r *regexLimitCall) Exec(frame *ExecutionFrame) ref.Val { + args := r.Args() + if len(args) >= 2 { + patternVal := args[1].Exec(frame) + if types.IsError(patternVal) { + return patternVal + } + if types.IsUnknown(patternVal) { + return patternVal + } + if pat, ok := patternVal.(types.String); ok { + sz, err := types.RegexProgramSize(string(pat)) + if err != nil { + return types.WrapErr(err) + } + if sz > r.limit { + return types.WrapErr(fmt.Errorf("regex program size %d exceeds limit of %d", sz, r.limit)) + } + } + } + return r.InterpretableCall.Exec(frame) +} + func maybeOptimizeConstUnary(i InterpretableV2, call InterpretableCall) (InterpretableV2, error) { args := call.Args() if len(args) != 1 { diff --git a/interpreter/interpreter.go b/interpreter/interpreter.go index ef13ab92..29df9d41 100644 --- a/interpreter/interpreter.go +++ b/interpreter/interpreter.go @@ -227,6 +227,11 @@ func CompileRegexConstants(regexOptimizations ...*RegexOptimization) PlannerOpti return CustomDecoratorV2(decRegexOptimizer(regexOptimizations...)) } +// RegexProgramSizeLimit caps the maximum regex program plan size permitted during evaluation. +func RegexProgramSizeLimit(limit int) PlannerOption { + return CustomDecoratorV2(decRegexProgramSizeLimit(limit)) +} + type exprInterpreter struct { dispatcher Dispatcher container *containers.Container diff --git a/interpreter/interpreter_test.go b/interpreter/interpreter_test.go index bcb0f575..b4b090ed 100644 --- a/interpreter/interpreter_test.go +++ b/interpreter/interpreter_test.go @@ -2113,6 +2113,72 @@ func TestInterpreter_InterruptableEval(t *testing.T) { } } +func TestInterpreter_RegexProgramSizeLimit(t *testing.T) { + tcConst := testCase{ + expr: `'hello'.matches('(a|b)*[0-9]+')`, + } + _, _, err := program(t, &tcConst, RegexProgramSizeLimit(5)) + if err == nil { + t.Fatalf("expected program creation error for constant regex exceeding limit") + } + if !strings.Contains(err.Error(), "regex program size 8 exceeds limit of 5") { + t.Errorf("got error %v, wanted error containing 'regex program size 8 exceeds limit of 5'", err) + } + + tcDyn := testCase{ + expr: `'hello'.matches(pattern)`, + vars: []*decls.VariableDecl{ + decls.NewVariable("pattern", types.StringType), + }, + in: map[string]any{ + "pattern": "(a|b)*[0-9]+", + }, + } + prg, frame, err := program(t, &tcDyn, RegexProgramSizeLimit(5)) + if err != nil { + t.Fatalf("program() failed: %v", err) + } + out := prg.Exec(frame) + frame.Close() + if !types.IsError(out) || !strings.Contains(out.(*types.Err).String(), "regex program size 8 exceeds limit of 5") { + t.Errorf("got %v, wanted regex program size limit error", out) + } + + tcValid := testCase{ + expr: `'hello'.matches(pattern)`, + vars: []*decls.VariableDecl{ + decls.NewVariable("pattern", types.StringType), + }, + in: map[string]any{ + "pattern": "el*", + }, + out: true, + } + prgValid, frameValid, err := program(t, &tcValid, RegexProgramSizeLimit(5)) + if err != nil { + t.Fatalf("program() failed: %v", err) + } + outValid := prgValid.Exec(frameValid) + frameValid.Close() + if outValid != types.True { + t.Errorf("got %v, wanted true", outValid) + } + + // Non-regex function should not be modified by RegexProgramSizeLimit decorator + tcOther := testCase{ + expr: `'hello'.contains('e')`, + } + prgOther, frameOther, err := program(t, &tcOther, RegexProgramSizeLimit(5)) + if err != nil { + t.Fatalf("program() failed: %v", err) + } + outOther := prgOther.Exec(frameOther) + frameOther.Close() + if outOther != types.True { + t.Errorf("got %v, wanted true", outOther) + } +} + func TestInterpreter_ExhaustiveLogicalOrEquals(t *testing.T) { // a || b == "b" // Operator "==" is at Expr 4, should be evaluated though "a" is true