Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions cel/cel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions cel/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions cel/program.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
139 changes: 105 additions & 34 deletions cel/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Loading