A Copilot-co-authored change meant to bound recursion in Scriban's array initializers reused a depth check that only logged an error, so deeply nested templates could still crash the host with an uncatchable stack overflow.
How AI contributed: Incomplete remediation·Published Jun 26, 2026 in scriban/scriban
Show full advisory descriptionHide advisory description
Summary
The ExpressionDepthLimit parser guard in Scriban does not actually stop parsing — it only logs a non-fatal error and lets recursive descent continue. As a result, a template containing a deeply nested expression (parentheses, array initializers, object initializers, or unary operators) drives the recursive-descent parser into a native stack overflow. The resulting StackOverflowException is uncatchable in .NET and immediately terminates the host process.
Any application that parses an attacker-influenced template — or that passes attacker-controlled strings to object.eval / object.eval_template — can be crashed by a single small request (roughly an 8 KB payload). This is a denial-of-service. It affects both Scriban-native (Template.Parse) and Liquid (Template.ParseLiquid) syntax modes, which share the same expression parser.
This re-opens two advisories that were reported as fixed: GHSA-wgh7-7m3c-fx25 ("Uncontrolled recursion in parser → StackOverflow", reported fixed in 6.6.0) and GHSA-p6q4-fgr8-vx4p ("StackOverflow via nested array initializers bypasses ExpressionDepthLimit", reported fixed in 7.0.0). Both fixes are incomplete: the limit they rely on never halts recursion. All releases 6.6.0 through 7.2.0 (current) are affected.
Details
The depth guard is EnterExpression() in src/Scriban/Parsing/Parser.Expressions.cs:
When the limit is exceeded it calls LogError(...) and sets a flag. It does not throw, does not return a sentinel, and does not unwind the parse. LogError here uses the default isFatal: false, so it merely appends a message and sets HasErrors — parsing proceeds:
The flag _isExpressionDepthLimitReached is consulted only to avoid logging the same error more than once — no code path uses it to stop descending. Confirmed by full-repo search (grep -rn "_isExpressionDepthLimitReached" src/): it appears in exactly four places — the field declaration (Parser.cs:40), a reset to false (Parser.cs:106), and within EnterExpression the dedup test (Parser.Expressions.cs:1213) and its assignment to true (:1216). The only *read* is the dedup test on line 1213; nothing else reads it. ParseExpression calls EnterExpression() and then continues straight into the token switch with no flag check:
Both Template.Parse (Scriban-native) and Template.ParseLiquid (Liquid-compatibility) front-ends share this same expression parser, so both entry points are affected.
So for input nested N levels deep, the parser recurses N levels deep regardless of ExpressionDepthLimit. There is no RuntimeHelpers.EnsureSufficientExecutionStack() call and no absolute recursion cap anywhere in the parser. Once the native thread stack is exhausted, the runtime raises StackOverflowException, which .NET does not allow to be caught and which tears down the entire process. The number of nesting levels required to overflow depends on the platform's thread-stack size (empirically around 4,000 levels on a default 1 MB stack); it is not a configurable mitigation.
The same defective guard is what makes the array-initializer fix for GHSA-p6q4-fgr8-vx4p ineffective: ParseArrayInitializer was wrapped in EnterExpression()/LeaveExpression(), but because EnterExpression() only logs, the array path still overflows.
The existing regression tests only assert HasErrors == true at a nesting depth of ~20 with a limit of 10 (src/Scriban.Tests/TestParser.cs); they never use a depth large enough to overflow the stack, so they pass while the protection does nothing against the actual DoS.
Runtime reachability without template injection: object.eval / object.eval_template (src/Scriban/Functions/ObjectFunctions.cs:72-155) re-parse a string argument at render time using Template.Parse(...). An application whose own templates are fully trusted is still vulnerable if any user-controlled value flows into object.eval. The catch (Exception) inside Eval cannot intercept the StackOverflowException.
PoC
A single console project reproduces it on the released NuGet package.
poc.csproj:
Program.cs:
Run:
Observed output (process aborts; shell exit code 134 = SIGABRT):
Additional confirmations (same crash / exit 134), substituting the template body in Program.cs:
The explicit limit is ignored — still crashes:
Array initializers (the GHSA-p6q4 path):
Object initializers {x:{x:...{x:1}...}}:
Unary operators:
Liquid syntax mode (shares the same expression parser):
Runtime via object.eval, with a fully trusted outer template — verified end-to-end: the outer parse reports HasErrors == false, then Render() crashes the process and the surrounding try/catch never fires (the StackOverflowException is uncatchable):
Verified against clean NuGet installs of Scriban 6.6.0, 7.0.0, 7.1.0, and 7.2.0 (net8.0, .NET 9 runtime, Linux). A control template with depth 200 parses normally (HasErrors == false, no crash).
Impact
· Type: Denial of service via uncontrolled recursion (CWE-674) leading to an uncatchable StackOverflowException and full process termination.
· Severity: CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H = 7.5 (High) — the same vector and score as both prior advisories it re-opens (GHSA-wgh7-7m3c-fx25 and GHSA-p6q4-fgr8-vx4p, each scored 7.5 High with the identical AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H vector). The score reflects the library boundary, where no privileges are required to parse a template; the privilege actually needed in a given deployment depends on how that application exposes template input.
· Who is impacted: Any application that calls Template.Parse / Template.ParseLiquid (or Template.Render on an unparsed source) on template text that is wholly or partially attacker-controlled — the documented server-side template scenario — and any application that passes attacker-controlled strings to object.eval / object.eval_template, even when its own templates are trusted.
· Why the existing mitigation does not help: ExpressionDepthLimit (default 250) is advisory only; it records a parse error but does not stop recursion, so it cannot prevent the stack overflow. Because the exception is a StackOverflowException, callers cannot defend with try/catch either — the process is lost.
· Affected versions: 6.6.0 – 7.2.0 (all versions shipping the depth-limit guard). Versions before 6.6.0 are vulnerable to the original unbounded-recursion condition.
Suggested remediation: make the limit actually stop descent — e.g. throw a parse exception from EnterExpression() when the limit is exceeded (or log with isFatal: true and have the parse loop honor _hasFatalError by unwinding). As defense in depth, call RuntimeHelpers.EnsureSufficientExecutionStack() at the entry of ParseExpression (the same technique already used in object.to_json), and add a regression test at a depth that overflows without the fix (e.g. 100,000), asserting a graceful exception rather than only checking HasErrors at depth 20.
What actually happenedHide details
Scriban's parser has an expression depth limit, but the helper that enforces it only records a parse error and keeps descending, so a deeply nested template still recurses until the thread stack runs out; .NET then raises a StackOverflowException that cannot be caught and that terminates the host process. A human change turned the limit on by default in 6.6.0 with this log-only helper. A Copilot-co-authored change in 7.0.0 set out to bound the previously unlimited array-initializer route but reused the same helper, so the parser stayed vulnerable. A human fix in 7.2.1 makes parsing stop at the limit or when native stack headroom runs low.
--- a/src/Scriban.Tests/TestParser.cs+++ b/src/Scriban.Tests/TestParser.cs@@ -826,6 +826,31 @@ m Assert.Throws<ScriptRuntimeException>(() => template.Render(context)); }+ [Test]+ public void EnsureExpressionDepthLimitAppliesToNestedArrayInitializers()+ {+ var builder = new StringBuilder();+ for (var i = 0; i < 20; i++)
Show 29 hidden lines
+ {+ builder.Append('[');+ }++ builder.Append('0');++ for (var i = 0; i < 20; i++)+ {+ builder.Append(']');+ }++ var template = Template.Parse($"{{{{ {builder} }}}}", parserOptions: new ParserOptions+ {+ ExpressionDepthLimit = 10+ });++ Assert.True(template.HasErrors);+ StringAssert.Contains("The statement depth limit `10` was reached when parsing this statement", template.Messages[0].ToString());+ }+ [TestCase(@"ab{{end}}c")] // no blocks [TestCase(@"a{{if true}}b{{end}}{{end}}c")] // one-level block [TestCase(@"a{{if true}}{{for i in 0..1}}b{{end}}{{end}}{{end}}c")] // two-level block (nested)@@ -1227,4 +1252,4 @@ m } } }-}+}
AI added a depth-limit test for nested array initializers but the parser change only enters the depth guard without aborting recursion.
--- a/src/Scriban/Parsing/Parser.Expressions.cs+++ b/src/Scriban/Parsing/Parser.Expressions.cs@@ -771,67 +771,75 @@ namespace Scriban.Parsing private ScriptExpression ParseArrayInitializer() {- var scriptArray = Open<ScriptArrayInitializerExpression>();+ EnterExpression();+ try+ {+ var scriptArray = Open<ScriptArrayInitializerExpression>();- // Should happen before the NextToken to consume any EOL after- _allowNewLineLevel++;
Show 102 hidden lines
+ // Should happen before the NextToken to consume any EOL after+ _allowNewLineLevel++;- // Parse [- ExpectAndParseTokenTo(scriptArray.OpenBracketToken, TokenType.OpenBracket);+ // Parse [+ ExpectAndParseTokenTo(scriptArray.OpenBracketToken, TokenType.OpenBracket);- bool expectingEndOfInitializer = false;-- // unit test: 120-array-initializer-accessor.txt- while (true)- {- if (Current.Type == TokenType.CloseBracket)- {- break;- }+ bool expectingEndOfInitializer = false;- if (!expectingEndOfInitializer)+ // unit test: 120-array-initializer-accessor.txt+ while (true) {- // unit test: 120-array-initializer-error2.txt- var expression = ExpectAndParseExpression(scriptArray);- if (expression == null)+ if (Current.Type == TokenType.CloseBracket) { break; }- scriptArray.Values.Add(expression);- if (Current.Type == TokenType.Comma)+ if (!expectingEndOfInitializer) {- // Record trailing Commas- if (_isKeepTrivia)+ // unit test: 120-array-initializer-error2.txt+ var expression = ExpectAndParseExpression(scriptArray);+ if (expression == null) {- PushTokenToTrivia();+ break; }+ scriptArray.Values.Add(expression);- NextToken();+ if (Current.Type == TokenType.Comma)+ {+ // Record trailing Commas+ if (_isKeepTrivia)+ {+ PushTokenToTrivia();+ }- if (_isKeepTrivia)+ NextToken();++ if (_isKeepTrivia)+ {+ FlushTriviasToLastTerminal();+ }+ }+ else {- FlushTriviasToLastTerminal();+ expectingEndOfInitializer = true; } } else {- expectingEndOfInitializer = true;+ break; } }- else- {- break;- }- }- // Should happen before NextToken() to stop on the next EOF- _allowNewLineLevel--;+ // Should happen before NextToken() to stop on the next EOF+ _allowNewLineLevel--;- // Parse ]- // unit test: 120-array-initializer-error1.txt- ExpectAndParseTokenTo(scriptArray.CloseBracketToken, TokenType.CloseBracket);- return Close(scriptArray);+ // Parse ]+ // unit test: 120-array-initializer-error1.txt+ ExpectAndParseTokenTo(scriptArray.CloseBracketToken, TokenType.CloseBracket);+ return Close(scriptArray);+ }+ finally+ {+ LeaveExpression();+ } } private ScriptExpression ParseObjectInitializer()
AI wrapped array-initializer parsing in EnterExpression/limit check but kept deep recursion going, so a stack overflow could still occur despite the recorded error.
--- a/src/Scriban.Tests/TestParser.cs+++ b/src/Scriban.Tests/TestParser.cs@@ -927,6 +927,55 @@ m StringAssert.Contains("The statement depth limit `10` was reached when parsing this statement", template.Messages[0].ToString());
Show 3 hidden lines
}+ [TestCase("parentheses")]
+ [TestCase("arrays")]+ [TestCase("objects")]+ [TestCase("unary")]+ public void ExpressionDepthLimitStopsDeepNestedExpressionsBeforeStackOverflow(string expressionKind)+ {+ var expression = CreateDeepExpression(expressionKind, 10000);+
Show 4 hidden lines
+ var template = Template.Parse($"{{{{ {expression} }}}}");++ Assert.True(template.HasErrors);+ StringAssert.Contains("The statement depth limit `250` was reached when parsing this statement", template.Messages[0].ToString());
+ }++ [Test]+ public void ExpressionDepthLimitStopsDeepLiquidExpressionsBeforeStackOverflow()+ {+ var expression = CreateDeepExpression("parentheses", 10000);+
Show 33 hidden lines
+ var template = Template.ParseLiquid($"{{{{ {expression} }}}}");++ Assert.True(template.HasErrors);+ StringAssert.Contains("The statement depth limit `250` was reached when parsing this statement", template.Messages[0].ToString());+ }++ private static string CreateDeepExpression(string expressionKind, int depth)+ {+ switch (expressionKind)+ {+ case "parentheses":+ return new string('(', depth) + "1" + new string(')', depth);+ case "arrays":+ return new string('[', depth) + "1" + new string(']', depth);+ case "objects":+ var objectBuilder = new StringBuilder();+ for (var i = 0; i < depth; i++)+ {+ objectBuilder.Append("{x:");+ }+ objectBuilder.Append('1');+ objectBuilder.Append('}', depth);+ return objectBuilder.ToString();+ case "unary":+ return new string('!', depth) + "true";+ default:+ throw new ArgumentOutOfRangeException(nameof(expressionKind), expressionKind, "Unsupported expression kind.");+ }+ }+ [TestCase(@"ab{{end}}c")] // no blocks [TestCase(@"a{{if true}}b{{end}}{{end}}c")] // one-level block [TestCase(@"a{{if true}}{{for i in 0..1}}b{{end}}{{end}}{{end}}c")] // two-level block (nested)
Fix tests assert that deep parentheses/arrays/objects/unary nesting stop with a depth-limit error instead of overflowing the stack.
--- a/src/Scriban/Parsing/Parser.Expressions.cs+++ b/src/Scriban/Parsing/Parser.Expressions.cs@@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Diagnostics;+using System.Runtime.CompilerServices; using Scriban.Functions; using Scriban.Runtime; using Scriban.Syntax;
Show 30 hidden lines
@@ -1208,12 +1209,26 @@ namespace Scriban.Parsing private void EnterExpression() {+ try+ {+ RuntimeHelpers.EnsureSufficientExecutionStack();+ }+ catch (InsufficientExecutionStackException)+ {+ LogError(GetSpanForToken(Previous), "The parser recursive depth limit was reached near a stack overflow", true);+ throw FatalParserException.Instance;+ }+ _expressionDepth++; var limit = Options.ExpressionDepthLimit;- if (limit > 0 && !_isExpressionDepthLimitReached && _expressionDepth > limit)+ if (limit > 0 && _expressionDepth > limit) {- LogError(GetSpanForToken(Previous), $"The statement depth limit `{limit}` was reached when parsing this statement");- _isExpressionDepthLimitReached = true;+ if (!_isExpressionDepthLimitReached)+ {+ LogError(GetSpanForToken(Previous), $"The statement depth limit `{limit}` was reached when parsing this statement", true);+ _isExpressionDepthLimitReached = true;+ }+ throw FatalParserException.Instance; } }
Fix uses EnsureSufficientExecutionStack and throws FatalParserException at the depth limit so recursion stops before StackOverflowException can crash the host.