Skip to content

Commit 6cdc6db

Browse files
committed
common/ast: depth-validate ASTs from non-parser ingestion
ASTs loaded via ParsedExprToAst / CheckedExprToAst bypass the parser's recursion limit, so a deeply nested loaded AST could exhaust the Go stack during checking or planning and crash the process instead of returning a normal error. Add ast.ExceedsMaxDepth, a bounded traversal (default limit 250, the same as the parser's maxRecursionDepth) that Env.Check and Env.PlanProgram run before recursing, returning an ordinary error when the limit is exceeded. Includes a regression test that loads a synthetic over-deep AST. Closes #1333
1 parent 7ce3e73 commit 6cdc6db

3 files changed

Lines changed: 176 additions & 0 deletions

File tree

cel/env.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,16 @@ func (e *Env) Check(ast *Ast) (*Ast, *Issues) {
403403
return nil, NewIssuesWithSourceInfo(errs, ast.NativeRep().SourceInfo())
404404
}
405405

406+
// Guard against ASTs that bypass the parser depth limit (e.g. loaded via
407+
// ParsedExprToAst / CheckedExprToAst), since later recursive checking
408+
// would otherwise risk a Go stack overflow on adversarially deep inputs.
409+
if celast.ExceedsMaxDepth(ast.NativeRep().Expr(), celast.MaxNestingDepth) {
410+
errs := common.NewErrors(ast.Source())
411+
errs.ReportErrorString(common.NoLocation,
412+
fmt.Sprintf("input exceeds maximum expression nesting depth: %d", celast.MaxNestingDepth))
413+
return nil, NewIssuesWithSourceInfo(errs, ast.NativeRep().SourceInfo())
414+
}
415+
406416
checked, errs := checker.Check(ast.NativeRep(), ast.Source(), chk)
407417
if len(errs.GetErrors()) > 0 {
408418
return nil, NewIssuesWithSourceInfo(errs, ast.NativeRep().SourceInfo())
@@ -693,6 +703,13 @@ func (e *Env) Program(ast *Ast, opts ...ProgramOption) (Program, error) {
693703
// PlanProgram generates an evaluable instance of the AST in the go-native representation within
694704
// the environment (Env).
695705
func (e *Env) PlanProgram(a *celast.AST, opts ...ProgramOption) (Program, error) {
706+
// Guard against ASTs that bypass the parser depth limit (e.g. loaded via
707+
// ParsedExprToAst / CheckedExprToAst), since later recursive planning and
708+
// evaluation would otherwise risk a Go stack overflow on adversarially
709+
// deep inputs.
710+
if a != nil && celast.ExceedsMaxDepth(a.Expr(), celast.MaxNestingDepth) {
711+
return nil, fmt.Errorf("input exceeds maximum expression nesting depth: %d", celast.MaxNestingDepth)
712+
}
696713
optSet := e.progOpts
697714
if len(opts) != 0 {
698715
mergedOpts := []ProgramOption{}

cel/io_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,61 @@ func TestCheckedExprToAstMissingInfo(t *testing.T) {
316316
t.Fatalf("ast2.ResultType() got %v, wanted 'int'", ast.ResultType())
317317
}
318318
}
319+
320+
func TestLoadedAstDepthLimit(t *testing.T) {
321+
env, err := NewEnv()
322+
if err != nil {
323+
t.Fatalf("NewEnv() failed: %v", err)
324+
}
325+
326+
// Sanity check: a shallow parsed expression still checks and plans clean.
327+
shallow, iss := env.Parse("1 + 2")
328+
if iss.Err() != nil {
329+
t.Fatalf("Parse('1 + 2') failed: %v", iss.Err())
330+
}
331+
if _, iss := env.Check(shallow); iss.Err() != nil {
332+
t.Fatalf("Check(shallow) failed: %v", iss.Err())
333+
}
334+
if _, err := env.Program(shallow); err != nil {
335+
t.Fatalf("Program(shallow) failed: %v", err)
336+
}
337+
338+
// Build a synthetic deeply nested AST (depth ~300) using iteratively
339+
// stacked unary `!` calls, well above the 250 default but far below the
340+
// Go stack limit so the test itself never crashes.
341+
const depth = 300
342+
expr := &exprpb.Expr{
343+
Id: 1,
344+
ExprKind: &exprpb.Expr_ConstExpr{
345+
ConstExpr: &exprpb.Constant{
346+
ConstantKind: &exprpb.Constant_BoolValue{BoolValue: true},
347+
},
348+
},
349+
}
350+
for i := 0; i < depth; i++ {
351+
expr = &exprpb.Expr{
352+
Id: int64(i + 2),
353+
ExprKind: &exprpb.Expr_CallExpr{
354+
CallExpr: &exprpb.Expr_Call{
355+
Function: operators.LogicalNot,
356+
Args: []*exprpb.Expr{expr},
357+
},
358+
},
359+
}
360+
}
361+
deepAst := ParsedExprToAst(&exprpb.ParsedExpr{Expr: expr})
362+
363+
_, iss = env.Check(deepAst)
364+
if iss == nil || iss.Err() == nil {
365+
t.Fatalf("Check(deepAst) expected an error, got nil")
366+
}
367+
if !strings.Contains(iss.Err().Error(), "maximum expression nesting depth") {
368+
t.Errorf("Check(deepAst) error = %v, want it to mention 'maximum expression nesting depth'", iss.Err())
369+
}
370+
371+
if _, err := env.Program(deepAst); err == nil {
372+
t.Fatalf("Program(deepAst) expected an error, got nil")
373+
} else if !strings.Contains(err.Error(), "maximum expression nesting depth") {
374+
t.Errorf("Program(deepAst) error = %v, want it to mention 'maximum expression nesting depth'", err)
375+
}
376+
}

common/ast/depth.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package ast
16+
17+
// MaxNestingDepth is the default maximum expression nesting depth applied to
18+
// ASTs that enter through non-parser ingestion paths. It mirrors the parser's
19+
// default maxRecursionDepth so loaded ASTs are validated against the same
20+
// bound as ASTs produced from CEL source.
21+
const MaxNestingDepth = 250
22+
23+
// ExceedsMaxDepth reports whether the given expression nests deeper than
24+
// maxDepth. The traversal itself is bounded: it never recurses past
25+
// maxDepth+1 levels, so it is safe to call on adversarially deep inputs that
26+
// would otherwise blow the Go stack during later checking or planning.
27+
//
28+
// A non-positive maxDepth disables the check and always returns false.
29+
func ExceedsMaxDepth(e Expr, maxDepth int) bool {
30+
if maxDepth <= 0 {
31+
return false
32+
}
33+
return exceedsMaxDepth(e, 0, maxDepth)
34+
}
35+
36+
func exceedsMaxDepth(e Expr, depth, maxDepth int) bool {
37+
if e == nil {
38+
return false
39+
}
40+
if depth > maxDepth {
41+
return true
42+
}
43+
switch e.Kind() {
44+
case CallKind:
45+
c := e.AsCall()
46+
if c.IsMemberFunction() {
47+
if exceedsMaxDepth(c.Target(), depth+1, maxDepth) {
48+
return true
49+
}
50+
}
51+
for _, arg := range c.Args() {
52+
if exceedsMaxDepth(arg, depth+1, maxDepth) {
53+
return true
54+
}
55+
}
56+
case ComprehensionKind:
57+
c := e.AsComprehension()
58+
if exceedsMaxDepth(c.IterRange(), depth+1, maxDepth) {
59+
return true
60+
}
61+
if exceedsMaxDepth(c.AccuInit(), depth+1, maxDepth) {
62+
return true
63+
}
64+
if exceedsMaxDepth(c.LoopCondition(), depth+1, maxDepth) {
65+
return true
66+
}
67+
if exceedsMaxDepth(c.LoopStep(), depth+1, maxDepth) {
68+
return true
69+
}
70+
if exceedsMaxDepth(c.Result(), depth+1, maxDepth) {
71+
return true
72+
}
73+
case ListKind:
74+
for _, elem := range e.AsList().Elements() {
75+
if exceedsMaxDepth(elem, depth+1, maxDepth) {
76+
return true
77+
}
78+
}
79+
case MapKind:
80+
for _, entry := range e.AsMap().Entries() {
81+
me := entry.AsMapEntry()
82+
if exceedsMaxDepth(me.Key(), depth+1, maxDepth) {
83+
return true
84+
}
85+
if exceedsMaxDepth(me.Value(), depth+1, maxDepth) {
86+
return true
87+
}
88+
}
89+
case SelectKind:
90+
if exceedsMaxDepth(e.AsSelect().Operand(), depth+1, maxDepth) {
91+
return true
92+
}
93+
case StructKind:
94+
for _, f := range e.AsStruct().Fields() {
95+
if exceedsMaxDepth(f.AsStructField().Value(), depth+1, maxDepth) {
96+
return true
97+
}
98+
}
99+
}
100+
return false
101+
}

0 commit comments

Comments
 (0)