harden: hard-cap AST parser recursion depth like the evaluator (#840)

The AST evaluator (ast_eval_node) has a deliberate C-stack recursion cap
(AST_EVAL_MAX_DEPTH=400) to keep adversarial deep ASTs from overflowing the
stack, noting platforms with smaller stack defaults. The AST parser
(parseSequence/parseEvalBracket/parseBraceGroup/parseFunctionCall, mutually
recursive on []/{}/() nesting) had no such cap — bounded only by the LBUF input
length (~16380 max nesting) and small frames. On the 8 MiB main-thread stack
that is empirically safe (verified: 16380-deep brackets parse without crashing),
so this is defense-in-depth, not a reachable crash; but ~16380 frames is several
MiB and would overflow a smaller stack.

Mirror the evaluator's guard: a thread_local depth counter + RAII
AstParseDepthGuard at the top of parseSequence (every nesting level re-enters
there), capped at AST_PARSE_MAX_DEPTH=1000. Over the cap parseSequence returns
its empty node without recursing; the enclosing parseEvalBracket still consumed
its opening token so m_pos advances and parsing terminates. thread_local so it
also bounds the structural-arg re-parse. Real softcode nests a few levels.

muxscript: add(1,2)=3, [add(mul(2,3),4)]=10, [[[[5]]]]=5 unchanged; 16380-deep
survives. Smoke 1255/1255.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Stephen Dennis 2026-06-14 17:40:57 -06:00
parent 68d411be48
commit 57bc4066b3

View file

@ -164,6 +164,35 @@ static const std::string *ast_call_raw_arg(const ASTNode *call, int argIndex)
return nullptr;
}
// Hard cap on AST *parser* recursion depth, mirroring the evaluator's
// AST_EVAL_MAX_DEPTH (below). parseSequence/parseEvalBracket/parseBraceGroup/
// parseFunctionCall are mutually recursive on the nesting of [], {}, and () in
// the (LBUF-bounded) input. On a default 8 MiB stack the LBUF cap already keeps
// even maximally nested input ("[[[[...]]]]") from overflowing, but — like the
// evaluator's cap — this bounds adversarial deep nesting independently of stack
// size (the evaluator's comment notes platforms with smaller stack defaults).
// The counter is thread_local so it also bounds the nested re-parse of NOEVAL
// structural arguments (ast_parse_region in parser_apply_structural_arg_policy).
//
// Over the cap, parseSequence returns its (empty) node without recursing; the
// enclosing parseEvalBracket/etc. still consumed their opening token, so m_pos
// always advances and parsing terminates. Real softcode nests a few levels;
// 1000 is far beyond legitimate use and well under the evaluator's reach.
//
static constexpr int AST_PARSE_MAX_DEPTH = 1000;
static thread_local int s_ast_parse_depth = 0;
namespace
{
class AstParseDepthGuard
{
public:
AstParseDepthGuard() { ++s_ast_parse_depth; }
~AstParseDepthGuard() { --s_ast_parse_depth; }
bool overflow() const { return s_ast_parse_depth > AST_PARSE_MAX_DEPTH; }
};
}
class ASTParser {
public:
ASTParser(const std::vector<ASTToken> &tokens)
@ -275,7 +304,14 @@ private:
std::unique_ptr<ASTNode> parseSequence(
bool stopRP, bool stopRB, bool stopRC, bool stopCM)
{
// Bound parser recursion: every []/{}/() nesting level re-enters here.
//
AstParseDepthGuard depth_guard;
auto seq = std::make_unique<ASTNode>(AST_SEQUENCE);
if (depth_guard.overflow())
{
return seq;
}
while (!atEnd())
{
ASTTokenType t = peek().type;