Stack Depth Limit
The template engine caps the nesting depth of block tags and partials at a fixed maximum. This cap is a deliberate security control, not just a sanity check. Without it, a malicious template — or a well-intentioned template author who accidentally builds a recursion cycle — could exhaust the .NET runtime stack and crash the host process.
The limit
public const int TemplateEngine.MaxStack = 64;The engine spawns a nested sub-engine for each of the following constructs:
{#if}blocks{#each}iterations{#with}blocks{>partial}invocations
When the cumulative nesting depth reaches MaxStack, the next nested invocation throws.
How it surfaces
A stack-depth violation surfaces as a TemplateStackDepthException (extends InvalidOperationException) wrapped inside a MergeException. Inspect InnerException to distinguish a depth violation from other template errors:
try
{
string output = ngin.Merge(data);
}
catch (MergeException mex) when (mex.InnerException is TemplateStackDepthException sdex)
{
// The template exceeded MaxStack — reject it.
LogSuspiciousTemplate(templateId, sdex);
return ErrorResponse.TemplateTooDeep;
}
catch (MergeException mex)
{
// Normal template/data error path.
}Security model
The cap defends against two vectors:
- Recursive partials — a partial’s body is bound from data, making recursion trivially expressible. Without a cap, an unbounded recursion terminates the process via
StackOverflowException, which since .NET 2.0 is unrecoverable and uncatchable.MaxStackconverts this into a catchableTemplateStackDepthException. - Deep static nesting — a hand-crafted template with hundreds of nested blocks can exhaust the stack without recursion. User-supplied or externally loaded templates are the primary source.
What counts toward the budget
Each of the four nesting constructs contributes one level per invocation:
| Construct | Contributes |
|---|---|
{#if … /if} block entry | 1 level |
{#each … /each} block entry | 1 level (regardless of item count) |
{#with … /with} block entry | 1 level |
{>partial} invocation | 1 level |
A loop with 1000 items inside an {#if} does not blow the budget — the engine creates a single sub-engine for the {#each} block and reuses it across iterations. Each iteration is a sibling, not a child, in stack terms. The budget only grows along the depth axis: nested blocks, nested partials, or partials that invoke partials.
Simple tags, variable tags ({?var:}, {?:}, {:}), comments, and debug tags do not contribute. They run within the current engine frame.
Practical implications
64 levels is far beyond what any legitimate template needs. For applications accepting templates from untrusted or external sources:
- Validate template length and complexity before passing to the engine
- Pre-flight templates statically — measure nesting depth and reject templates exceeding a stricter application-level limit
- Log and rate-limit catches of
TemplateStackDepthException
Why a const
MaxStack is a public const — readable by callers for validation but not raisable at runtime. A fixed value is auditable; a configurable one can be bypassed via misconfiguration.
Compound bind depth
Dotted bind expressions ({A.B.C}) resolve via recursion. A separate cap applies:
// Maximum depth for compound bind expressions (e.g. {A.B.C.D…})
const int maxDepth = 16;Exceeding 16 levels throws RecursionStackDepthException (extends InvalidOperationException), wrapped in a MergeException. Check InnerException to identify it.
In practice, legitimate expressions rarely exceed 3–4 levels. The cap exists to bound recursion in the reflection resolver against adversarial input.
Next steps
- Partials — the primary attack vector this cap defends against
- Exception Handling — the broader exception model and how to identify depth violations via
InnerException