Add depth validation for ASTs loaded through ParsedExprToAst / Checke...#1334
Add depth validation for ASTs loaded through ParsedExprToAst / Checke...#1334wilyan09007 wants to merge 4 commits into
Conversation
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 google#1333
| // 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 |
There was a problem hiding this comment.
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?
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.
daf8cf6 to
662a019
Compare
| // 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 { |
There was a problem hiding this comment.
Prefer instead to use a visitor that observes the expression depth to the navigable.go file.
Existing methods:
cel-go/common/ast/navigable.go
Lines 174 to 183 in f456a6e
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
}| maxDepth = defaultMaxASTDepth | ||
| } | ||
| if a != nil && celast.ExceedsMaxDepth(a.Expr(), maxDepth) { | ||
| return nil, fmt.Errorf("input exceeds maximum expression nesting depth: %d", maxDepth) |
There was a problem hiding this comment.
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.
| }, | ||
| } | ||
| } | ||
| deepAst := ParsedExprToAst(&exprpb.ParsedExpr{Expr: expr}) |
There was a problem hiding this comment.
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.
|
/gcbrun |
Summary
cel-goenforces a recursion/expression-depth limit while parsing CEL source, but ASTs ingested directly — throughParsedExprToAst/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 withfatal error: stack overflowinstead 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:
ast.ExceedsMaxDepth(expr, maxDepth)incommon/ast— a bounded traversal that never recurses pastmaxDepth+1levels, so it stays safe on the same adversarially deep inputs it guards against. The defaultast.MaxNestingDepth = 250mirrors the parser's defaultmaxRecursionDepth, keeping loaded ASTs consistent with parsed ones.Env.CheckandEnv.PlanProgramnow 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.CheckandProgramreturn an error (not a crash), while normal expressions remain unaffected.Closes #1333