From 205ca6a7c2349d3d388bd5f1f7729ee198c0d5e5 Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Sun, 24 May 2026 19:41:50 +0200 Subject: [PATCH 01/11] Limit array multiplication growth --- site/docs/runtime/safe-runtime.md | 2 +- src/Scriban.Tests/TestRuntime.cs | 28 ++++++++++++++++++++++++++++ src/Scriban/Runtime/ScriptArray.cs | 21 ++++++++++++++++++++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/site/docs/runtime/safe-runtime.md b/site/docs/runtime/safe-runtime.md index 8c3467d6..500bf775 100644 --- a/site/docs/runtime/safe-runtime.md +++ b/site/docs/runtime/safe-runtime.md @@ -67,7 +67,7 @@ The following table lists the main `TemplateContext` properties that influence r | Property | Default | Used for | |----------|---------|----------| | `StrictVariables` | `false` | Throws a `ScriptRuntimeException` when a variable lookup fails instead of returning `null`. This only affects unresolved variables; relaxed member/indexer settings still control member access behavior on resolved objects. | -| `LoopLimit` | `1000` | Caps loop iterations and also limits range expressions such as `1..100000`. Set to `0` to disable this limit. | +| `LoopLimit` | `1000` | Caps loop iterations and also limits internal iteration/allocation work such as range expressions (`1..100000`) and array multiplication. Set to `0` to disable this limit. | | `LoopLimitQueryable` | `null` | Optional separate loop limit for `IQueryable` enumerations. When `null`, Scriban uses `LoopLimit`. Set to `0` to disable the `IQueryable`-specific limit. | | `RecursiveLimit` | `100` | Caps recursive function calls. Set to `0` to disable recursion-depth checks. | | `LimitToString` | `1048576` | Caps string materialization and rendered output growth. Scriban truncates output with `...` when the limit is reached, and some builtins throw if an operation would create a string larger than this limit. Set to `0` to disable the limit. | diff --git a/src/Scriban.Tests/TestRuntime.cs b/src/Scriban.Tests/TestRuntime.cs index 87a711fc..76e02447 100644 --- a/src/Scriban.Tests/TestRuntime.cs +++ b/src/Scriban.Tests/TestRuntime.cs @@ -356,6 +356,34 @@ public void ArrayInsertAtShouldRespectLoopLimitForInternalIteration() StringAssert.Contains("iteration limit `10`", exception!.Message); } + [Test] + public void ArrayMultiplyShouldRespectLoopLimitForInternalIteration() + { + var context = new TemplateContext + { + LoopLimit = 10 + }; + var template = Template.Parse("{{ ([1, 2, 3, 4, 5] * 50000000) | array.size }}"); + + var exception = Assert.Throws(() => template.Render(context)); + + StringAssert.Contains("iteration limit `10`", exception!.Message); + } + + [Test] + public void ArrayMultiplyShouldRejectOverflowingResultLength() + { + var context = new TemplateContext + { + LoopLimit = 0 + }; + var template = Template.Parse("{{ [1, 2, 3, 4, 5] * 429496730 }}"); + + var exception = Assert.Throws(() => template.Render(context)); + + StringAssert.Contains("maximum supported array length", exception!.Message); + } + [Test] public void InternalArrayEnumerationShouldCheckCancellation() { diff --git a/src/Scriban/Runtime/ScriptArray.cs b/src/Scriban/Runtime/ScriptArray.cs index 3a46d80b..a09e5c62 100644 --- a/src/Scriban/Runtime/ScriptArray.cs +++ b/src/Scriban/Runtime/ScriptArray.cs @@ -501,9 +501,28 @@ public bool TryEvaluate(TemplateContext context, SourceSpan span, ScriptBinaryOp return true; } - var newArray = new ScriptArray(intModifier * array.Count); + if (array.Count == 0) + { + result = new ScriptArray(); + return true; + } + + var resultLength = (long)intModifier * array.Count; + if (resultLength > int.MaxValue) + { + throw new ScriptRuntimeException(span, "Array multiplication result length exceeds the maximum supported array length."); + } + + if (context.LoopLimit > 0 && resultLength > context.LoopLimit) + { + throw new ScriptRuntimeException(span, $"Exceeding number of iteration limit `{context.LoopLimit}` for internal iteration."); + } + + var newArray = new ScriptArray((int)resultLength); + var loopStep = 0; for (int i = 0; i < intModifier; i++) { + context.StepLoop(span, ref loopStep); newArray.AddRange(array); } From 8fdbd687bbe8f00085c4c4c5b2b3b8d529933949 Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Sun, 24 May 2026 19:43:31 +0200 Subject: [PATCH 02/11] Stop parsing at expression depth limit --- site/docs/runtime/ast.md | 2 +- src/Scriban.Tests/TestParser.cs | 49 ++++++++++ src/Scriban/Parsing/Parser.Expressions.cs | 21 ++++- src/Scriban/Parsing/Parser.cs | 105 +++++++++++++--------- 4 files changed, 129 insertions(+), 48 deletions(-) diff --git a/site/docs/runtime/ast.md b/site/docs/runtime/ast.md index 4c8cfac3..93e7e23e 100644 --- a/site/docs/runtime/ast.md +++ b/site/docs/runtime/ast.md @@ -9,7 +9,7 @@ title: "Lexer, Parser and AST" The lexer has a few [`LexerOptions`](https://github.com/lunet-io/scriban/blob/master/src/Scriban/Parsing/LexerOptions.cs) to control the way the lexer is behaving, as described with the [parsing modes](parsing#parsing-modes) -The parser has a [`ParserOptions`](https://github.com/lunet-io/scriban/blob/master/src/Scriban/Parsing/ParserOptions.cs) only used for securing nested statements/blocks to avoid any stack overflow exceptions while parsing a document. +The parser has [`ParserOptions`](https://github.com/lunet-io/scriban/blob/master/src/Scriban/Parsing/ParserOptions.cs) used for securing nested statements/blocks and expressions to avoid stack overflow exceptions while parsing a document. `ExpressionDepthLimit` defaults to `250`; when the limit is reached, parsing stops and the returned template contains a parser error. ## Abstract Syntax Tree diff --git a/src/Scriban.Tests/TestParser.cs b/src/Scriban.Tests/TestParser.cs index f0c28198..eaba9e33 100644 --- a/src/Scriban.Tests/TestParser.cs +++ b/src/Scriban.Tests/TestParser.cs @@ -927,6 +927,55 @@ public void EnsureExpressionDepthLimitAppliesToNestedUnaryExpressions() StringAssert.Contains("The statement depth limit `10` was reached when parsing this statement", template.Messages[0].ToString()); } + [TestCase("parentheses")] + [TestCase("arrays")] + [TestCase("objects")] + [TestCase("unary")] + public void ExpressionDepthLimitStopsDeepNestedExpressionsBeforeStackOverflow(string expressionKind) + { + var expression = CreateDeepExpression(expressionKind, 10000); + + 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); + + 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) diff --git a/src/Scriban/Parsing/Parser.Expressions.cs b/src/Scriban/Parsing/Parser.Expressions.cs index b842c238..7e4a1bda 100644 --- 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; @@ -1208,12 +1209,26 @@ private void TransformLiquidFunctionCallToScriban(ScriptFunctionCall functionCal 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; } } diff --git a/src/Scriban/Parsing/Parser.cs b/src/Scriban/Parsing/Parser.cs index 37af38e0..87c3ff2b 100644 --- a/src/Scriban/Parsing/Parser.cs +++ b/src/Scriban/Parsing/Parser.cs @@ -104,65 +104,73 @@ public Parser(Lexer lexer, ParserOptions? options = null) HasErrors = false; _blockLevel = 0; _isExpressionDepthLimitReached = false; + _hasFatalError = false; + _expressionDepth = 0; Blocks.Clear(); var page = Open(); - var parsingMode = CurrentParsingMode; - switch (parsingMode) + try { - case ScriptMode.FrontMatterAndContent: - case ScriptMode.FrontMatterOnly: - if (Current.Type != TokenType.FrontMatterMarker) - { - LogError($"When `{CurrentParsingMode}` is enabled, expecting a `{_lexer.Options.FrontMatterMarker}` at the beginning of the text instead of `{Current.GetText(_lexer.Text)}`"); - return null; - } + var parsingMode = CurrentParsingMode; + switch (parsingMode) + { + case ScriptMode.FrontMatterAndContent: + case ScriptMode.FrontMatterOnly: + if (Current.Type != TokenType.FrontMatterMarker) + { + LogError($"When `{CurrentParsingMode}` is enabled, expecting a `{_lexer.Options.FrontMatterMarker}` at the beginning of the text instead of `{Current.GetText(_lexer.Text)}`"); + return null; + } - _inFrontMatter = true; - _inCodeSection = true; + _inFrontMatter = true; + _inCodeSection = true; - _frontmatter = Open(); + _frontmatter = Open(); - // Parse the frontmatter start=-marker - ExpectAndParseTokenTo(_frontmatter.StartMarker, TokenType.FrontMatterMarker); + // Parse the frontmatter start=-marker + ExpectAndParseTokenTo(_frontmatter.StartMarker, TokenType.FrontMatterMarker); - // Parse front-marker statements - _frontmatter.Statements = ParseBlockStatement(_frontmatter); + // Parse front-marker statements + _frontmatter.Statements = ParseBlockStatement(_frontmatter); - // We should not be in a frontmatter after parsing the statements - if (_inFrontMatter) - { - LogError($"End of frontmatter `{_lexer.Options.FrontMatterMarker}` not found"); - } + // We should not be in a frontmatter after parsing the statements + if (_inFrontMatter) + { + LogError($"End of frontmatter `{_lexer.Options.FrontMatterMarker}` not found"); + } - page.FrontMatter = _frontmatter; - page.Span = _frontmatter.Span; + page.FrontMatter = _frontmatter; + page.Span = _frontmatter.Span; - if (parsingMode == ScriptMode.FrontMatterOnly) - { - return page; - } - break; - case ScriptMode.ScriptOnly: - _inCodeSection = true; - break; - } + if (parsingMode == ScriptMode.FrontMatterOnly) + { + return page; + } + break; + case ScriptMode.ScriptOnly: + _inCodeSection = true; + break; + } - page.Body = ParseBlockStatement(page); - if (page.Body is not null) - { - page.Span = page.Body.Span; - } + page.Body = ParseBlockStatement(page); + if (page.Body is not null) + { + page.Span = page.Body.Span; + } - // Flush any pending trivias - if (_isKeepTrivia && _lastTerminalWithTrivias is not null) - { - FlushTriviasToLastTerminal(); - } + // Flush any pending trivias + if (_isKeepTrivia && _lastTerminalWithTrivias is not null) + { + FlushTriviasToLastTerminal(); + } - if (page.FrontMatter is not null) + if (page.FrontMatter is not null) + { + FixRawStatementAfterFrontMatter(page); + } + } + catch (FatalParserException) { - FixRawStatementAfterFrontMatter(page); } if (_lexer.HasErrors) @@ -176,6 +184,15 @@ public Parser(Lexer lexer, ParserOptions? options = null) return page; } + private sealed class FatalParserException : Exception + { + public static readonly FatalParserException Instance = new FatalParserException(); + + private FatalParserException() + { + } + } + private void PushTokenToTrivia() { if (_isKeepTrivia) From d45f7f03c3407729da7fe663c180ce780adb9329 Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Sun, 24 May 2026 19:55:43 +0200 Subject: [PATCH 03/11] Document member filters as reflection-only --- site/docs/runtime/member-renamer.md | 14 +++++++++----- site/docs/runtime/scriptobject.md | 4 ++-- src/Scriban/Runtime/ScriptObjectExtensions.cs | 12 ++++++------ src/Scriban/ScribanAsync.generated.cs | 12 ++++++------ src/Scriban/Template.cs | 12 ++++++------ src/Scriban/TemplateContext.cs | 6 ++++-- 6 files changed, 33 insertions(+), 27 deletions(-) diff --git a/site/docs/runtime/member-renamer.md b/site/docs/runtime/member-renamer.md index a29158d7..a5417a22 100644 --- a/site/docs/runtime/member-renamer.md +++ b/site/docs/runtime/member-renamer.md @@ -4,7 +4,7 @@ title: "Member renamer and filter" ## Member renamer -By default, .NET objects accessed through a `ScriptObject` are automatically exposed with lowercase and `_` names. It means that a property like `MyMethodIsNice` will be exposed as `my_method_is_nice`. This is the default convention, originally to match the behavior of `liquid` templates. +By default, reflection-backed .NET objects accessed through a `ScriptObject` are automatically exposed with lowercase and `_` names. It means that a property like `MyMethodIsNice` will be exposed as `my_method_is_nice`. This is the default convention, originally to match the behavior of `liquid` templates. A renamer is simply a delegate that takes an input MemberInfo and return a new member name: @@ -45,7 +45,9 @@ Note that renaming can be changed at two levels: var context = new TemplateContext {MemberRenamer = member => member.Name}; ``` - It is important to setup this on the `TemplateContext` for any .NET objects that might be accessed indirectly through another `ScriptObject` so that when a .NET object is exposed, it is exposed with the correct naming convention. + It is important to setup this on the `TemplateContext` for any reflection-backed .NET objects that might be accessed indirectly through another `ScriptObject` so that when a .NET object is exposed, it is exposed with the correct naming convention. + +Member renamers apply to reflected .NET members. They do not rename dictionary keys or `ScriptObject` entries; those keys are template data and are exposed with their existing names. The method `Template.Render(object, renamer)` takes also a member renamer, imports the object model with the renamer and setup correctly the renamer on the underlying `TemplateContext`. @@ -59,7 +61,7 @@ template.Render(new MyObject(), member => member.Name); ## Member filter -Similar to the member renamer, by default, .NET objects accessed through a `ScriptObject` are automatically exposing all public instance fields and properties of .NET objects. +Similar to the member renamer, by default, reflection-backed .NET objects accessed through a `ScriptObject` are automatically exposing all public instance fields and properties of .NET objects. A filter is simply a delegate that takes an input MemberInfo and return a boolean to indicate whether to expose the member (`true`) or discard the member (`false`) @@ -84,11 +86,13 @@ namespace Scriban.Runtime // Imports only properties that contains the word "Yo" scriptObject1.Import(new MyObject(), filter: member => member is PropertyInfo && member.Name.Contains("Yo")); ``` -- By setting the default member filter on the `TemplateContext`, so that .NET objects automatically exposed via a `ScriptObject` will follow the global filtering rules defined on the context: +- By setting the default member filter on the `TemplateContext`, so that reflection-backed .NET objects automatically exposed via a `ScriptObject` will follow the global filtering rules defined on the context: ```csharp // Setup a default filter at the `TemplateContext` level var context = new TemplateContext {MemberFilter = member => member is PropertyInfo && member.Name.Contains("Yo") }; ``` -As for the member renamer, it is important to setup this on the `TemplateContext` for any .NET objects that might be accessed indirectly through another `ScriptObject` so that when a .NET object is exposed, it is exposed with the same filtering convention +Member filters apply to reflected .NET members only. They do not filter dictionary keys or `ScriptObject` entries because these are data entries, not `MemberInfo` reflection members. If a template should not see some dictionary data, project or sanitize that dictionary before exposing it to Scriban. + +As for the member renamer, it is important to setup this on the `TemplateContext` for any reflection-backed .NET objects that might be accessed indirectly through another `ScriptObject` so that when a .NET object is exposed, it is exposed with the same filtering convention. diff --git a/site/docs/runtime/scriptobject.md b/site/docs/runtime/scriptobject.md index 07b5211a..b40552cf 100644 --- a/site/docs/runtime/scriptobject.md +++ b/site/docs/runtime/scriptobject.md @@ -24,7 +24,7 @@ A `ScriptObject` is mainly an extended version of a `IDictionary Console.WriteLine(result); ``` -Note that any `IDictionary` put as a property will be accessible as well. +Note that any `IDictionary` put as a property will be accessible as well. Dictionary keys are treated as data and are not affected by `MemberRenamer` or `MemberFilter`. ## Imports System.Text.Json.JsonElement @@ -289,7 +289,7 @@ and import the properties/functions of this object into a ScriptObject, via `Scr ``` -Also any objects inheriting from `IDictionary` or `IDictionary` will be also accessible automatically. Typically, you can usually access directly any generic JSON objects that was parsed by a JSON library. +Also any objects inheriting from `IDictionary` or `IDictionary` will be also accessible automatically. Typically, you can usually access directly any generic JSON objects that was parsed by a JSON library. Dictionary keys are used as data keys and are not renamed or filtered by `MemberRenamer` or `MemberFilter`; project or sanitize dictionaries before exposing them if some keys should not be available to templates. > [!NOTE] > diff --git a/src/Scriban/Runtime/ScriptObjectExtensions.cs b/src/Scriban/Runtime/ScriptObjectExtensions.cs index 0211c661..a9187a3e 100644 --- a/src/Scriban/Runtime/ScriptObjectExtensions.cs +++ b/src/Scriban/Runtime/ScriptObjectExtensions.cs @@ -46,8 +46,8 @@ public static void AssertNotReadOnly(this IScriptObject scriptObject) /// /// The script object to import into /// The object. - /// Optional member filterer - /// Optional renamer + /// Optional reflected member filterer. This is not applied when importing dictionary data. + /// Optional reflected member renamer. This is not applied when importing dictionary data. /// ///
    ///
  • If is a , this method will import only the static field/properties of the specified object.
  • @@ -60,14 +60,14 @@ public static void Import(this IScriptObject script, object? obj, MemberFilterDe { if (obj is IScriptObject scriptObj) { - // TODO: Add support for filter, member renamer + // ScriptObject entries are data, not reflected members, so filter/renamer do not apply. script.Import(scriptObj); return; } if (obj is IDictionary dictionary) { - // TODO: Add support for filter, member renamer + // Dictionary entries are data, not reflected members, so filter/renamer do not apply. script.ImportDictionary(dictionary); return; } @@ -209,8 +209,8 @@ public static void ImportMember(this IScriptObject script, object obj, string me /// The script object to import into /// The object. /// The import flags. - /// A filter applied on each member - /// The member renamer. + /// A filter applied on each reflected member. + /// The reflected member renamer. /// [RequiresUnreferencedCode("This method uses reflection to discover and import fields, properties, and methods from the specified object or type.")] public static void Import(this IScriptObject script, object? obj, ScriptMemberImportFlags flags, MemberFilterDelegate? filter = null, MemberRenamerDelegate? renamer = null) diff --git a/src/Scriban/ScribanAsync.generated.cs b/src/Scriban/ScribanAsync.generated.cs index 5348db03..dda40aa6 100644 --- a/src/Scriban/ScribanAsync.generated.cs +++ b/src/Scriban/ScribanAsync.generated.cs @@ -124,8 +124,8 @@ partial class Template /// Evaluates the template using the specified context /// /// An object model to use with the evaluation. - /// The member renamer used to import this .NET object and transitive objects. See member renamer documentation for more details. - /// The member filter used to filter members for .NET objects being accessed through the template, including the model being passed to this method. + /// The member renamer used to import reflection-backed .NET object members and transitive reflection-backed object members. Dictionary keys and ScriptObject entries are data and are not renamed. See member renamer documentation for more details. + /// The member filter used to filter reflection-backed .NET object members accessed through the template, including reflected members of the model being passed to this method. Dictionary keys and ScriptObject entries are data and are not filtered. /// If the template . Check the property for more details /// Returns the result of the last statement [RequiresUnreferencedCode("This overload imports the model object using reflection. Use Evaluate(TemplateContext) for AOT-safe evaluation.")] @@ -153,8 +153,8 @@ partial class Template /// /// A code only expression (without enclosing `{{` and `}}`) /// An object instance used as a model for evaluating this expression - /// The member renamer used to import this .NET object and transitive objects. See member renamer documentation for more details. - /// The member filter used to filter members for .NET objects being accessed through the template, including the model being passed to this method. + /// The member renamer used to import reflection-backed .NET object members and transitive reflection-backed object members. Dictionary keys and ScriptObject entries are data and are not renamed. See member renamer documentation for more details. + /// The member filter used to filter reflection-backed .NET object members accessed through the template, including reflected members of the model being passed to this method. Dictionary keys and ScriptObject entries are data and are not filtered. /// The result of the evaluation of the expression [RequiresUnreferencedCode("This overload imports the model object using reflection. Use Evaluate(TemplateContext) for AOT-safe evaluation.")] public static async ValueTask EvaluateAsync(string expression, object model, MemberRenamerDelegate? memberRenamer = null, MemberFilterDelegate? memberFilter = null) @@ -190,8 +190,8 @@ public async ValueTask RenderAsync(TemplateContext context) /// Renders this template using the specified object model. /// /// The object model. - /// The member renamer used to import this .NET object and transitive objects. See member renamer documentation for more details. - /// The member filter used to filter members for .NET objects being accessed through the template, including the model being passed to this method. + /// The member renamer used to import reflection-backed .NET object members and transitive reflection-backed object members. Dictionary keys and ScriptObject entries are data and are not renamed. See member renamer documentation for more details. + /// The member filter used to filter reflection-backed .NET object members accessed through the template, including reflected members of the model being passed to this method. Dictionary keys and ScriptObject entries are data and are not filtered. /// A rendering result as a string [RequiresUnreferencedCode("This overload imports the model object using reflection. Use Render(TemplateContext) for AOT-safe rendering.")] public async ValueTask RenderAsync(object? model = null, MemberRenamerDelegate? memberRenamer = null, MemberFilterDelegate? memberFilter = null) diff --git a/src/Scriban/Template.cs b/src/Scriban/Template.cs index 2731d64e..e0c0d87f 100644 --- a/src/Scriban/Template.cs +++ b/src/Scriban/Template.cs @@ -110,8 +110,8 @@ public static Template ParseLiquid(string text, string? sourceFilePath = null, P /// /// A code only expression (without enclosing `{{` and `}}`) /// An object instance used as a model for evaluating this expression - /// The member renamer used to import this .NET object and transitive objects. See member renamer documentation for more details. - /// The member filter used to filter members for .NET objects being accessed through the template, including the model being passed to this method. + /// The member renamer used to import reflection-backed .NET object members and transitive reflection-backed object members. Dictionary keys and ScriptObject entries are data and are not renamed. See member renamer documentation for more details. + /// The member filter used to filter reflection-backed .NET object members accessed through the template, including reflected members of the model being passed to this method. Dictionary keys and ScriptObject entries are data and are not filtered. /// The result of the evaluation of the expression [RequiresUnreferencedCode("This overload imports the model object using reflection. Use Evaluate(TemplateContext) for AOT-safe evaluation.")] public static object? Evaluate(string expression, object model, MemberRenamerDelegate? memberRenamer = null, MemberFilterDelegate? memberFilter = null) @@ -148,8 +148,8 @@ public static Template ParseLiquid(string text, string? sourceFilePath = null, P /// Evaluates the template using the specified context /// /// An object model to use with the evaluation. - /// The member renamer used to import this .NET object and transitive objects. See member renamer documentation for more details. - /// The member filter used to filter members for .NET objects being accessed through the template, including the model being passed to this method. + /// The member renamer used to import reflection-backed .NET object members and transitive reflection-backed object members. Dictionary keys and ScriptObject entries are data and are not renamed. See member renamer documentation for more details. + /// The member filter used to filter reflection-backed .NET object members accessed through the template, including reflected members of the model being passed to this method. Dictionary keys and ScriptObject entries are data and are not filtered. /// If the template . Check the property for more details /// Returns the result of the last statement [RequiresUnreferencedCode("This overload imports the model object using reflection. Use Evaluate(TemplateContext) for AOT-safe evaluation.")] @@ -197,8 +197,8 @@ public string Render(TemplateContext context) /// Renders this template using the specified object model. /// /// The object model. - /// The member renamer used to import this .NET object and transitive objects. See member renamer documentation for more details. - /// The member filter used to filter members for .NET objects being accessed through the template, including the model being passed to this method. + /// The member renamer used to import reflection-backed .NET object members and transitive reflection-backed object members. Dictionary keys and ScriptObject entries are data and are not renamed. See member renamer documentation for more details. + /// The member filter used to filter reflection-backed .NET object members accessed through the template, including reflected members of the model being passed to this method. Dictionary keys and ScriptObject entries are data and are not filtered. /// A rendering result as a string [RequiresUnreferencedCode("This overload imports the model object using reflection. Use Render(TemplateContext) for AOT-safe rendering.")] public string Render(object? model = null, MemberRenamerDelegate? memberRenamer = null, MemberFilterDelegate? memberFilter = null) diff --git a/src/Scriban/TemplateContext.cs b/src/Scriban/TemplateContext.cs index 210430ce..163c9592 100644 --- a/src/Scriban/TemplateContext.cs +++ b/src/Scriban/TemplateContext.cs @@ -274,12 +274,14 @@ public bool IndentWithInclude public LexerOptions TemplateLoaderLexerOptions { get; set; } /// - /// A global settings used to rename property names of exposed .NET objects. + /// A global setting used to rename reflected field/property names of exposed .NET objects. + /// This does not rename dictionary keys or entries. /// public MemberRenamerDelegate MemberRenamer { get; set; } /// - /// A global settings used to filter field/property names of exposed .NET objects. + /// A global setting used to filter reflected field/property names of exposed .NET objects. + /// This does not filter dictionary keys or entries. /// public MemberFilterDelegate? MemberFilter { get; set; } From 390db7fff43b42b22ef5da45625a1ba913a2ce95 Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Thu, 28 May 2026 21:23:18 +0200 Subject: [PATCH 04/11] Clarify variable scope documentation --- site/docs/language.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/site/docs/language.md b/site/docs/language.md index 3b5508ec..ec405454 100644 --- a/site/docs/language.md +++ b/site/docs/language.md @@ -392,9 +392,11 @@ When resolving to a string output, the null value will output an empty string: ## 4 Variables -Scriban supports the concept of **global** and **local** variables +Scriban supports the concept of **global/property** and **local** variables. -A **global/property variable** like `{{ "{{" }} name {{ "}}" }}` is a liquid like handle, starting by a letter or underscore `_` and following by a letter `A-Z a-z`, a digit `0-9`, an underscore `_` +A **global/property variable** like `{{ "{{" }} name {{ "}}" }}` is a liquid like handle, starting by a letter or underscore `_` and following by a letter `A-Z a-z`, a digit `0-9`, an underscore `_`. + +Global/property variables are resolved against the current object context first, then against any outer object contexts. These object contexts are stacked by constructs such as `with` blocks, parametric functions, the template model, and the builtins. An assignment to a global/property variable writes to the current object context; it does not search for and update a variable with the same name in an outer object context. The following text are valid variable names: @@ -405,7 +407,7 @@ The following text are valid variable names: > [!NOTE] > In liquid, the character `-` is allowed in a variable name, but when translating it to a scriban, you will have to enclose it into a quoted string -A **local variable** like `{{ "{{" }} $name {{ "}}" }}` is an identifier starting with `$`. A local variable is only accessible within the same include page or function body. +A **local variable** like `{{ "{{" }} $name {{ "}}" }}` is an identifier starting with `$`. A local variable is only accessible within the same include page or function body. Local variables are stored in a separate local scope, so assigning `$name` does not create or update the global/property variable `name`, and both can coexist. The **special local variable** `$` alone is an array containing the arguments passed to the current function or include page. @@ -687,9 +689,11 @@ Note that a function can have mixed text statements as well: ``` > [!NOTE] -> Setting a non-local variable (e.g `a = 10`) in a simple function will be set at the global level and not at the function level. +> Simple functions create a local scope for local variables (e.g `$a`) and for the special `$` arguments variable, but they do not create a new object/global context. +> +> Setting a non-local variable (e.g `a = 10`) in a simple function writes to the current object/global context of the caller. > -> Parametric functions are solving this behavior by introducing a new variable scope inside the function that includes parameters. +> Parametric functions differ by introducing a new object/global context inside the function that includes parameters. ### 7.2 Anonymous functions @@ -726,6 +730,8 @@ They are similar to simple functions but they are declared with parenthesis, whi Another difference with simple functions is that they require function calls and arguments to match the expected function parameters. +Parametric functions open a new object/global context for each invocation. The parameter names are global/property variables in that new context. Assigning to an unprefixed name inside the function writes to this function context, so it will not update an outer global/property variable with the same name. Local variables prefixed with `$` still use the separate local scope, as with simple functions. + - A function with normal parameters: ``` @@ -1383,7 +1389,7 @@ Note that `readonly` variables won't be override. ### 9.9 `with ... end` -The `with ... end` statement will open a new object context with the passed variable, all assignment will result in setting the members of the passed object. +The `with ... end` statement opens a new object/global context with the passed variable. Unprefixed assignments write to the passed object, even if an outer context contains a variable with the same name. Variables declared outside the `with` block are still readable from inside the block when the current object does not contain a member with that name. ``` myobject = {} @@ -1421,9 +1427,6 @@ will output: 4 -> This is inside the wrap! ``` -Note that variables declared outside the `with` block are accessible within. - - ### 9.11 `include arg1?...argn?` and `include_join ` `include` is not a statement but rather a function that allows you to parse and render a specified template. To use this function, set a template loader on [`TemplateContext.TemplateLoader`](../runtime/includes#include-and-itemplateloader) before rendering the template. From 54b781ecdbba3f340d50f7d58bc8f096b36e3c04 Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Fri, 29 May 2026 09:30:17 +0200 Subject: [PATCH 05/11] Fix parametric function variable scope (#676) --- site/docs/language.md | 6 +- src/Scriban.Tests/TestAsync.cs | 21 ++ src/Scriban.Tests/TestRuntime.cs | 124 ++++++++++ src/Scriban/ScribanAsync.generated.cs | 24 +- .../Syntax/Statements/ScriptFunction.cs | 24 +- src/Scriban/TemplateContext.Variables.cs | 223 +++++++++++++----- src/Scriban/TemplateContext.cs | 4 + 7 files changed, 328 insertions(+), 98 deletions(-) diff --git a/site/docs/language.md b/site/docs/language.md index ec405454..c2ef69eb 100644 --- a/site/docs/language.md +++ b/site/docs/language.md @@ -396,7 +396,7 @@ Scriban supports the concept of **global/property** and **local** variables. A **global/property variable** like `{{ "{{" }} name {{ "}}" }}` is a liquid like handle, starting by a letter or underscore `_` and following by a letter `A-Z a-z`, a digit `0-9`, an underscore `_`. -Global/property variables are resolved against the current object context first, then against any outer object contexts. These object contexts are stacked by constructs such as `with` blocks, parametric functions, the template model, and the builtins. An assignment to a global/property variable writes to the current object context; it does not search for and update a variable with the same name in an outer object context. +Global/property variables are resolved against the current function variable scope first (when applicable), then against the current object context and any outer object contexts. Object contexts are stacked by constructs such as `with` blocks, the template model, and the builtins. An assignment to a global/property variable writes to the current object context; it does not search for and update a variable with the same name in an outer object context. The following text are valid variable names: @@ -693,7 +693,7 @@ Note that a function can have mixed text statements as well: > > Setting a non-local variable (e.g `a = 10`) in a simple function writes to the current object/global context of the caller. > -> Parametric functions differ by introducing a new object/global context inside the function that includes parameters. +> Named function calls do not inherit the function variable scope of the caller. Parametric functions differ by introducing a function variable scope that includes parameters. ### 7.2 Anonymous functions @@ -730,7 +730,7 @@ They are similar to simple functions but they are declared with parenthesis, whi Another difference with simple functions is that they require function calls and arguments to match the expected function parameters. -Parametric functions open a new object/global context for each invocation. The parameter names are global/property variables in that new context. Assigning to an unprefixed name inside the function writes to this function context, so it will not update an outer global/property variable with the same name. Local variables prefixed with `$` still use the separate local scope, as with simple functions. +Parametric functions open a new function variable scope for each invocation. The parameter names are global/property variables in that function scope. Assigning to a parameter or to a variable created only inside the function writes to the function scope, so it will not leak to the caller. Assigning to an unprefixed name that already exists in an available object/global scope writes to the current object/global context; for example, a `with` block still receives the assignment instead of updating an outer object context. Local variables prefixed with `$` still use the separate local scope, as with simple functions. - A function with normal parameters: diff --git a/src/Scriban.Tests/TestAsync.cs b/src/Scriban.Tests/TestAsync.cs index f778ba86..70e579fc 100644 --- a/src/Scriban.Tests/TestAsync.cs +++ b/src/Scriban.Tests/TestAsync.cs @@ -100,6 +100,27 @@ public async Task RenderAsyncShouldAwaitValueTaskMemberValues() Assert.That(result, Is.EqualTo("hello")); } + [Test] + public async Task RenderAsyncShouldUseFunctionScopeForParametricFunctions() + { + var template = Template.Parse(@" +{{- +my_global_var = 1 + +func mutate_global(x) + my_global_var += 1 +end + +mutate_global 0 +my_global_var +-}} +"); + + var result = await template.RenderAsync(); + + Assert.That(result, Is.EqualTo("2")); + } + public class ValueWrapper { public string Value { get; set; } diff --git a/src/Scriban.Tests/TestRuntime.cs b/src/Scriban.Tests/TestRuntime.cs index 76e02447..587a08d3 100644 --- a/src/Scriban.Tests/TestRuntime.cs +++ b/src/Scriban.Tests/TestRuntime.cs @@ -961,6 +961,130 @@ public void TestPipeAndFunction() TextAssert.AreEqual("12300", result); } + [Test] + public void TestParametricFunctionCanMutateGlobalVariableScope() + { + var template = Template.Parse(@" +{{- +my_global_var = 1 + +func mutate_global(x) + my_global_var += 1 +end + +mutate_global 0 +my_global_var +-}} +"); + + var result = template.Render(); + TextAssert.AreEqual("2", result); + } + + [Test] + public void TestParametricFunctionVariablesDoNotLeakToGlobalScope() + { + var template = Template.Parse(@" +{{- +func set_function_variable(x) + function_variable = x +end + +set_function_variable 42 +function_variable +-}} +"); + + var result = template.Render(); + TextAssert.AreEqual(string.Empty, result); + } + + [Test] + public void TestParametricFunctionDoesNotExposeCallerFunctionScope() + { + var template = Template.Parse(@" +{{- +func read_x(y) + ret x +end + +func caller(x) + ret read_x 0 +end + +caller 42 +-}} +"); + + var result = template.Render(); + TextAssert.AreEqual(string.Empty, result); + } + + [Test] + public void TestParameterlessFunctionDoesNotExposeCallerFunctionScope() + { + var template = Template.Parse(@" +{{- +func read_x + ret x +end + +func caller(x) + ret read_x +end + +caller 42 +-}} +"); + + var result = template.Render(); + TextAssert.AreEqual(string.Empty, result); + } + + [Test] + public void TestWithStatementWritesToCurrentGlobalScope() + { + var template = Template.Parse(@" +{{- +my_global_var = 1 +target = {} + +with target + my_global_var = 2 +end + +my_global_var; '|'; target.my_global_var +-}} +"); + + var result = template.Render(); + TextAssert.AreEqual("1|2", result); + } + + [Test] + public void TestParametricFunctionWritesToWithScope() + { + var template = Template.Parse(@" +{{- +my_global_var = 1 +target = {} + +with target + func mutate_global(x) + my_global_var += 1 + end + + mutate_global 0 +end + +my_global_var; '|'; target.my_global_var +-}} +"); + + var result = template.Render(); + TextAssert.AreEqual("1|2", result); + } + [Test] public void TestPipeAndFunctionAndLoop() diff --git a/src/Scriban/ScribanAsync.generated.cs b/src/Scriban/ScribanAsync.generated.cs index dda40aa6..ea0dcfaf 100644 --- a/src/Scriban/ScribanAsync.generated.cs +++ b/src/Scriban/ScribanAsync.generated.cs @@ -1386,14 +1386,8 @@ partial class ScriptFunction public async ValueTask InvokeAsync(TemplateContext context, ScriptNode? callerContext, ScriptArray arguments, ScriptBlockStatement? blockStatement) { bool hasParams = HasParameters; - if (hasParams) - { - context.PushGlobal(new ScriptObject()); - } - else - { - context.PushLocal(); - } + var functionVariables = hasParams ? new ScriptObject() : null; + context.PushFunction(functionVariables, !IsAnonymous); try { @@ -1405,8 +1399,7 @@ partial class ScriptFunction context.SetValue(ScriptVariable.Arguments, arguments, true); if (hasParams) { - var glob = context.CurrentGlobal; - if (glob is null) + if (functionVariables is null) { throw new ScriptRuntimeException(Span, "Missing global scope for function invocation."); } @@ -1426,7 +1419,7 @@ partial class ScriptFunction throw new ScriptRuntimeException(param.Span, "Missing function parameter name."); } - glob.SetValue(parameterName, arguments[i], false); + functionVariables.SetValue(parameterName, arguments[i], false); } } @@ -1441,14 +1434,7 @@ partial class ScriptFunction } finally { - if (hasParams) - { - context.PopGlobal(); - } - else - { - context.PopLocal(); - } + context.PopFunction(); } } } diff --git a/src/Scriban/Syntax/Statements/ScriptFunction.cs b/src/Scriban/Syntax/Statements/ScriptFunction.cs index b2dca6ba..4cb8f8f0 100644 --- a/src/Scriban/Syntax/Statements/ScriptFunction.cs +++ b/src/Scriban/Syntax/Statements/ScriptFunction.cs @@ -194,14 +194,8 @@ public override void PrintTo(ScriptPrinter printer) public object? Invoke(TemplateContext context, ScriptNode? callerContext, ScriptArray arguments, ScriptBlockStatement? blockStatement) { bool hasParams = HasParameters; - if (hasParams) - { - context.PushGlobal(new ScriptObject()); - } - else - { - context.PushLocal(); - } + var functionVariables = hasParams ? new ScriptObject() : null; + context.PushFunction(functionVariables, !IsAnonymous); try { if (NameOrDoToken is ScriptVariableLocal localVariable) @@ -213,8 +207,7 @@ public override void PrintTo(ScriptPrinter printer) if (hasParams) { - var glob = context.CurrentGlobal; - if (glob is null) + if (functionVariables is null) { throw new ScriptRuntimeException(Span, "Missing global scope for function invocation."); } @@ -233,7 +226,7 @@ public override void PrintTo(ScriptPrinter printer) throw new ScriptRuntimeException(param.Span, "Missing function parameter name."); } - glob.SetValue(parameterName, arguments[i], false); + functionVariables.SetValue(parameterName, arguments[i], false); } } @@ -248,14 +241,7 @@ public override void PrintTo(ScriptPrinter printer) } finally { - if (hasParams) - { - context.PopGlobal(); - } - else - { - context.PopLocal(); - } + context.PopFunction(); } } diff --git a/src/Scriban/TemplateContext.Variables.cs b/src/Scriban/TemplateContext.Variables.cs index c526feae..6579213b 100644 --- a/src/Scriban/TemplateContext.Variables.cs +++ b/src/Scriban/TemplateContext.Variables.cs @@ -98,6 +98,29 @@ public void PopLocal() PopVariableScope(VariableScope.Local); } + internal void PushFunction(ScriptObject? functionVariables, bool hideParentFunctionScopes) + { + var functionContext = _availableFunctionContexts.Count > 0 ? _availableFunctionContexts.Pop() : new VariableContext(null); + functionContext.LocalObject = functionVariables; + functionContext.HideParentFunctionScopes = hideParentFunctionScopes; + _functionContexts.Push(functionContext); + PushLocal(); + } + + internal void PopFunction() + { + var functionContext = _functionContexts.Pop(); + PopLocal(); + + if (functionContext.LocalObject is ScriptObject functionVariables) + { + functionVariables.Clear(); + } + functionContext.LocalObject = null; + functionContext.HideParentFunctionScopes = false; + _availableFunctionContexts.Push(functionContext); + } + /// /// Sets the variable with the specified value. /// @@ -182,7 +205,7 @@ public virtual void SetLoopVariable(ScriptVariable variable, object? value) if (variable is null) throw new ArgumentNullException(nameof(variable)); var context = variable.Scope == ScriptVariableScope.Global - ? _globalContexts.Peek() + ? GetCurrentGlobalVariableContext() : _currentLocalContext ?? throw new InvalidOperationException("No current local context is available."); if (context.Loops.Count == 0) @@ -262,32 +285,21 @@ private void PushLocalContext(ScriptObject? locals = null) if (variable is null) throw new ArgumentNullException(nameof(variable)); object? value = null; + foreach (var functionContext in GetVisibleFunctionContexts()) + { + if (TryGetValue(functionContext, variable, out value)) + { + return value; + } + } + { var count = _globalContexts.Count; var items = _globalContexts.Items; - var isInLoop = IsInLoop; for (int i = count - 1; i >= 0; i--) { var context = items[i]; - // Check loop variable first - if (isInLoop) - { - var loopCount = context.Loops.Count; - if (loopCount > 0) - { - var loopItems = context.Loops.Items; - for (int j = loopCount - 1; j >= 0; j--) - { - if (loopItems[j].TryGetValue(this, variable.Span, variable.Name, out value)) - { - return value; - } - } - } - } - - var localObject = items[i].LocalObject; - if (localObject is not null && localObject.TryGetValue(this, variable.Span, variable.Name, out value)) + if (TryGetValue(context, variable, out value)) { return value; } @@ -379,23 +391,7 @@ private IScriptObject GetStoreForWrite(ScriptVariable variable) switch (scope) { case ScriptVariableScope.Global: - var name = variable.Name; - int lastStoreIndex = _globalContexts.Count - 1; - var items = _globalContexts.Items; - finalStore = items[lastStoreIndex].LocalObject; - if (finalStore is null) - { - throw new ScriptRuntimeException(variable.Span, $"Invalid usage of the global variable `{variable}` in the current context"); - } - - // We check that for upper store, we actually can write a variable with this name - // otherwise we don't allow to create a variable with the same name as a readonly variable - if (!finalStore.CanWrite(name)) - { - var variableType = finalStore == BuiltinObject ? "builtin " : string.Empty; - throw new ScriptRuntimeException(variable.Span, $"Cannot set the {variableType}readonly variable `{variable}`"); - } - + finalStore = GetGlobalStoreForWrite(variable); break; case ScriptVariableScope.Local: var currentLocalContext = _currentLocalContext ?? throw new ScriptRuntimeException(variable.Span, $"Invalid usage of the local variable `{variable}` in the current context"); @@ -440,28 +436,20 @@ private IEnumerable GetStoreForRead(ScriptVariable variable) { case ScriptVariableScope.Global: { - var isInLoop = IsInLoop; - for (int i = _globalContexts.Count - 1; i >= 0; i--) + foreach (var functionContext in GetVisibleFunctionContexts()) { - var context = _globalContexts.Items[i]; - - // Return loop variable first - if (isInLoop) + foreach (var store in GetStoresForRead(functionContext)) { - var loopCount = context.Loops.Count; - if (loopCount > 0) - { - var loopItems = context.Loops.Items; - for (int j = loopCount - 1; j >= 0; j--) - { - yield return loopItems[j]; - } - } + yield return store; } + } - if (context.LocalObject is not null) + for (int i = _globalContexts.Count - 1; i >= 0; i--) + { + var context = _globalContexts.Items[i]; + foreach (var store in GetStoresForRead(context)) { - yield return context.LocalObject; + yield return store; } } @@ -503,6 +491,125 @@ private IEnumerable GetStoreForRead(ScriptVariable variable) } } + private IEnumerable GetVisibleFunctionContexts() + { + for (int i = _functionContexts.Count - 1; i >= 0; i--) + { + var functionContext = _functionContexts.Items[i]; + yield return functionContext; + + if (functionContext.HideParentFunctionScopes) + { + yield break; + } + } + } + + private VariableContext GetCurrentGlobalVariableContext() + { + foreach (var currentFunctionContext in GetVisibleFunctionContexts()) + { + if (currentFunctionContext.LocalObject is not null) + { + return currentFunctionContext; + } + } + + return _globalContexts.Peek(); + } + + private IScriptObject GetGlobalStoreForWrite(ScriptVariable variable) + { + var name = variable.Name; + var currentGlobal = _globalContexts.Peek().LocalObject; + if (currentGlobal is null) + { + throw new ScriptRuntimeException(variable.Span, $"Invalid usage of the global variable `{variable}` in the current context"); + } + + IScriptObject? currentFunctionStore = null; + foreach (var functionContext in GetVisibleFunctionContexts()) + { + var functionStore = functionContext.LocalObject; + if (functionStore is null) + { + continue; + } + + currentFunctionStore ??= functionStore; + if (functionStore.Contains(name)) + { + CheckStoreCanWrite(variable, functionStore); + return functionStore; + } + } + + if (currentFunctionStore is null) + { + CheckStoreCanWrite(variable, currentGlobal); + return currentGlobal; + } + + if (ContainsInGlobalContexts(name)) + { + CheckStoreCanWrite(variable, currentGlobal); + return currentGlobal; + } + + CheckStoreCanWrite(variable, currentFunctionStore); + return currentFunctionStore; + } + + private bool ContainsInGlobalContexts(string name) + { + for (int i = _globalContexts.Count - 1; i >= 0; i--) + { + if (_globalContexts.Items[i].LocalObject?.Contains(name) == true) + { + return true; + } + } + + return false; + } + + private static IEnumerable GetStoresForRead(VariableContext context) + { + var loopItems = context.Loops.Items; + for (int i = context.Loops.Count - 1; i >= 0; i--) + { + yield return loopItems[i]; + } + + if (context.LocalObject is not null) + { + yield return context.LocalObject; + } + } + + private bool TryGetValue(VariableContext context, ScriptVariable variable, out object? value) + { + foreach (var store in GetStoresForRead(context)) + { + if (store.TryGetValue(this, variable.Span, variable.Name, out value)) + { + return true; + } + } + + value = null; + return false; + } + + private void CheckStoreCanWrite(ScriptVariable variable, IScriptObject store) + { + if (!store.CanWrite(variable.Name)) + { + var variableType = store == BuiltinObject ? "builtin " : string.Empty; + throw new ScriptRuntimeException(variable.Span, $"Cannot set the {variableType}readonly variable `{variable}`"); + } + } + private void CheckVariableFound(ScriptVariable variable, bool found) { if (StrictVariables && !found) @@ -526,7 +633,7 @@ private void PushVariableScope(VariableScope scope) { var localStore = _availableStores.Count > 0 ? _availableStores.Pop() : new ScriptObject(); var globalStore = _availableStores.Count > 0 ? _availableStores.Pop() : new ScriptObject(); - _globalContexts.Peek().Loops.Push(globalStore); + GetCurrentGlobalVariableContext().Loops.Push(globalStore); var currentLocalContext = _currentLocalContext ?? throw new InvalidOperationException("No current local context is available."); currentLocalContext.Loops.Push(localStore); } @@ -558,7 +665,7 @@ private void PopVariableScope(VariableScope scope) throw new InvalidOperationException("Invalid number of matching push/pop VariableScope."); } - var globalStore = _globalContexts.Peek().Loops.Pop(); + var globalStore = GetCurrentGlobalVariableContext().Loops.Pop(); // The store is cleanup once it is pushed back globalStore.Clear(); _availableStores.Push(globalStore); @@ -581,6 +688,8 @@ public VariableContext(IScriptObject? localObject) public IScriptObject? LocalObject; public FastStack Loops; + + public bool HideParentFunctionScopes; } private enum VariableScope diff --git a/src/Scriban/TemplateContext.cs b/src/Scriban/TemplateContext.cs index 163c9592..82555382 100644 --- a/src/Scriban/TemplateContext.cs +++ b/src/Scriban/TemplateContext.cs @@ -43,6 +43,7 @@ partial class TemplateContext private FastStack _availableStores; internal FastStack BlockDelegates; private FastStack _globalContexts; + private FastStack _functionContexts; private FastStack _cultures; private readonly Dictionary _listAccessors; private FastStack _loops; @@ -58,6 +59,7 @@ partial class TemplateContext private int _loopStep; private int _getOrSetValueLevel; private FastStack _availableGlobalContexts; + private FastStack _availableFunctionContexts; private FastStack _availableLocalContexts; private FastStack _availablePipeArguments; private FastStack _pipeArguments; @@ -167,7 +169,9 @@ public TemplateContext(ScriptObject? builtin, IEqualityComparer? keyComp _outputs.Push(_output); _globalContexts = new FastStack(4); + _functionContexts = new FastStack(4); _availableGlobalContexts = new FastStack(4); + _availableFunctionContexts = new FastStack(4); _availableLocalContexts = new FastStack(4); _localContexts = new FastStack(4); _availableStores = new FastStack(4); From 29294e1b470039a6e2605d1a9b81b07375bb9d30 Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Fri, 29 May 2026 09:41:34 +0200 Subject: [PATCH 06/11] Clarify MemberFilter sandbox limitations --- site/docs/builtins/object.md | 1 + site/docs/runtime/member-renamer.md | 5 ++++- site/docs/runtime/readme.md | 4 ++-- site/docs/runtime/safe-runtime.md | 9 ++++++--- site/docs/runtime/scriptobject.md | 2 ++ site/readme.md | 6 +++--- src/Scriban/Functions/ObjectFunctions.cs | 1 + src/Scriban/TemplateContext.cs | 3 ++- 8 files changed, 21 insertions(+), 10 deletions(-) diff --git a/site/docs/builtins/object.md b/site/docs/builtins/object.md index 436871f2..53820a30 100644 --- a/site/docs/builtins/object.md +++ b/site/docs/builtins/object.md @@ -448,6 +448,7 @@ A JSON representation of the value true null ``` +`object.to_json` serializes primitive/scalar values and values implementing `IFormattable` directly with System.Text.Json. For these values, serialization does not use `TemplateContext.MemberFilter` or `TemplateContext.MemberRenamer`. Do not expose objects containing data that templates must not access; prefer explicit `ScriptObject` / `ScriptArray` models for untrusted templates. > [!NOTE] > This document was automatically generated from the source code using `Scriban.DocGen`. diff --git a/site/docs/runtime/member-renamer.md b/site/docs/runtime/member-renamer.md index a5417a22..adba20dc 100644 --- a/site/docs/runtime/member-renamer.md +++ b/site/docs/runtime/member-renamer.md @@ -63,6 +63,9 @@ template.Render(new MyObject(), member => member.Name); Similar to the member renamer, by default, reflection-backed .NET objects accessed through a `ScriptObject` are automatically exposing all public instance fields and properties of .NET objects. +> [!IMPORTANT] +> `MemberFilter` is an exposure convenience for reflected .NET members; it is not a security sandbox. If a template must not access a value, do not put that value (or an object graph that can reveal it) in the Scriban context. For security-sensitive or untrusted templates, prefer constructing a sanitized `ScriptObject` / `ScriptArray` model containing only approved values and functions, and push that into the `TemplateContext` instead of exposing .NET objects directly. This explicit model is also the Native AOT/trimming-friendly path. + A filter is simply a delegate that takes an input MemberInfo and return a boolean to indicate whether to expose the member (`true`) or discard the member (`false`) ```csharp @@ -93,6 +96,6 @@ namespace Scriban.Runtime var context = new TemplateContext {MemberFilter = member => member is PropertyInfo && member.Name.Contains("Yo") }; ``` -Member filters apply to reflected .NET members only. They do not filter dictionary keys or `ScriptObject` entries because these are data entries, not `MemberInfo` reflection members. If a template should not see some dictionary data, project or sanitize that dictionary before exposing it to Scriban. +Member filters apply to reflected .NET members only. They do not filter dictionary keys or `ScriptObject` entries because these are data entries, not `MemberInfo` reflection members. They also do not affect code paths that deliberately serialize or format a value outside Scriban's member-accessor layer; for example, `object.to_json` uses `System.Text.Json` directly for primitive/scalar values and values implementing `IFormattable`. If a template should not see some data, project or sanitize that data before exposing it to Scriban. As for the member renamer, it is important to setup this on the `TemplateContext` for any reflection-backed .NET objects that might be accessed indirectly through another `ScriptObject` so that when a .NET object is exposed, it is exposed with the same filtering convention. diff --git a/site/docs/runtime/readme.md b/site/docs/runtime/readme.md index 438e45e7..078dc0c3 100644 --- a/site/docs/runtime/readme.md +++ b/site/docs/runtime/readme.md @@ -6,7 +6,7 @@ title: "Runtime API" This document describes the runtime API to manipulate scriban text templating. -Scriban provides a **safe runtime**, meaning it doesn't expose any .NET objects that haven't been made explicitly available to a Template. +Scriban provides a **safe runtime**, meaning it doesn't expose any .NET objects that haven't been made explicitly available to a Template. This is an exposure-control model, not a complete security sandbox for arbitrary objects: the host application must avoid exposing objects, properties, functions, loaders, or data that an untrusted template must not use. For security-sensitive or untrusted templates, prefer pushing explicit, sanitized `ScriptObject` / `ScriptArray` data into the `TemplateContext` instead of exposing .NET objects directly; this is also the Native AOT/trimming-friendly approach. The runtime is composed of two main parts: @@ -25,4 +25,4 @@ The scriban runtime was designed to provide an easy, powerful and extensible inf | [Include and `ITemplateLoader`](includes.md) | Load templates dynamically with the `include` directive | | [Lexer, Parser and AST](ast.md) | Low-level parsing, the Abstract Syntax Tree and AST-to-text round-tripping | | [Extending and custom functions](extending.md) | Extend `TemplateContext`, advanced and hyper custom functions | -| [Safe runtime](safe-runtime.md) | Understand Scriban's sandbox model, evaluate expressions, and configure `TemplateContext` runtime limits and execution switches | +| [Safe runtime](safe-runtime.md) | Understand Scriban's exposure model, evaluate expressions, and configure `TemplateContext` runtime limits and execution switches | diff --git a/site/docs/runtime/safe-runtime.md b/site/docs/runtime/safe-runtime.md index 500bf775..18772897 100644 --- a/site/docs/runtime/safe-runtime.md +++ b/site/docs/runtime/safe-runtime.md @@ -9,12 +9,15 @@ Scriban's safe runtime has two complementary parts: - **Exposure control**: templates can only access the builtin functions plus the objects, members, and functions that your application explicitly exposes. - **Execution control**: `TemplateContext` lets you put limits on loops, recursion, string/output growth, regex execution, and how permissive the runtime should be when values are missing or null. -This is not a process-level sandbox. If you expose a .NET object that can access the file system or network, or configure an [`ITemplateLoader`](includes.md#include-and-itemplateloader) that reads from disk, templates can use those capabilities. +This is not a process-level sandbox or a security boundary around arbitrary .NET objects. If you expose a .NET object that can access the file system, network, secrets, or other sensitive state, or configure an [`ITemplateLoader`](includes.md#include-and-itemplateloader) that reads from disk, templates can use those capabilities. -The practical sandbox boundary is therefore: +For untrusted templates, the host application is responsible for exposing only data and functions that the template is allowed to use. Prefer building the context from explicit, sanitized [`ScriptObject`](scriptobject.md) and `ScriptArray` values instead of passing rich .NET objects directly. This also keeps the data model compatible with Native AOT/trimming because Scriban does not need reflection to discover members. Do not rely on `MemberFilter`, `MemberRenamer`, relaxed access switches, or individual builtins as a complete sandbox for objects that contain sensitive members. + +The practical exposure boundary is therefore: - which globals and builtins you expose through [`ScriptObject`](scriptobject.md) -- which .NET members you allow through the [member renamer and filter](member-renamer.md) +- which .NET objects and values you choose to put in the context; project or sanitize sensitive data before exposure +- which .NET members you allow through the [member renamer and filter](member-renamer.md), as a convenience exposure mechanism rather than a security guarantee - whether you configure `TemplateContext.TemplateLoader` for `include` - which `TemplateContext` execution limits and relaxed-access switches you enable diff --git a/site/docs/runtime/scriptobject.md b/site/docs/runtime/scriptobject.md index b40552cf..621a785e 100644 --- a/site/docs/runtime/scriptobject.md +++ b/site/docs/runtime/scriptobject.md @@ -6,6 +6,8 @@ title: "The ScriptObject" The `ScriptObject` is a special implementation of a `Dictionary` that runtime properties and functions accessible to a template: +For security-sensitive or untrusted templates, prefer exposing an explicit `ScriptObject` / `ScriptArray` data model containing only approved values and functions instead of passing rich .NET objects directly to the `TemplateContext`. Avoid storing sensitive .NET objects inside a `ScriptObject`, because templates can access exposed object graphs. Building the model explicitly also keeps rendering compatible with Native AOT/trimming by avoiding reflection-based member discovery. + ## Accessing as regular dictionary objects A `ScriptObject` is mainly an extended version of a `IDictionary`: diff --git a/site/readme.md b/site/readme.md index 3758ad11..21ae3fb0 100644 --- a/site/readme.md +++ b/site/readme.md @@ -89,10 +89,10 @@ og_type: website
    -
    Safe sandbox
    +
    Safe runtime

    - By default, no .NET objects are exposed unless explicitly allowed. You control exactly what is available to templates - perfect for user-facing scenarios. + By default, no .NET objects are exposed unless explicitly allowed. For untrusted templates, prefer explicit ScriptObject data with only sanitized values and safe functions.

    [Runtime & security](docs/runtime/readme.md) · [Safe runtime](docs/runtime/readme.md#safe-runtime) @@ -288,4 +288,4 @@ For more examples, see the [Getting started](docs/getting-started.md) guide. } }); })(); - \ No newline at end of file + diff --git a/src/Scriban/Functions/ObjectFunctions.cs b/src/Scriban/Functions/ObjectFunctions.cs index 1cc97ad9..1fd5f88b 100644 --- a/src/Scriban/Functions/ObjectFunctions.cs +++ b/src/Scriban/Functions/ObjectFunctions.cs @@ -555,6 +555,7 @@ private static TemplateContext.LoopType GetLoopType(IEnumerable values) /// true /// null /// ``` + /// `object.to_json` serializes primitive/scalar values and values implementing `IFormattable` directly with System.Text.Json. For these values, serialization does not use `TemplateContext.MemberFilter` or `TemplateContext.MemberRenamer`. Do not expose objects containing data that templates must not access; prefer explicit `ScriptObject` / `ScriptArray` models for untrusted templates. /// [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing known primitive types, strings, and IFormattable values.")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing known primitive types, strings, and IFormattable values.")] diff --git a/src/Scriban/TemplateContext.cs b/src/Scriban/TemplateContext.cs index 82555382..87416cdd 100644 --- a/src/Scriban/TemplateContext.cs +++ b/src/Scriban/TemplateContext.cs @@ -285,7 +285,8 @@ public bool IndentWithInclude /// /// A global setting used to filter reflected field/property names of exposed .NET objects. - /// This does not filter dictionary keys or entries. + /// This does not filter dictionary keys or entries and is not a security boundary for sensitive object graphs. + /// For untrusted templates, prefer pushing explicit, sanitized / models instead of exposing .NET objects directly. /// public MemberFilterDelegate? MemberFilter { get; set; } From 34d1cd12632d1c09cb5ca9bd7f7d1f1fef13b74f Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Sat, 30 May 2026 07:42:31 +0200 Subject: [PATCH 07/11] Document safe include file loading --- site/docs/runtime/includes.md | 41 +++++++++++++++++++++----- src/Scriban/Runtime/ITemplateLoader.cs | 5 ++-- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/site/docs/runtime/includes.md b/site/docs/runtime/includes.md index fc6458f2..3434cd4a 100644 --- a/site/docs/runtime/includes.md +++ b/site/docs/runtime/includes.md @@ -15,8 +15,9 @@ A template loader is responsible for providing the text template from an include public interface ITemplateLoader { /// - /// Gets an absolute path for the specified include template name. Note that it is not necessarely a path on a disk, - /// but an absolute path that can be used as a dictionary key for caching) + /// Gets an absolute path for the specified include template name. Note that it is not necessarily a path on a disk, + /// but an absolute path that can be used as a dictionary key for caching). If the loader maps template names to + /// files, it is responsible for validating and normalizing names against its allowed template roots. /// /// The current context called from /// The current span called from @@ -37,26 +38,50 @@ public interface ITemplateLoader In order to use the `include` directive, the template loader should provide: -- The `GetPath` method translates a `templateName` (the argument passed to the `include ` directive) to a logical/phyisical path that the `ITemplateLoader.Load` method will understand. +- The `GetPath` method translates a `templateName` (the argument passed to the `include ` directive) to a logical/physical path that the `ITemplateLoader.Load` method will understand. - The `Load` method to actually load the the text template code from the specified `templatePath` (previously returned by `GetPath` method) The 2 step methods, `GetPath` and then `Load` allows to cache intermediate results. If a template loader returns the same `template path` for a `template name` any existing cached templates will be returned instead. Cached templates are stored in the `TemplateContext.CachedTemplates` property. -A typical implementation of `ITemplateLoader` could read data from the disk: +A template name is application-defined: Scriban passes the value from `include` to the loader, but it does not normalize or restrict it because a loader might use logical names, database keys, embedded resources, or another non-file scheme. If a loader maps template names to files, the loader should normalize the candidate path and verify that it stays under the intended template root before reading from disk: ```csharp /// -/// A very simple ITemplateLoader loading directly from the disk, without any checks...etc. +/// A simple ITemplateLoader loading templates from a configured directory. /// public class MyIncludeFromDisk : ITemplateLoader { - string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName) + private static readonly StringComparison PathComparison = + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + private readonly string _templateRoot; + + public MyIncludeFromDisk(string templateRoot) + { + _templateRoot = Path.GetFullPath(templateRoot); + + if (!_templateRoot.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + { + _templateRoot += Path.DirectorySeparatorChar; + } + } + + public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName) { - return Path.Combine(Environment.CurrentDirectory, templateName); + var templatePath = Path.GetFullPath(Path.Combine(_templateRoot, templateName)); + + if (!templatePath.StartsWith(_templateRoot, PathComparison)) + { + throw new ScriptRuntimeException(callerSpan, $"Include `{templateName}` is outside the template root."); + } + + return templatePath; } - string Load(TemplateContext context, SourceSpan callerSpan, string templatePath) + public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath) { // Template path was produced by the `GetPath` method above in case the Template has // not been loaded yet diff --git a/src/Scriban/Runtime/ITemplateLoader.cs b/src/Scriban/Runtime/ITemplateLoader.cs index 0199fd91..7c78ed11 100644 --- a/src/Scriban/Runtime/ITemplateLoader.cs +++ b/src/Scriban/Runtime/ITemplateLoader.cs @@ -21,8 +21,9 @@ namespace Scriban.Runtime interface ITemplateLoader { /// - /// Gets an absolute path for the specified include template name. Note that it is not necessarely a path on a disk, - /// but an absolute path that can be used as a dictionary key for caching) + /// Gets an absolute path for the specified include template name. Note that it is not necessarily a path on a disk, + /// but an absolute path that can be used as a dictionary key for caching). If the loader maps template names to + /// files, it is responsible for validating and normalizing names against its allowed template roots. /// /// The current context called from /// The current span called from From c7377a60c6e067c97f9ffe0e7821fb5b11f8eb76 Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Sat, 30 May 2026 09:00:07 +0200 Subject: [PATCH 08/11] Restrict reflected writes to public setters --- site/docs/runtime/safe-runtime.md | 2 + src/Scriban.Tests/TestRuntime.cs | 61 +++++++++++++++++++ .../Runtime/Accessors/TypedObjectAccessor.cs | 29 +++++++++ 3 files changed, 92 insertions(+) diff --git a/site/docs/runtime/safe-runtime.md b/site/docs/runtime/safe-runtime.md index 18772897..6e339137 100644 --- a/site/docs/runtime/safe-runtime.md +++ b/site/docs/runtime/safe-runtime.md @@ -13,6 +13,8 @@ This is not a process-level sandbox or a security boundary around arbitrary .NET For untrusted templates, the host application is responsible for exposing only data and functions that the template is allowed to use. Prefer building the context from explicit, sanitized [`ScriptObject`](scriptobject.md) and `ScriptArray` values instead of passing rich .NET objects directly. This also keeps the data model compatible with Native AOT/trimming because Scriban does not need reflection to discover members. Do not rely on `MemberFilter`, `MemberRenamer`, relaxed access switches, or individual builtins as a complete sandbox for objects that contain sensitive members. +When a template accesses a reflected .NET object directly, it can assign to public fields and public non-`init` property or indexer setters. Properties with private, internal, protected, or `init` setters are read-only to templates. `MemberFilter` controls which reflected members are exposed, but it does not provide separate read and write filtering. + The practical exposure boundary is therefore: - which globals and builtins you expose through [`ScriptObject`](scriptobject.md) diff --git a/src/Scriban.Tests/TestRuntime.cs b/src/Scriban.Tests/TestRuntime.cs index 587a08d3..0e967090 100644 --- a/src/Scriban.Tests/TestRuntime.cs +++ b/src/Scriban.Tests/TestRuntime.cs @@ -41,6 +41,13 @@ private static TException AssertThrows(TestDelegate code) where TExc return Assert.Throws(code) ?? throw new AssertionException($"Expected {typeof(TException).Name}."); } + private static string RenderWithObject(string script, object value) + { + var context = new TemplateContext(); + context.PushGlobal(new ScriptObject { ["obj"] = value }); + return Template.Parse(script).Render(context); + } + [Test] public void TestFunctionPointerWithPath() { @@ -1593,6 +1600,39 @@ public void TestScriptObjectAccessor() } } + [Test] + public void TestTypedObjectAccessorSetterVisibility() + { + var obj = new ObjectWithRestrictedSetters { InitOnlyValue = "init" }; + + RenderWithObject("{{ obj.public_value = 'changed' }}", obj); + Assert.AreEqual("changed", obj.PublicValue); + + RenderWithObject("{{ obj.public_field = 'changed' }}", obj); + Assert.AreEqual("changed", obj.PublicField); + + AssertReadonly("{{ obj.private_set_value = 'changed' }}"); + Assert.AreEqual("private", obj.PrivateSetValue); + + AssertReadonly("{{ obj.internal_set_value = 'changed' }}"); + Assert.AreEqual("internal", obj.InternalSetValue); + + AssertReadonly("{{ obj.init_only_value = 'changed' }}"); + Assert.AreEqual("init", obj.InitOnlyValue); + + AssertReadonly("{{ obj.read_only_field = 'changed' }}"); + Assert.AreEqual("readonly", obj.ReadOnlyField); + + AssertReadonly("{{ obj['key'] = 'changed' }}"); + Assert.AreEqual("indexer", obj["key"]); + + void AssertReadonly(string script) + { + var exception = Assert.Throws(() => RenderWithObject(script, obj)); + StringAssert.Contains("readonly", exception!.Message); + } + } + [Test] public void TestNullableArgument() { @@ -2138,6 +2178,27 @@ private class MyObject2 : MyObject public string? PropertyC { get; set; } } + private class ObjectWithRestrictedSetters + { + public string PublicField = "public"; + + public readonly string ReadOnlyField = "readonly"; + + public string PublicValue { get; set; } = "public"; + + public string PrivateSetValue { get; private set; } = "private"; + + public string InternalSetValue { get; internal set; } = "internal"; + + public string InitOnlyValue { get; init; } = "init"; + + public string this[string key] + { + get => "indexer"; + private set { } + } + } + private class MyStaticObject { static MyStaticObject() diff --git a/src/Scriban/Runtime/Accessors/TypedObjectAccessor.cs b/src/Scriban/Runtime/Accessors/TypedObjectAccessor.cs index 4f779433..72a3d1a2 100644 --- a/src/Scriban/Runtime/Accessors/TypedObjectAccessor.cs +++ b/src/Scriban/Runtime/Accessors/TypedObjectAccessor.cs @@ -99,6 +99,11 @@ public bool TrySetItem(TemplateContext context, SourceSpan span, object target, { return false; } + var setMethod = _indexer.GetSetMethod(false); + if (setMethod is null || IsInitOnly(setMethod)) + { + return false; + } _indexer.SetValue(target, value, new[] { index }); return true; } @@ -112,11 +117,22 @@ public bool TrySetValue(TemplateContext context, SourceSpan span, object target, if (memberAccessor is FieldInfo fieldAccessor) { + if (fieldAccessor.IsInitOnly || fieldAccessor.IsLiteral) + { + return false; + } + fieldAccessor.SetValue(target, context.ToObject(span, value, fieldAccessor.FieldType)); return true; } var propertyAccessor = (PropertyInfo)memberAccessor; + var setMethod = propertyAccessor.GetSetMethod(false); + if (setMethod is null || IsInitOnly(setMethod)) + { + return false; + } + propertyAccessor.SetValue(target, context.ToObject(span, value, propertyAccessor.PropertyType)); return true; @@ -189,5 +205,18 @@ private string Rename(MemberInfo member) { return _renamer(member); } + + private static bool IsInitOnly(MethodInfo setMethod) + { + foreach (var modifier in setMethod.ReturnParameter.GetRequiredCustomModifiers()) + { + if (modifier.FullName == "System.Runtime.CompilerServices.IsExternalInit") + { + return true; + } + } + + return false; + } } } From 99cc7c61665e52b865076f6097e67ca5103b56ae Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Mon, 8 Jun 2026 19:28:52 +0200 Subject: [PATCH 09/11] Clarify URL escaping semantics --- site/docs/builtins/html.md | 14 ++-- .../TestFiles/400-builtins/470-html.out.txt | 2 + .../TestFiles/400-builtins/470-html.txt | 2 + src/Scriban.Tests/TestRuntime.cs | 2 +- src/Scriban/Functions/HtmlFunctions.cs | 70 ++++++++++++++++--- 5 files changed, 77 insertions(+), 13 deletions(-) diff --git a/site/docs/builtins/html.md b/site/docs/builtins/html.md index bd76d24d..9ca1f780 100644 --- a/site/docs/builtins/html.md +++ b/site/docs/builtins/html.md @@ -112,7 +112,10 @@ html.url_encode #### Description -Converts any URL-unsafe characters in a string into percent-encoded characters. +Percent-encodes a string for use as a URL component. +This function encodes URL syntax characters such as `:`, `/`, `?`, `#`, `&`, `=`, and `'`. Spaces are encoded as `%20`. +It does not validate complete URLs and does not make arbitrary input safe for every output context. When writing a value +into an HTML attribute, validate complete URLs separately and HTML-escape the attribute value. #### Arguments @@ -120,7 +123,7 @@ Converts any URL-unsafe characters in a string into percent-encoded characters. #### Returns -The input string url encoded +The input string URL encoded #### Examples @@ -141,7 +144,10 @@ html.url_escape #### Description -Identifies all characters in a string that are not allowed in URLS, and replaces the characters with their escaped variants. +Escapes characters that are not valid in a complete URL while preserving URL syntax characters. +This function is intended for already-formed, trusted or validated URLs and paths. It preserves reserved URL syntax +characters such as `:`, `/`, `?`, `#`, `&`, `=`, and `'`. It does not validate schemes or hosts and must not be used +as a sanitizer for untrusted `href` or `src` values. Use `html.url_encode` for untrusted URL components or query values. #### Arguments @@ -149,7 +155,7 @@ Identifies all characters in a string that are not allowed in URLS, and replaces #### Returns -The input string url escaped +The input string URL escaped #### Examples diff --git a/src/Scriban.Tests/TestFiles/400-builtins/470-html.out.txt b/src/Scriban.Tests/TestFiles/400-builtins/470-html.out.txt index dd7fa729..545099f2 100644 --- a/src/Scriban.Tests/TestFiles/400-builtins/470-html.out.txt +++ b/src/Scriban.Tests/TestFiles/400-builtins/470-html.out.txt @@ -1,6 +1,8 @@ <p>test</p> john%40liquid.com +a%20b%26c%3Dd%2Fe %3Chello%3E%20&%20%3Cscriban%3E +https://example.com/a%20path/?q=%3Ctag%3E&ok=true#frag and test and test and test diff --git a/src/Scriban.Tests/TestFiles/400-builtins/470-html.txt b/src/Scriban.Tests/TestFiles/400-builtins/470-html.txt index e5c2f694..85764971 100644 --- a/src/Scriban.Tests/TestFiles/400-builtins/470-html.txt +++ b/src/Scriban.Tests/TestFiles/400-builtins/470-html.txt @@ -1,6 +1,8 @@ {{ "

    test

    " | html.escape }} {{ "john@liquid.com" | html.url_encode }} +{{ "a b&c=d/e" | html.url_encode }} {{ " & " | html.url_escape }} +{{ "https://example.com/a path/?q=&ok=true#frag" | html.url_escape }} {{ " and test" | html.strip }} {{ " and test" | html.strip }} {{ " and test " | html.strip }} diff --git a/src/Scriban.Tests/TestRuntime.cs b/src/Scriban.Tests/TestRuntime.cs index 0e967090..4e7eca8d 100644 --- a/src/Scriban.Tests/TestRuntime.cs +++ b/src/Scriban.Tests/TestRuntime.cs @@ -36,7 +36,7 @@ private static IScriptObject GetCurrentGlobal(TemplateContext context) return context.CurrentGlobal ?? throw new AssertionException("Expected a current global script object."); } - private static TException AssertThrows(TestDelegate code) where TException : Exception + private static TException AssertThrows(Action code) where TException : Exception { return Assert.Throws(code) ?? throw new AssertionException($"Expected {typeof(TException).Name}."); } diff --git a/src/Scriban/Functions/HtmlFunctions.cs b/src/Scriban/Functions/HtmlFunctions.cs index ecf60135..9c749dbc 100644 --- a/src/Scriban/Functions/HtmlFunctions.cs +++ b/src/Scriban/Functions/HtmlFunctions.cs @@ -5,6 +5,7 @@ #nullable enable using System; +using System.Text; using System.Text.RegularExpressions; using Scriban.Runtime; @@ -101,10 +102,13 @@ class HtmlFunctions : ScriptObject /// - /// Converts any URL-unsafe characters in a string into percent-encoded characters. + /// Percent-encodes a string for use as a URL component. + /// This function encodes URL syntax characters such as `:`, `/`, `?`, `#`, `&`, `=`, and `'`. Spaces are encoded as `%20`. + /// It does not validate complete URLs and does not make arbitrary input safe for every output context. When writing a value + /// into an HTML attribute, validate complete URLs separately and HTML-escape the attribute value. /// /// The input string - /// The input string url encoded + /// The input string URL encoded /// /// ```scriban-html /// {{ "john@liquid.com" | html.url_encode }} @@ -123,10 +127,13 @@ class HtmlFunctions : ScriptObject } /// - /// Identifies all characters in a string that are not allowed in URLS, and replaces the characters with their escaped variants. + /// Escapes characters that are not valid in a complete URL while preserving URL syntax characters. + /// This function is intended for already-formed, trusted or validated URLs and paths. It preserves reserved URL syntax + /// characters such as `:`, `/`, `?`, `#`, `&`, `=`, and `'`. It does not validate schemes or hosts and must not be used + /// as a sanitizer for untrusted `href` or `src` values. Use `html.url_encode` for untrusted URL components or query values. /// /// The input string - /// The input string url escaped + /// The input string URL escaped /// /// ```scriban-html /// {{ "<hello> & <scriban>" | html.url_escape }} @@ -137,13 +144,60 @@ class HtmlFunctions : ScriptObject /// public static string? UrlEscape(string? text) { - if (string.IsNullOrEmpty(text)) + if (text is null || text.Length == 0) { return text; } -#pragma warning disable SYSLIB0013 - return Uri.EscapeUriString(text); -#pragma warning restore SYSLIB0013 + + StringBuilder? builder = null; + var unescapedStart = 0; + var escapeStart = -1; + + for (var i = 0; i < text.Length; i++) + { + if (IsAllowedInEscapedUrl(text[i])) + { + if (escapeStart >= 0) + { + builder ??= new StringBuilder(text.Length); + builder.Append(text, unescapedStart, escapeStart - unescapedStart); + builder.Append(Uri.EscapeDataString(text.Substring(escapeStart, i - escapeStart))); + unescapedStart = i; + escapeStart = -1; + } + } + else if (escapeStart < 0) + { + escapeStart = i; + } + } + + if (escapeStart >= 0) + { + builder ??= new StringBuilder(text.Length); + builder.Append(text, unescapedStart, escapeStart - unescapedStart); + builder.Append(Uri.EscapeDataString(text.Substring(escapeStart))); + } + else if (builder is not null) + { + builder.Append(text, unescapedStart, text.Length - unescapedStart); + } + + return builder?.ToString() ?? text; + } + + private static bool IsAllowedInEscapedUrl(char c) + { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) + { + return true; + } + + return c is '-' or '.' or '_' or '~' + // Reserved URL syntax characters that must be preserved when escaping a complete URL. + or ':' or '/' or '?' or '#' or '[' or ']' or '@' + or '!' or '$' or '&' or '\'' or '(' or ')' or '*' + or '+' or ',' or ';' or '='; } } } From 4ca0455a39d7bcafccb545420028ae014b1cc2aa Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Mon, 8 Jun 2026 19:48:58 +0200 Subject: [PATCH 10/11] Fix build warning --- src/Directory.Packages.props | 8 +++++--- src/Scriban.AsyncCodeGen/Scriban.AsyncCodeGen.csproj | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 6d698c1c..99fa3852 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -10,12 +10,13 @@ - - + + - + + @@ -30,6 +31,7 @@ + diff --git a/src/Scriban.AsyncCodeGen/Scriban.AsyncCodeGen.csproj b/src/Scriban.AsyncCodeGen/Scriban.AsyncCodeGen.csproj index 3d583eca..a78a566b 100644 --- a/src/Scriban.AsyncCodeGen/Scriban.AsyncCodeGen.csproj +++ b/src/Scriban.AsyncCodeGen/Scriban.AsyncCodeGen.csproj @@ -1,4 +1,4 @@ - + Exe @@ -14,9 +14,11 @@ + + From 8872919995b8baae3008c2d3be6f0fad04e812ac Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Mon, 22 Jun 2026 21:55:26 +0200 Subject: [PATCH 11/11] Allow nulls in nullable builtin functions --- site/docs/builtins/math.md | 4 +++- .../TestFiles/400-builtins/410-array.out.txt | 1 + .../TestFiles/400-builtins/410-array.txt | 1 + .../TestFiles/400-builtins/420-math.out.txt | 9 ++++++++- .../TestFiles/400-builtins/420-math.txt | 9 ++++++++- src/Scriban/Functions/ArrayFunctions.cs | 2 +- src/Scriban/Functions/MathFunctions.cs | 14 ++++++++------ 7 files changed, 30 insertions(+), 10 deletions(-) diff --git a/site/docs/builtins/math.md b/site/docs/builtins/math.md index b1870bb2..d51db0ca 100644 --- a/site/docs/builtins/math.md +++ b/site/docs/builtins/math.md @@ -197,15 +197,17 @@ Returns a boolean indicating if the input value is a number #### Examples -> **input** [Try out](/?template=%7B%7B%20255%20%7C%20math.is_number%20%7D%7D%0A%7B%7B%20%22yo%22%20%7C%20math.is_number%20%7D%7D&model=%7B%7D) +> **input** [Try out](/?template=%7B%7B%20255%20%7C%20math.is_number%20%7D%7D%0A%7B%7B%20%22yo%22%20%7C%20math.is_number%20%7D%7D%0A%7B%7B%20null%20%7C%20math.is_number%20%7D%7D&model=%7B%7D) ```scriban-html {{ "{{" }} 255 | math.is_number {{ "}}" }} {{ "{{" }} "yo" | math.is_number {{ "}}" }} +{{ "{{" }} null | math.is_number {{ "}}" }} ``` > **output** ```html true false +false ``` ### `math.minus` diff --git a/src/Scriban.Tests/TestFiles/400-builtins/410-array.out.txt b/src/Scriban.Tests/TestFiles/400-builtins/410-array.out.txt index b696581a..92f39c76 100644 --- a/src/Scriban.Tests/TestFiles/400-builtins/410-array.out.txt +++ b/src/Scriban.Tests/TestFiles/400-builtins/410-array.out.txt @@ -45,6 +45,7 @@ abcab defd Multiple cycle: adgbegcdgdeg +null cycle: Multiple cycle group: adbecdde [5,6,7,8] | array.limit 2: [5, 6] diff --git a/src/Scriban.Tests/TestFiles/400-builtins/410-array.txt b/src/Scriban.Tests/TestFiles/400-builtins/410-array.txt index becdf898..21c8ceec 100644 --- a/src/Scriban.Tests/TestFiles/400-builtins/410-array.txt +++ b/src/Scriban.Tests/TestFiles/400-builtins/410-array.txt @@ -57,6 +57,7 @@ Multiple cycle: array.cycle [] end }} +null cycle:{{ array.cycle null }} Multiple cycle group: {{for x in 1..4 array.cycle ["a", "b", "c", "d"] "group1" diff --git a/src/Scriban.Tests/TestFiles/400-builtins/420-math.out.txt b/src/Scriban.Tests/TestFiles/400-builtins/420-math.out.txt index 7381af40..e7fd1a7d 100644 --- a/src/Scriban.Tests/TestFiles/400-builtins/420-math.out.txt +++ b/src/Scriban.Tests/TestFiles/400-builtins/420-math.out.txt @@ -8,4 +8,11 @@ true true false -4.24 \ No newline at end of file +false +4.24 +null | math.plus 1: +1 | math.plus null: +null | math.minus 1: +1 | math.times null: +1 | math.divided_by null: +1 | math.modulo null: \ No newline at end of file diff --git a/src/Scriban.Tests/TestFiles/400-builtins/420-math.txt b/src/Scriban.Tests/TestFiles/400-builtins/420-math.txt index 021eda10..91564769 100644 --- a/src/Scriban.Tests/TestFiles/400-builtins/420-math.txt +++ b/src/Scriban.Tests/TestFiles/400-builtins/420-math.txt @@ -8,4 +8,11 @@ {{ 4.5 | math.is_number }} {{ 4 | math.is_number }} {{ "toto" | math.is_number }} -{{ 4.2435 | math.format "0.##" }} \ No newline at end of file +{{ null | math.is_number }} +{{ 4.2435 | math.format "0.##" }} +null | math.plus 1:{{ null | math.plus 1 }} +1 | math.plus null:{{ 1 | math.plus null }} +null | math.minus 1:{{ null | math.minus 1 }} +1 | math.times null:{{ 1 | math.times null }} +1 | math.divided_by null:{{ 1 | math.divided_by null }} +1 | math.modulo null:{{ 1 | math.modulo null }} \ No newline at end of file diff --git a/src/Scriban/Functions/ArrayFunctions.cs b/src/Scriban/Functions/ArrayFunctions.cs index 363c853a..542b65f4 100644 --- a/src/Scriban/Functions/ArrayFunctions.cs +++ b/src/Scriban/Functions/ArrayFunctions.cs @@ -178,7 +178,7 @@ public static IEnumerable Add(TemplateContext context, SourceSpan span, IEnumera /// `cycle` accepts a parameter called cycle group in cases where you need multiple cycle blocks in one template. /// If no name is supplied for the cycle group, then it is assumed that multiple calls with the same parameters are one group. /// - public static object? Cycle(TemplateContext context, SourceSpan span, IList list, object? group = null) + public static object? Cycle(TemplateContext context, SourceSpan span, IList? list, object? group = null) { if (list is null) { diff --git a/src/Scriban/Functions/MathFunctions.cs b/src/Scriban/Functions/MathFunctions.cs index c1230f64..ca62b0d9 100644 --- a/src/Scriban/Functions/MathFunctions.cs +++ b/src/Scriban/Functions/MathFunctions.cs @@ -129,7 +129,7 @@ public static double Ceil(double value) /// 4 /// ``` /// - public static object? DividedBy(TemplateContext context, SourceSpan span, double value, object divisor) + public static object? DividedBy(TemplateContext context, SourceSpan span, double value, object? divisor) { var result = ScriptBinaryExpression.Evaluate(context, span, ScriptBinaryOperator.Divide, value, divisor); @@ -209,13 +209,15 @@ public static string Format(TemplateContext context, SourceSpan span, object? va /// ```scriban-html /// {{ 255 | math.is_number }} /// {{ "yo" | math.is_number }} + /// {{ null | math.is_number }} /// ``` /// ```html /// true /// false + /// false /// ``` /// - public static bool IsNumber(object value) + public static bool IsNumber(object? value) { return value is sbyte || value is byte @@ -247,7 +249,7 @@ public static bool IsNumber(object value) /// 250 /// ``` /// - public static object? Minus(TemplateContext context, SourceSpan span, object value, object with) + public static object? Minus(TemplateContext context, SourceSpan span, object? value, object? with) { return ScriptBinaryExpression.Evaluate(context, span, ScriptBinaryOperator.Subtract, value, with); } @@ -268,7 +270,7 @@ public static bool IsNumber(object value) /// 1 /// ``` /// - public static object? Modulo(TemplateContext context, SourceSpan span, object value, object with) + public static object? Modulo(TemplateContext context, SourceSpan span, object? value, object? with) { return ScriptBinaryExpression.Evaluate(context, span, ScriptBinaryOperator.Modulus, value, with); } @@ -289,7 +291,7 @@ public static bool IsNumber(object value) /// 3 /// ``` /// - public static object? Plus(TemplateContext context, SourceSpan span, object value, object with) + public static object? Plus(TemplateContext context, SourceSpan span, object? value, object? with) { return ScriptBinaryExpression.Evaluate(context, span, ScriptBinaryOperator.Add, value, with); } @@ -333,7 +335,7 @@ public static double Round(double value, int precision = 0) /// 6 /// ``` /// - public static object? Times(TemplateContext context, SourceSpan span, object value, object with) + public static object? Times(TemplateContext context, SourceSpan span, object? value, object? with) { return ScriptBinaryExpression.Evaluate(context, span, ScriptBinaryOperator.Multiply, value, with); }