Skip to content

Add depth validation for ASTs loaded through ParsedExprToAst / Checke...#1334

Open
wilyan09007 wants to merge 4 commits into
cel-expr:masterfrom
wilyan09007:fix/issue-1333
Open

Add depth validation for ASTs loaded through ParsedExprToAst / Checke...#1334
wilyan09007 wants to merge 4 commits into
cel-expr:masterfrom
wilyan09007:fix/issue-1333

Conversation

@wilyan09007

Copy link
Copy Markdown

Summary

cel-go enforces a recursion/expression-depth limit while parsing CEL source, but ASTs ingested directly — through ParsedExprToAst / CheckedExprToAst — bypass that guard. A deeply nested loaded AST then flows into the recursive checker (Env.Check) and planner (Env.Program / PlanProgram), which can exhaust the Go stack and abort the process with fatal error: stack overflow instead of returning a recoverable error. Embedders that load stored or serialized CEL ASTs as policy objects are the most exposed.

This validates expression nesting depth before those recursive phases:

  • Adds ast.ExceedsMaxDepth(expr, maxDepth) in common/ast — a bounded traversal that never recurses past maxDepth+1 levels, so it stays safe on the same adversarially deep inputs it guards against. The default ast.MaxNestingDepth = 250 mirrors the parser's default maxRecursionDepth, keeping loaded ASTs consistent with parsed ones.
  • Env.Check and Env.PlanProgram now run that check up front and return an ordinary error/issue — input exceeds maximum expression nesting depth: 250 — rather than recursing into a stack overflow.
  • Adds a regression test that loads a synthetic over-deep AST and asserts Check and Program return an error (not a crash), while normal expressions remain unaffected.

Closes #1333

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 cel-expr#1333
Comment thread common/ast/depth.go Outdated
// ASTs that enter through non-parser ingestion paths. It mirrors the parser's
// default maxRecursionDepth so loaded ASTs are validated against the same
// bound as ASTs produced from CEL source.
const MaxNestingDepth = 250

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's preferred to create functional options with sensible defaults for user-configurable behavior. CEL doesn't currently validate the AST size on load since the parser should be the only entry point for a user-authored AST. Defense in depth is great, but could you please hoist this out of a publicly mutable variable and add it to the cel.NewEnv as a functional option that gets checked during Program calls?

wilyan09007 added a commit to wilyan09007/cel-go that referenced this pull request Jun 5, 2026
The depth bound for ASTs ingested outside the parser (via ParsedExprToAst / CheckedExprToAst) was an exported package constant, common/ast.MaxNestingDepth. Replace it with a cel.ExpressionNestingDepthLimit functional option on NewEnv so the behavior is user-configurable with a sensible default rather than a public package-level knob.

The option defaults to 250, matching the parser's maxRecursionDepth, and a negative value disables the check. Enforce it once during Program planning (PlanProgram) and drop the separate Env.Check guard, leaving a single configurable enforcement point.
Comment thread cel/options.go
Comment thread common/ast/depth.go Outdated
// 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer instead to use a visitor that observes the expression depth to the navigable.go file.

Existing methods:

// PostOrderVisit walks the expression graph and calls the visitor in post-order (bottom-up).
func PostOrderVisit(expr Expr, visitor Visitor) {
visit(expr, visitor, postOrder, 0, 0)
}
// PreOrderVisit walks the expression graph and calls the visitor in pre-order (top-down).
func PreOrderVisit(expr Expr, visitor Visitor) {
visit(expr, visitor, preOrder, 0, 0)
}

Proposed impl:

// ExceedsDepth determines whether the AST contains expressions nested beyond a specified maxDepth.
func ExceedsDepth(a *AST, depth int) bool {
  exceedsDepth := false
  visitor := NewExprVisitor(func(e Expr) {
      nav := e.(NavigableExpr)
      if nav.Depth() >= depth {
        exceedsDepth = true
      }
  })
  root := NavigateAST(a)
  PostOrderVisit(root, visitor)
  return exceedsDepth
}

Comment thread cel/env.go Outdated
maxDepth = defaultMaxASTDepth
}
if a != nil && celast.ExceedsMaxDepth(a.Expr(), maxDepth) {
return nil, fmt.Errorf("input exceeds maximum expression nesting depth: %d", maxDepth)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe if the maxDepth is 0 just skip the check. The other alternative is to shift this check to the io.go file instead which converts from the proto representation to the AST. In fact, let's maybe do that instead since a hand-rolled golang AST isn't possible without full control of the code.

Comment thread cel/io_test.go Outdated
},
}
}
deepAst := ParsedExprToAst(&exprpb.ParsedExpr{Expr: expr})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's instead add the depth check in the ParsedExprToAst and CheckedExprToAst since the program construction path is the hot path and the defense in depth check should be something users can disable if it doesn't apply since it's added compute without added security in a number of cases.

Address review feedback on the loaded-AST depth guard:

- common/ast: replace the bespoke depth.go traversal with ExceedsDepth in
  navigable.go, built on the existing NavigableExpr.Depth() visitor. The walk
  is bounded to maxDepth+1 levels so it stays safe on the adversarially deep
  inputs it guards against.
- cel: move the depth check out of the PlanProgram hot path and into the
  ParsedExprToAst / CheckedExprToAst proto-conversion helpers, the actual
  entry points for ASTs that bypass the parser. The expensive traversal now
  runs once at load; Check/Program only read the recorded error. Embedders in
  full control of their AST inputs can skip the check by constructing the AST
  through the common/ast package directly.
- cel: register limitMaxASTDepth in limitIDsToNames so the depth limit
  round-trips through env.Config export/import.

Updates the regression test to cover the parsed and checked conversion paths,
Check surfacing, the low-level bypass, and the env-config round-trip.
@TristonianJones

Copy link
Copy Markdown
Collaborator

/gcbrun

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add depth validation for ASTs loaded through ParsedExprToAst / CheckedExprToAst

2 participants