diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 2866c977..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,13 +0,0 @@ -# These are supported funding model platforms - -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] -patreon: # Replace with a single Patreon username -open_collective: # Replace with a single Open Collective username -ko_fi: # Replace with a single Ko-fi username -tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -liberapay: # Replace with a single Liberapay username -issuehunt: Handlebars-Net/Handlebars.Net # Replace with a single IssueHunt username -otechie: # Replace with a single Otechie username -lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry -custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48139aee..73c6e280 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,8 @@ jobs: dotnet-version: 3.1.x - uses: actions/setup-java@v4 with: - java-version: '17' # The JDK version to make available on the path. + java-version: '21' # The JDK version to make available on the path. + distribution: 'zulu' - name: Clean package cache as a temporary workaround for https://github.com/actions/setup-dotnet/issues/155 working-directory: ./source run: dotnet clean -c Release && dotnet nuget locals all --clear @@ -144,4 +145,4 @@ jobs: steps: - uses: release-drafter/release-drafter@v5 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 97d02eb2..3ad79439 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -60,9 +60,10 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: 3.1.x - - uses: actions/setup-java@v1 + - uses: actions/setup-java@v4 with: - java-version: '17' # The JDK version to make available on the path. + java-version: '21' # The JDK version to make available on the path. + distribution: 'zulu' - name: Clean package cache as a temporary workaround for https://github.com/actions/setup-dotnet/issues/155 working-directory: ./source run: dotnet clean -c Release && dotnet nuget locals all --clear @@ -136,4 +137,4 @@ jobs: uses: actions/upload-artifact@v2 with: name: Benchmark - path: source/Handlebars.Benchmark/BenchmarkDotNet.Artifacts/results/ \ No newline at end of file + path: source/Handlebars.Benchmark/BenchmarkDotNet.Artifacts/results/ diff --git a/source/Handlebars.Test/ExceptionTests.cs b/source/Handlebars.Test/ExceptionTests.cs index 434dc999..0adea91a 100644 --- a/source/Handlebars.Test/ExceptionTests.cs +++ b/source/Handlebars.Test/ExceptionTests.cs @@ -57,5 +57,14 @@ public void TestLooseClosingBlockInIteratorExpressionException() Handlebars.Compile("{{#each enumerateMe}}test{{/if}}{{/each}}")(data); }); } + + [Fact] + public void TestNonClosingIgnoreBlockException() + { + Assert.Throws(() => + { + Handlebars.Compile("{{ [test }}")(new { }); + }); + } } } diff --git a/source/Handlebars.Test/InlinePartialTests.cs b/source/Handlebars.Test/InlinePartialTests.cs index 3e6aa4ec..7d4a019f 100644 --- a/source/Handlebars.Test/InlinePartialTests.cs +++ b/source/Handlebars.Test/InlinePartialTests.cs @@ -1,4 +1,5 @@ -using Xunit; +using System.Collections.Generic; +using Xunit; namespace HandlebarsDotNet.Test { @@ -355,6 +356,63 @@ public void InlinePartialInEach() var result = template(data); Assert.Equal("12", result); } + + [Fact] + public void RecursionUnboundedInlinePartial() + { + string source = "{{#*inline \"list\"}}{{>list}}{{/inline}}{{>list}}"; + + var template = Handlebars.Compile(source); + + string Result() => template(null); + var ex = Assert.Throws(Result); + while (ex.InnerException != null) + ex = Assert.IsType(ex.InnerException); + Assert.Equal("Runtime error while rendering partial 'list', exceeded recursion depth limit of 100", ex.Message); + } + + [Fact] + public void RecursionBoundedToLimitInlinePartial() + { + string source = "{{#*inline \"list\"}}x{{#each items}}{{#if items}}{{>list}}{{/if}}{{/each}}{{/inline}}{{>list}}{{>list}}"; + + var template = Handlebars.Compile(source); + + var data = new Dictionary(); + var items = data; + for (var depth = 0; depth < Handlebars.Configuration.PartialRecursionDepthLimit; depth++) + { + var nestedItems = new Dictionary(); + items.Add("items", new[] { nestedItems }); + items = nestedItems; + } + + var result = template(data); + Assert.Equal(new string('x', Handlebars.Configuration.PartialRecursionDepthLimit * 2), result); + } + + [Fact] + public void RecursionBoundedAboveLimitInlinePartial() + { + string source = "{{#*inline \"list\"}}x{{#each items}}{{#if items}}{{>list}}{{/if}}{{/each}}{{/inline}}{{>list}}"; + + var template = Handlebars.Compile(source); + + var data = new Dictionary(); + var items = data; + for (var depth = 0; depth < Handlebars.Configuration.PartialRecursionDepthLimit + 1; depth++) + { + var nestedItems = new Dictionary(); + items.Add("items", new[] { nestedItems }); + items = nestedItems; + } + + string Result() => template(data); + var ex = Assert.Throws(Result); + while (ex.InnerException != null) + ex = Assert.IsType(ex.InnerException); + Assert.Equal("Runtime error while rendering partial 'list', exceeded recursion depth limit of 100", ex.Message); + } } } diff --git a/source/Handlebars.Test/PartialTests.cs b/source/Handlebars.Test/PartialTests.cs index 4a0784db..bbd8e896 100644 --- a/source/Handlebars.Test/PartialTests.cs +++ b/source/Handlebars.Test/PartialTests.cs @@ -660,6 +660,30 @@ public void BlockPartialWithNestedSpecialNamedPartial2() Assert.Equal("A 1 B 3 C 4 D 2 E", result); } + [Fact] + public void RecursionUnboundedBlockPartialWithSpecialNamedPartial() + { + string source = "{{#>myPartial}}{{>myPartial}}{{/myPartial}}"; + + var template = Handlebars.Compile(source); + + var partialSource = "{{> @partial-block }}"; + using (var reader = new StringReader(partialSource)) + { + var partialTemplate = Handlebars.Compile(reader); + Handlebars.RegisterTemplate("myPartial", partialTemplate); + } + + string Result() => template(null); + var ex = Assert.Throws(Result); + Assert.Equal("Runtime error while rendering partial 'myPartial', see inner exception for more information", ex.Message); + ex = Assert.IsType(ex.InnerException); + Assert.Equal("Runtime error while rendering partial 'myPartial', see inner exception for more information", ex.Message); + ex = Assert.IsType(ex.InnerException); + Assert.Equal("Referenced partial name @partial-block could not be resolved", ex.Message); + Assert.Null(ex.InnerException); + } + [Fact] public void TemplateWithSpecialNamedPartial() { @@ -717,6 +741,84 @@ public void SubExpressionPartial() var result = template(data); Assert.Equal("Hello, world!", result); } + + [Fact] + public void RecursionUnboundedPartial() + { + string source = "{{>list}}"; + + var template = Handlebars.Compile(source); + + var partialSource = "{{>list}}"; + using (var reader = new StringReader(partialSource)) + { + var partialTemplate = Handlebars.Compile(reader); + Handlebars.RegisterTemplate("list", partialTemplate); + } + + string Result() => template(null); + var ex = Assert.Throws(Result); + while (ex.InnerException != null) + ex = Assert.IsType(ex.InnerException); + Assert.Equal("Runtime error while rendering partial 'list', exceeded recursion depth limit of 100", ex.Message); + } + + [Fact] + public void RecursionBoundedToLimitPartial() + { + string source = "{{>list}}{{>list}}"; + + var template = Handlebars.Compile(source); + + var partialSource = "x{{#each items}}{{#if items}}{{>list}}{{/if}}{{/each}}"; + using (var reader = new StringReader(partialSource)) + { + var partialTemplate = Handlebars.Compile(reader); + Handlebars.RegisterTemplate("list", partialTemplate); + } + + var data = new Dictionary(); + var items = data; + for (var depth = 0; depth < Handlebars.Configuration.PartialRecursionDepthLimit; depth++) + { + var nestedItems = new Dictionary(); + items.Add("items", new[] { nestedItems }); + items = nestedItems; + } + + var result = template(data); + Assert.Equal(new string('x', Handlebars.Configuration.PartialRecursionDepthLimit * 2), result); + } + + [Fact] + public void RecursionBoundedAboveLimitPartial() + { + string source = "{{>list}}"; + + var template = Handlebars.Compile(source); + + var partialSource = "x{{#each items}}{{#if items}}{{>list}}{{/if}}{{/each}}"; + using (var reader = new StringReader(partialSource)) + { + var partialTemplate = Handlebars.Compile(reader); + Handlebars.RegisterTemplate("list", partialTemplate); + } + + var data = new Dictionary(); + var items = data; + for (var depth = 0; depth < Handlebars.Configuration.PartialRecursionDepthLimit + 1; depth++) + { + var nestedItems = new Dictionary(); + items.Add("items", new[] { nestedItems }); + items = nestedItems; + } + + string Result() => template(data); + var ex = Assert.Throws(Result); + while (ex.InnerException != null) + ex = Assert.IsType(ex.InnerException); + Assert.Equal("Runtime error while rendering partial 'list', exceeded recursion depth limit of 100", ex.Message); + } } } diff --git a/source/Handlebars.Test/PathInfoTests.cs b/source/Handlebars.Test/PathInfoTests.cs index f55b154a..9eb9fb06 100644 --- a/source/Handlebars.Test/PathInfoTests.cs +++ b/source/Handlebars.Test/PathInfoTests.cs @@ -50,6 +50,9 @@ public void DotPath() [InlineData("a/[b.c].[b/c]/d", new [] {"a", "[b.c].[b/c]", "d"})] [InlineData("a/[b/c]/d", new [] {"a", "[b/c]", "d"})] [InlineData("a/[b.c/d]/e", new [] {"a", "[b.c/d]", "e"})] + [InlineData("a/[b.c/d/e/f]/e", new [] {"a", "[b.c/d/e/f]", "e"})] + [InlineData("a/[a//b/c.d/e]/e", new [] {"a", "[a//b/c.d/e]", "e"})] + [InlineData("a/[b/c/d].[e/f/g]/h", new [] {"a", "[b/c/d].[e/f/g]", "h"})] public void SlashPath(string input, string[] expected) { var pathInfo = PathInfo.Parse(input); @@ -62,4 +65,4 @@ public void SlashPath(string input, string[] expected) } } } -} \ No newline at end of file +} diff --git a/source/Handlebars/BindingContext.cs b/source/Handlebars/BindingContext.cs index 089cf276..2f1e6c24 100644 --- a/source/Handlebars/BindingContext.cs +++ b/source/Handlebars/BindingContext.cs @@ -117,6 +117,8 @@ out WellKnownVariables[(int) WellKnownVariable.Parent] internal TemplateDelegate PartialBlockTemplate { get; set; } + internal short PartialDepth { get; set; } + public object Value { get; set; } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/source/Handlebars/Compiler/Lexer/Parsers/WordParser.cs b/source/Handlebars/Compiler/Lexer/Parsers/WordParser.cs index 32800c64..64974024 100644 --- a/source/Handlebars/Compiler/Lexer/Parsers/WordParser.cs +++ b/source/Handlebars/Compiler/Lexer/Parsers/WordParser.cs @@ -44,16 +44,7 @@ private static string AccumulateWord(ExtendedStringReader reader) while (true) { - if (isEscaped) - { - var c = (char) reader.Read(); - if (c == ']') isEscaped = false; - - buffer.Append(c); - continue; - } - - if (!inString) + if (!inString && !isEscaped) { var peek = (char) reader.Peek(); @@ -70,6 +61,15 @@ private static string AccumulateWord(ExtendedStringReader reader) throw new HandlebarsParserException("Reached end of template before the expression was closed.", reader.GetContext()); } + if (isEscaped) + { + var c = (char) node; + if (c == ']') isEscaped = false; + + buffer.Append(c); + continue; + } + if (node == '[' && !inString) { isEscaped = true; diff --git a/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs b/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs index 247e9c58..57467832 100644 --- a/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs +++ b/source/Handlebars/Compiler/Translation/Expression/PartialBinder.cs @@ -133,10 +133,24 @@ private static bool InvokePartial( return true; } + void IncreaseDepth() + { + if (++context.PartialDepth > configuration.PartialRecursionDepthLimit) + throw new HandlebarsRuntimeException($"Runtime error while rendering partial '{partialName}', exceeded recursion depth limit of {configuration.PartialRecursionDepthLimit}"); + } + //if we have an inline partial, skip the file system and RegisteredTemplates collection if (context.InlinePartialTemplates.TryGetValue(partialName, out var partial)) { - partial(writer, context); + IncreaseDepth(); + try + { + partial(writer, context); + } + finally + { + context.PartialDepth--; + } return true; } @@ -152,6 +166,7 @@ private static bool InvokePartial( } } + IncreaseDepth(); try { using var textWriter = writer.CreateWrapper(); @@ -162,6 +177,10 @@ private static bool InvokePartial( { throw new HandlebarsRuntimeException($"Runtime error while rendering partial '{partialName}', see inner exception for more information", exception); } + finally + { + context.PartialDepth--; + } } } } diff --git a/source/Handlebars/Configuration/HandlebarsConfiguration.cs b/source/Handlebars/Configuration/HandlebarsConfiguration.cs index c7d4d09a..9342e067 100644 --- a/source/Handlebars/Configuration/HandlebarsConfiguration.cs +++ b/source/Handlebars/Configuration/HandlebarsConfiguration.cs @@ -59,6 +59,11 @@ public string UnresolvedBindingFormatter /// public IMissingPartialTemplateHandler MissingPartialTemplateHandler { get; set; } + /// + /// Maximum depth to recurse into partial templates when evaluating the template. Defaults to 100. + /// + public short PartialRecursionDepthLimit { get; set; } = 100; + /// public IAppendOnlyList AliasProviders { get; } = new ObservableList(); diff --git a/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs b/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs index 94f5ff61..d589c4c2 100644 --- a/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs +++ b/source/Handlebars/Configuration/HandlebarsConfigurationAdapter.cs @@ -63,6 +63,7 @@ public HandlebarsConfigurationAdapter(HandlebarsConfiguration configuration) public bool ThrowOnUnresolvedBindingExpression => UnderlingConfiguration.ThrowOnUnresolvedBindingExpression; public IPartialTemplateResolver PartialTemplateResolver => UnderlingConfiguration.PartialTemplateResolver; public IMissingPartialTemplateHandler MissingPartialTemplateHandler => UnderlingConfiguration.MissingPartialTemplateHandler; + public short PartialRecursionDepthLimit => UnderlingConfiguration.PartialRecursionDepthLimit; public Compatibility Compatibility => UnderlingConfiguration.Compatibility; public bool NoEscape => UnderlingConfiguration.NoEscape; diff --git a/source/Handlebars/Configuration/ICompiledHandlebarsConfiguration.cs b/source/Handlebars/Configuration/ICompiledHandlebarsConfiguration.cs index 600e5f21..6434effa 100644 --- a/source/Handlebars/Configuration/ICompiledHandlebarsConfiguration.cs +++ b/source/Handlebars/Configuration/ICompiledHandlebarsConfiguration.cs @@ -36,6 +36,8 @@ public interface ICompiledHandlebarsConfiguration : IHandlebarsTemplateRegistrat IMissingPartialTemplateHandler MissingPartialTemplateHandler { get; } + short PartialRecursionDepthLimit { get; } + IIndexed>> Helpers { get; } IIndexed>> BlockHelpers { get; } diff --git a/source/Handlebars/PathStructure/PathInfo.cs b/source/Handlebars/PathStructure/PathInfo.cs index 06f08041..6acbfba8 100644 --- a/source/Handlebars/PathStructure/PathInfo.cs +++ b/source/Handlebars/PathStructure/PathInfo.cs @@ -148,6 +148,7 @@ public static PathInfo Parse(string path) var extendedEnumerator = ExtendedEnumerator.Create(pathParts); using var container = StringBuilderPool.Shared.Use(); var buffer = container.Value; + var bufferHasOpenEscapeBlock = false; while (extendedEnumerator.MoveNext()) { @@ -159,6 +160,7 @@ public static PathInfo Parse(string path) if(Substring.LastIndexOf(segment, ']', out var index) && !Substring.LastIndexOf(segment, '[', index, out _)) { + bufferHasOpenEscapeBlock = false; var chainSegment = GetPathChain(buffer.ToString()); if (chainSegment.Length > 1) isValidHelperLiteral = false; @@ -171,7 +173,8 @@ public static PathInfo Parse(string path) if(Substring.LastIndexOf(segment, '[', out var startIndex) && !Substring.LastIndexOf(segment, ']', startIndex, out _)) { - buffer.Append(in segment); + if (!bufferHasOpenEscapeBlock) buffer.Append(in segment); + bufferHasOpenEscapeBlock = true; continue; } @@ -192,7 +195,7 @@ public static PathInfo Parse(string path) if (chainSegments.Length > 1 && pathType != PathType.BlockHelper) isValidHelperLiteral = false; - segments.Add(new PathSegment(segment, chainSegments)); + if (!bufferHasOpenEscapeBlock) segments.Add(new PathSegment(segment, chainSegments)); } if (isValidHelperLiteral && segments.Count > 1) isValidHelperLiteral = false; @@ -252,4 +255,4 @@ private static PathType GetPathType(string path) }; } } -} \ No newline at end of file +} diff --git a/source/Handlebars/Pools/BindingContext.Pool.cs b/source/Handlebars/Pools/BindingContext.Pool.cs index a5036505..8fc6a999 100644 --- a/source/Handlebars/Pools/BindingContext.Pool.cs +++ b/source/Handlebars/Pools/BindingContext.Pool.cs @@ -33,6 +33,7 @@ public BindingContext CreateContext(ICompiledHandlebarsConfiguration configurati context.Value = value; context.ParentContext = parent; context.PartialBlockTemplate = partialBlockTemplate; + context.PartialDepth = parent?.PartialDepth ?? 0; context.Initialize();