Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
13 changes: 13 additions & 0 deletions cel/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,19 @@ func (e *Env) Program(ast *Ast, opts ...ProgramOption) (Program, error) {
// PlanProgram generates an evaluable instance of the AST in the go-native representation within
// the environment (Env).
func (e *Env) PlanProgram(a *celast.AST, opts ...ProgramOption) (Program, error) {
// Guard against ASTs that bypass the parser depth limit (e.g. loaded via
// ParsedExprToAst / CheckedExprToAst), since later recursive planning and
// evaluation would otherwise risk a Go stack overflow on adversarially
// deep inputs. The limit is configurable via ExpressionNestingDepthLimit;
// a zero value falls back to the parser-matching default and a negative
// value disables the check.
maxDepth := e.limits[limitMaxASTDepth]
if maxDepth == 0 {
maxDepth = defaultMaxASTDepth
}
if a != nil && celast.ExceedsMaxDepth(a.Expr(), maxDepth) {
return nil, fmt.Errorf("input exceeds maximum expression nesting depth: %d", maxDepth)
Comment thread
TristonianJones marked this conversation as resolved.
Outdated
}
optSet := e.progOpts
if len(opts) != 0 {
mergedOpts := []ProgramOption{}
Expand Down
62 changes: 62 additions & 0 deletions cel/io_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,65 @@ func TestCheckedExprToAstMissingInfo(t *testing.T) {
t.Fatalf("ast2.ResultType() got %v, wanted 'int'", ast.ResultType())
}
}

func TestLoadedAstDepthLimit(t *testing.T) {
env, err := NewEnv()
if err != nil {
t.Fatalf("NewEnv() failed: %v", err)
}

// Sanity check: a shallow parsed expression still checks and plans clean.
shallow, iss := env.Parse("1 + 2")
if iss.Err() != nil {
t.Fatalf("Parse('1 + 2') failed: %v", iss.Err())
}
if _, iss := env.Check(shallow); iss.Err() != nil {
t.Fatalf("Check(shallow) failed: %v", iss.Err())
}
if _, err := env.Program(shallow); err != nil {
t.Fatalf("Program(shallow) failed: %v", err)
}

// Build a synthetic deeply nested AST (depth ~300) using iteratively
// stacked unary `!` calls, well above the 250 default but far below the
// Go stack limit so the test itself never crashes.
const depth = 300
expr := &exprpb.Expr{
Id: 1,
ExprKind: &exprpb.Expr_ConstExpr{
ConstExpr: &exprpb.Constant{
ConstantKind: &exprpb.Constant_BoolValue{BoolValue: true},
},
},
}
for i := 0; i < depth; i++ {
expr = &exprpb.Expr{
Id: int64(i + 2),
ExprKind: &exprpb.Expr_CallExpr{
CallExpr: &exprpb.Expr_Call{
Function: operators.LogicalNot,
Args: []*exprpb.Expr{expr},
},
},
}
}
deepAst := ParsedExprToAst(&exprpb.ParsedExpr{Expr: expr})
Comment thread
TristonianJones marked this conversation as resolved.
Outdated

// Program enforces the default depth limit and returns a normal error
// rather than overflowing the stack during planning.
if _, err := env.Program(deepAst); err == nil {
t.Fatalf("Program(deepAst) expected an error, got nil")
} else if !strings.Contains(err.Error(), "maximum expression nesting depth") {
t.Errorf("Program(deepAst) error = %v, want it to mention 'maximum expression nesting depth'", err)
}

// The limit is configurable via the ExpressionNestingDepthLimit option: a
// negative value disables the check so the same deep AST plans cleanly.
unbounded, err := NewEnv(ExpressionNestingDepthLimit(-1))
if err != nil {
t.Fatalf("NewEnv(ExpressionNestingDepthLimit(-1)) failed: %v", err)
}
if _, err := unbounded.Program(deepAst); err != nil {
t.Errorf("Program(deepAst) with depth checking disabled failed: %v", err)
}
}
17 changes: 17 additions & 0 deletions cel/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,15 @@ const (
limitCodePointSize
// The number of attempts to recover from a parse error.
limitParseErrorRecovery
// The maximum nesting depth permitted for ASTs loaded outside the parser.
limitMaxASTDepth
)

// defaultMaxASTDepth mirrors the parser's default maxRecursionDepth (250) and
// is applied to ASTs that enter through non-parser ingestion paths (e.g. via
// ParsedExprToAst / CheckedExprToAst) when no explicit limit is configured.
const defaultMaxASTDepth = 250

var limitIDsToNames = map[limitID]string{
limitCodePointSize: "cel.limit.expression_code_points",
limitParseErrorRecovery: "cel.limit.parse_error_recovery",
Expand Down Expand Up @@ -942,6 +949,16 @@ func ParserExpressionSizeLimit(limit int) EnvOption {
return setLimit(limitCodePointSize, limit)
}

// ExpressionNestingDepthLimit bounds the nesting depth permitted for ASTs that
// are loaded outside the parser (e.g. via ParsedExprToAst / CheckedExprToAst),
// which would otherwise bypass the parser's recursion limit. The limit is
// enforced when a Program is planned, returning a normal error rather than
// risking a Go stack overflow on adversarially deep inputs. It defaults to the
// parser's recursion limit (250); a negative value disables the check.
func ExpressionNestingDepthLimit(limit int) EnvOption {
return setLimit(limitMaxASTDepth, limit)
}

// 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
95 changes: 95 additions & 0 deletions common/ast/depth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// 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 ast

// ExceedsMaxDepth reports whether the given expression nests deeper than
// maxDepth. The traversal itself is bounded: it never recurses past
// maxDepth+1 levels, so it is safe to call on adversarially deep inputs that
// would otherwise blow the Go stack during later checking or planning.
//
// A non-positive maxDepth disables the check and always returns false.
func ExceedsMaxDepth(e Expr, maxDepth int) bool {
Comment thread
TristonianJones marked this conversation as resolved.
Outdated
if maxDepth <= 0 {
return false
}
return exceedsMaxDepth(e, 0, maxDepth)
}

func exceedsMaxDepth(e Expr, depth, maxDepth int) bool {
if e == nil {
return false
}
if depth > maxDepth {
return true
}
switch e.Kind() {
case CallKind:
c := e.AsCall()
if c.IsMemberFunction() {
if exceedsMaxDepth(c.Target(), depth+1, maxDepth) {
return true
}
}
for _, arg := range c.Args() {
if exceedsMaxDepth(arg, depth+1, maxDepth) {
return true
}
}
case ComprehensionKind:
c := e.AsComprehension()
if exceedsMaxDepth(c.IterRange(), depth+1, maxDepth) {
return true
}
if exceedsMaxDepth(c.AccuInit(), depth+1, maxDepth) {
return true
}
if exceedsMaxDepth(c.LoopCondition(), depth+1, maxDepth) {
return true
}
if exceedsMaxDepth(c.LoopStep(), depth+1, maxDepth) {
return true
}
if exceedsMaxDepth(c.Result(), depth+1, maxDepth) {
return true
}
case ListKind:
for _, elem := range e.AsList().Elements() {
if exceedsMaxDepth(elem, depth+1, maxDepth) {
return true
}
}
case MapKind:
for _, entry := range e.AsMap().Entries() {
me := entry.AsMapEntry()
if exceedsMaxDepth(me.Key(), depth+1, maxDepth) {
return true
}
if exceedsMaxDepth(me.Value(), depth+1, maxDepth) {
return true
}
}
case SelectKind:
if exceedsMaxDepth(e.AsSelect().Operand(), depth+1, maxDepth) {
return true
}
case StructKind:
for _, f := range e.AsStruct().Fields() {
if exceedsMaxDepth(f.AsStructField().Value(), depth+1, maxDepth) {
return true
}
}
}
return false
}