Skip to content

Commit 3791bb4

Browse files
committed
fix: add default lmitation on vulnerable template options
docs: improve comments chore: address warnings/info
1 parent 6404192 commit 3791bb4

6 files changed

Lines changed: 215 additions & 38 deletions

File tree

src/Transmitly.TemplateEngine.Scriban/ScribanOptions.cs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,63 @@
1515
using SB = Scriban;
1616
namespace Transmitly.TemplateEngine.Scriban
1717
{
18+
/// <summary>
19+
/// Configures how Scriban templates are parsed and rendered by the Transmitly template engine.
20+
/// </summary>
1821
public sealed class ScribanOptions
1922
{
23+
public const int DefaultExpressionDepthLimit = 250;
24+
public const int DefaultObjectRecursionLimit = 20;
25+
public const int DefaultLimitToString = 1024 * 1024;
26+
2027
/// <summary>
21-
/// Whether to force the use of the Liquid template parser. (Default=false)
28+
/// Gets or sets a value indicating whether templates should be parsed using Scriban's Liquid-compatible parser.
2229
/// </summary>
2330
public bool UseLiquidTemplates { get; set; }
31+
32+
/// <summary>
33+
/// Gets or sets a value indicating whether parse errors should throw a <see cref="ScribanTemplateEngineException"/> instead of returning <see langword="null"/>.
34+
/// </summary>
2435
public bool ThrowIfTemplateError { get; set; } = true;
36+
37+
/// <summary>
38+
/// Gets or sets the maximum nested expression depth allowed during parsing.
39+
/// Set to <see langword="null"/> to disable the limit.
40+
/// </summary>
41+
public int? ExpressionDepthLimit { get; set; } = DefaultExpressionDepthLimit;
42+
43+
/// <summary>
44+
/// Gets or sets the maximum recursion depth allowed when Scriban converts rendered objects to strings.
45+
/// Set to <c>0</c> to disable the limit.
46+
/// </summary>
47+
public int ObjectRecursionLimit { get; set; } = DefaultObjectRecursionLimit;
48+
49+
/// <summary>
50+
/// Gets or sets the maximum number of characters produced while Scriban converts rendered objects to strings.
51+
/// Set to <c>0</c> to disable the limit.
52+
/// </summary>
53+
public int LimitToString { get; set; } = DefaultLimitToString;
54+
55+
/// <summary>
56+
/// Gets or sets the lexer options used when tokenizing template content.
57+
/// When <see langword="null"/>, Scriban uses its default lexer behavior.
58+
/// </summary>
2559
public SB.Parsing.LexerOptions? LexerOptions { get; set; }
26-
public SB.Parsing.ParserOptions ParserOptions { get; set; }
60+
61+
/// <summary>
62+
/// Gets or sets the base parser options used when parsing templates.
63+
/// The engine still applies <see cref="ExpressionDepthLimit"/> when creating the effective parser options.
64+
/// </summary>
65+
public SB.Parsing.ParserOptions ParserOptions { get; set; } = new();
66+
67+
/// <summary>
68+
/// Gets or sets the delegate used to rename imported .NET members before they are exposed to templates.
69+
/// </summary>
2770
public SB.Runtime.MemberRenamerDelegate? MemberRenamer { get; set; }
71+
72+
/// <summary>
73+
/// Gets or sets the delegate used to filter which imported .NET members are exposed to templates.
74+
/// </summary>
2875
public SB.Runtime.MemberFilterDelegate? MemberFilterDelegate { get; set; }
2976
}
3077
}

src/Transmitly.TemplateEngine.Scriban/ScribanTemplateEngine.cs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
using System;
1616
using System.Threading.Tasks;
17+
using Scriban.Runtime;
1718
using Transmitly.Template.Configuration;
1819
using Transmitly.Util;
1920
using SB = Scriban;
@@ -36,22 +37,55 @@ internal sealed class ScribanTemplateEngine(ScribanOptions options) : ITemplateE
3637
if (template.HasErrors)
3738
{
3839
var messages = string.Join(Environment.NewLine, template.Messages);
39-
if (options.ThrowIfTemplateError)
40+
if (_options.ThrowIfTemplateError)
4041
{
4142
throw new ScribanTemplateEngineException($"Provided template has errors. Pipeline: '{context.PipelineIntent}'.{Environment.NewLine}{messages}");
4243
}
4344
System.Diagnostics.Debug.WriteLine($"{nameof(ScribanTemplateEngine)} {string.Join(";", messages)}");
4445
return null;
4546
}
46-
var result = await template.RenderAsync(model, _options.MemberRenamer, _options.MemberFilterDelegate);
47-
return result?.ToString();
47+
48+
var renderContext = CreateTemplateContext(model);
49+
try
50+
{
51+
return await template.RenderAsync(renderContext);
52+
}
53+
finally
54+
{
55+
renderContext.PopGlobal();
56+
}
57+
}
58+
59+
internal SB.Parsing.ParserOptions CreateParserOptions()
60+
{
61+
var parserOptions = _options.ParserOptions;
62+
parserOptions.ExpressionDepthLimit = _options.ExpressionDepthLimit;
63+
return parserOptions;
64+
}
65+
66+
internal SB.TemplateContext CreateTemplateContext(object? model)
67+
{
68+
var scriptObject = new ScriptObject();
69+
if (model != null)
70+
{
71+
scriptObject.Import(model, renamer: _options.MemberRenamer, filter: _options.MemberFilterDelegate);
72+
}
73+
74+
var renderContext = _options.UseLiquidTemplates ? new SB.LiquidTemplateContext() : new SB.TemplateContext();
75+
renderContext.MemberRenamer = _options.MemberRenamer;
76+
renderContext.MemberFilter = _options.MemberFilterDelegate;
77+
renderContext.ObjectRecursionLimit = _options.ObjectRecursionLimit;
78+
renderContext.LimitToString = _options.LimitToString;
79+
renderContext.PushGlobal(scriptObject);
80+
return renderContext;
4881
}
4982

5083
private SB.Template Parse(string? content)
5184
{
85+
var parserOptions = CreateParserOptions();
5286
if (_options.UseLiquidTemplates)
53-
return SB.Template.ParseLiquid(content, parserOptions: _options.ParserOptions, lexerOptions: _options.LexerOptions);
54-
return SB.Template.Parse(content, parserOptions: _options.ParserOptions, lexerOptions: _options.LexerOptions);
87+
return SB.Template.ParseLiquid(content, parserOptions: parserOptions, lexerOptions: _options.LexerOptions);
88+
return SB.Template.Parse(content, parserOptions: parserOptions, lexerOptions: _options.LexerOptions);
5589
}
5690

5791
}

src/Transmitly.TemplateEngine.Scriban/ScribanTemplateEngineException.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,40 @@
1313
// limitations under the License.
1414

1515
using System;
16+
#if NETFRAMEWORK
1617
using System.Runtime.Serialization;
18+
#endif
1719

1820
namespace Transmitly.TemplateEngine.Scriban
1921
{
22+
#if NETFRAMEWORK
2023
[Serializable]
24+
/// <summary>
25+
/// Represents an error raised by the Scriban template engine while validating or rendering template content.
26+
/// </summary>
2127
public sealed class ScribanTemplateEngineException : Exception
2228
{
2329
public ScribanTemplateEngineException(string message) : base(message)
2430
{
2531

2632
}
2733

28-
protected ScribanTemplateEngineException(SerializationInfo info, StreamingContext context) : base(info, context)
34+
private ScribanTemplateEngineException(SerializationInfo info, StreamingContext context) : base(info, context)
2935
{
3036

3137
}
38+
39+
public override void GetObjectData(SerializationInfo info, StreamingContext context)
40+
{
41+
base.GetObjectData(info, context);
42+
}
43+
}
44+
#else
45+
/// <summary>
46+
/// Represents an error raised by the Scriban template engine while validating or rendering template content.
47+
/// </summary>
48+
public sealed class ScribanTemplateEngineException(string message) : Exception(message)
49+
{
3250
}
51+
#endif
3352
}

src/Transmitly.TemplateEngine.Scriban/ScribanTemplateEngineExtensions.cs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,33 @@
1919

2020
namespace Transmitly
2121
{
22+
/// <summary>
23+
/// Extensions for registering and referencing the Scriban template engine within a Transmitly configuration.
24+
/// </summary>
2225
public static class ScribanTemplateEngineExtensions
2326
{
2427
private const string ScribanId = "Scriban";
2528

26-
29+
/// <summary>
30+
/// Gets the identifier used to reference the Scriban template engine configurations.
31+
/// </summary>
32+
/// <param name="templateEngines">The template engine identifier source.</param>
33+
/// <param name="providerId">An optional provider-specific suffix used to create a distinct engine identifier.</param>
34+
/// <returns>The resolved template engine identifier.</returns>
2735
public static string Scriban(this TemplateEngines templateEngines, string? providerId = null)
2836
{
2937
Guard.AgainstNull(templateEngines);
3038

3139
return templateEngines.GetId(ScribanId, providerId);
3240
}
3341

42+
/// <summary>
43+
/// Registers the Scriban template engine using the supplied configuration callback.
44+
/// </summary>
45+
/// <param name="templateConfiguration">The template configuration builder that will receive the engine registration.</param>
46+
/// <param name="options">A callback that configures <see cref="ScribanOptions"/> before registration.</param>
47+
/// <param name="templateEngineId">An optional explicit template engine identifier.</param>
48+
/// <returns>The parent communications client builder.</returns>
3449
public static CommunicationsClientBuilder AddScribanTemplateEngine(this TemplateConfigurationBuilder templateConfiguration, Action<ScribanOptions> options, string? templateEngineId = null)
3550
{
3651
Guard.AgainstNull(templateConfiguration);
@@ -41,16 +56,33 @@ public static CommunicationsClientBuilder AddScribanTemplateEngine(this Template
4156
return templateConfiguration.Add(new ScribanTemplateEngine(opts), Id.TemplateEngines.Scriban(templateEngineId));
4257
}
4358

59+
/// <summary>
60+
/// Registers the Scriban template engine using the default <see cref="ScribanOptions"/>.
61+
/// </summary>
62+
/// <param name="templateConfiguration">The template configuration builder that will receive the engine registration.</param>
63+
/// <param name="templateEngineId">An optional explicit template engine identifier.</param>
64+
/// <returns>The parent communications client builder.</returns>
4465
public static CommunicationsClientBuilder AddScribanTemplateEngine(this TemplateConfigurationBuilder templateConfiguration, string? templateEngineId = null)
4566
{
4667
return AddScribanTemplateEngine(templateConfiguration, (opts) => { }, templateEngineId);
4768
}
4869

70+
/// <summary>
71+
/// Registers the Scriban template engine on a communications client using the default <see cref="ScribanOptions"/>.
72+
/// </summary>
73+
/// <param name="communicationsClientBuilder">The communications client builder to configure.</param>
74+
/// <returns>The configured communications client builder.</returns>
4975
public static CommunicationsClientBuilder AddScribanTemplateEngine(this CommunicationsClientBuilder communicationsClientBuilder)
5076
{
5177
return AddScribanTemplateEngine(communicationsClientBuilder.TemplateEngine, (opts) => { });
5278
}
5379

80+
/// <summary>
81+
/// Registers the Scriban template engine on a communications client using the supplied configuration callback.
82+
/// </summary>
83+
/// <param name="communicationsClientBuilder">The communications client builder to configure.</param>
84+
/// <param name="options">A callback that configures <see cref="ScribanOptions"/> before registration.</param>
85+
/// <returns>The configured communications client builder.</returns>
5486
public static CommunicationsClientBuilder AddScribanTemplateEngine(this CommunicationsClientBuilder communicationsClientBuilder, Action<ScribanOptions> options)
5587
{
5688
return AddScribanTemplateEngine(communicationsClientBuilder.TemplateEngine, options, Id.TemplateEngines.Scriban());

tests/Transmitly.TemplateEngine.Scriban.Tests/TemplateEngineTests.cs

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ public async Task ShouldThrowWithInvalidTemplateByDefault()
7878
context.Setup(s => s.ContentModel).Returns(model.Object);
7979
var engine = new Scriban.ScribanTemplateEngine(new ScribanOptions { });
8080

81-
await Assert.ThrowsExceptionAsync<ScribanTemplateEngineException>(() => engine.RenderAsync(template.Object, context.Object));
81+
await Assert.ThrowsExactlyAsync<ScribanTemplateEngineException>(() => engine.RenderAsync(template.Object, context.Object));
8282
}
8383

8484
[TestMethod]
@@ -100,5 +100,77 @@ public async Task ShouldReturnNullWithInvalidtemplate()
100100

101101
Assert.IsNull(result);
102102
}
103+
104+
[TestMethod]
105+
public void ShouldApplySecureDefaultParserLimit()
106+
{
107+
var engine = new Scriban.ScribanTemplateEngine(new ScribanOptions());
108+
109+
var parserOptions = engine.CreateParserOptions();
110+
111+
Assert.AreEqual(ScribanOptions.DefaultExpressionDepthLimit, parserOptions.ExpressionDepthLimit);
112+
}
113+
114+
[TestMethod]
115+
public void ShouldApplySecureDefaultRenderLimits()
116+
{
117+
var engine = new Scriban.ScribanTemplateEngine(new ScribanOptions());
118+
var renderContext = engine.CreateTemplateContext(model: null);
119+
120+
try
121+
{
122+
Assert.AreEqual(ScribanOptions.DefaultObjectRecursionLimit, renderContext.ObjectRecursionLimit);
123+
Assert.AreEqual(ScribanOptions.DefaultLimitToString, renderContext.LimitToString);
124+
}
125+
finally
126+
{
127+
renderContext.PopGlobal();
128+
}
129+
}
130+
131+
[TestMethod]
132+
public async Task ShouldThrowWhenExpressionDepthLimitExceeded()
133+
{
134+
var nestedExpression = new string('(', ScribanOptions.DefaultExpressionDepthLimit + 1) + "1" + new string(')', ScribanOptions.DefaultExpressionDepthLimit + 1);
135+
var templateContent = $"{{{{ {nestedExpression} }}}}";
136+
var template = new Mock<IContentTemplateRegistration>();
137+
template.Setup(s => s.GetContentAsync(It.IsAny<IDispatchCommunicationContext>())).Returns(Task.FromResult<string?>(templateContent));
138+
var model = new Mock<IContentModel>();
139+
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
140+
model.Setup(s => s.Model).Returns(null);
141+
#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type.
142+
var context = new Mock<IDispatchCommunicationContext>();
143+
context.Setup(s => s.ContentModel).Returns(model.Object);
144+
var engine = new Scriban.ScribanTemplateEngine(new ScribanOptions());
145+
146+
var exception = await Assert.ThrowsExactlyAsync<ScribanTemplateEngineException>(() => engine.RenderAsync(template.Object, context.Object));
147+
148+
StringAssert.Contains(exception.Message, "errors");
149+
}
150+
151+
[TestMethod]
152+
public async Task ShouldThrowWhenObjectRecursionLimitExceeded()
153+
{
154+
var recursiveObject = new global::Scriban.Runtime.ScriptObject();
155+
recursiveObject["self"] = recursiveObject;
156+
var model = new global::Scriban.Runtime.ScriptObject
157+
{
158+
["a"] = recursiveObject
159+
};
160+
var engine = new Scriban.ScribanTemplateEngine(new ScribanOptions());
161+
var template = global::Scriban.Template.Parse("{{ a }}");
162+
var renderContext = engine.CreateTemplateContext(model);
163+
164+
try
165+
{
166+
var exception = await Assert.ThrowsExactlyAsync<global::Scriban.Syntax.ScriptRuntimeException>(async () => await template.RenderAsync(renderContext));
167+
168+
StringAssert.Contains(exception.Message, "deeply nested");
169+
}
170+
finally
171+
{
172+
renderContext.PopGlobal();
173+
}
174+
}
103175
}
104-
}
176+
}

tests/Transmitly.TemplateEngine.Scriban.Tests/TestPlatformIdentity.cs

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)