From b9def64ef1624f7b1114733c931ae62053082415 Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Fri, 19 Jun 2026 20:53:46 -0400 Subject: [PATCH] fix: HTML-encode single quote character by default (issue #546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the default TextEncoder from HtmlEncoderLegacy to HtmlEncoder so that single quotes ('), backticks (`), and equals signs (=) are HTML-encoded as ', `, and = respectively — matching handlebars.js behavior. Users who require the old unescaped behavior can opt in explicitly via HandlebarsConfiguration.TextEncoder = new HtmlEncoderLegacy(). Co-Authored-By: Claude Sonnet 4.6 --- .../HandlebarsSpecCoverageTests.cs | 61 +++++++++---------- source/Handlebars.Test/IssueTests.cs | 31 ++++++++++ .../Configuration/HandlebarsConfiguration.cs | 2 +- 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs b/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs index 018f75e7..014c03eb 100644 --- a/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs +++ b/source/Handlebars.Test/HandlebarsSpecCoverageTests.cs @@ -23,74 +23,69 @@ public class HandlebarsSpecCoverageTests // 1. HTML ENCODING EDGE CASES // ───────────────────────────────────────────────────────────── - // [SPEC GAP] The default encoder (HtmlEncoderLegacy) does not encode ', `, or =. - // SPEC: Handlebars.js encodes &, <, >, ", ', `, and = for XSS safety. - // CURRENT: HtmlEncoderLegacy (set as HandlebarsConfiguration default) omits ', `, and =. - // HtmlEncoder (opt-in via HandlebarsConfiguration.TextEncoder) encodes all 7. - // SOURCE: HtmlEncoderLegacy.cs — the comment there states the omissions are intentional. - // HandlebarsConfiguration.cs line 91 sets the default to HtmlEncoderLegacy. - // COMPAT: HIGH — switching the default would break any code that embeds ' ` = in - // templates and expects them to pass through unescaped. A major version bump - // (or an opt-out flag) would be required to change this default safely. + // The default encoder (HtmlEncoder) encodes all 7 characters per Handlebars.js spec: + // &, <, >, ", ', `, and =. This was fixed in issue #546 — previously the default was + // HtmlEncoderLegacy which omitted ', `, and =. Users who need legacy behavior can + // configure TextEncoder = new HtmlEncoderLegacy() explicitly. [Fact] - public void HtmlEncoding_SingleQuote_DefaultEncoderGap() + public void HtmlEncoding_SingleQuote_DefaultEncoderSpec() { - // Gap test: default HtmlEncoderLegacy does NOT encode single quotes + // Default HtmlEncoder encodes single quotes per Handlebars.js spec (issue #546) var hbs = Handlebars.Create(); - Assert.Equal("it's", hbs.Compile("{{val}}")(new { val = "it's" })); + Assert.Equal("it's", hbs.Compile("{{val}}")(new { val = "it's" })); } [Fact] - public void HtmlEncoding_SingleQuote_FullEncoderSpec() + public void HtmlEncoding_SingleQuote_LegacyEncoderPassesThrough() { - // Spec-compliant behavior available via opt-in HtmlEncoder - var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoder() }); - Assert.Equal("it's", hbs.Compile("{{val}}")(new { val = "it's" })); + // Legacy encoder does NOT encode single quotes (opt-in for backward compatibility) + var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoderLegacy() }); + Assert.Equal("it's", hbs.Compile("{{val}}")(new { val = "it's" })); } [Fact] - public void HtmlEncoding_Backtick_DefaultEncoderGap() + public void HtmlEncoding_Backtick_DefaultEncoderSpec() { var hbs = Handlebars.Create(); - Assert.Equal("a`b", hbs.Compile("{{val}}")(new { val = "a`b" })); + Assert.Equal("a`b", hbs.Compile("{{val}}")(new { val = "a`b" })); } [Fact] - public void HtmlEncoding_Backtick_FullEncoderSpec() + public void HtmlEncoding_Backtick_LegacyEncoderPassesThrough() { - var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoder() }); - Assert.Equal("a`b", hbs.Compile("{{val}}")(new { val = "a`b" })); + var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoderLegacy() }); + Assert.Equal("a`b", hbs.Compile("{{val}}")(new { val = "a`b" })); } [Fact] - public void HtmlEncoding_Equals_DefaultEncoderGap() + public void HtmlEncoding_Equals_DefaultEncoderSpec() { var hbs = Handlebars.Create(); - Assert.Equal("a=b", hbs.Compile("{{val}}")(new { val = "a=b" })); + Assert.Equal("a=b", hbs.Compile("{{val}}")(new { val = "a=b" })); } [Fact] - public void HtmlEncoding_Equals_FullEncoderSpec() + public void HtmlEncoding_Equals_LegacyEncoderPassesThrough() { - var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoder() }); - Assert.Equal("a=b", hbs.Compile("{{val}}")(new { val = "a=b" })); + var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoderLegacy() }); + Assert.Equal("a=b", hbs.Compile("{{val}}")(new { val = "a=b" })); } [Fact] - public void HtmlEncoding_AllSpecialCharsAtOnce_DefaultEncoderGap() + public void HtmlEncoding_AllSpecialCharsAtOnce_DefaultEncoderSpec() { - // Default encoder: &, <, >, " are encoded; ', `, = are not + // Default HtmlEncoder encodes all 7 characters per Handlebars.js spec (issue #546) var hbs = Handlebars.Create(); - Assert.Equal("&<>"'`=", hbs.Compile("{{val}}")(new { val = "&<>\"'`=" })); + Assert.Equal("&<>"'`=", hbs.Compile("{{val}}")(new { val = "&<>\"'`=" })); } [Fact] - public void HtmlEncoding_AllSpecialCharsAtOnce_FullEncoderSpec() + public void HtmlEncoding_AllSpecialCharsAtOnce_LegacyEncoder() { - // Opt-in HtmlEncoder encodes all 7 characters per Handlebars.js spec - var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoder() }); - Assert.Equal("&<>"'`=", hbs.Compile("{{val}}")(new { val = "&<>\"'`=" })); + // Legacy encoder: &, <, >, " are encoded; ', `, = are not + var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoderLegacy() }); + Assert.Equal("&<>"'`=", hbs.Compile("{{val}}")(new { val = "&<>\"'`=" })); } [Theory, ClassData(typeof(HandlebarsEnvGenerator))] diff --git a/source/Handlebars.Test/IssueTests.cs b/source/Handlebars.Test/IssueTests.cs index 31136117..adab09c3 100644 --- a/source/Handlebars.Test/IssueTests.cs +++ b/source/Handlebars.Test/IssueTests.cs @@ -733,5 +733,36 @@ public void UnrecognisedExpressionThrowsOutOfMemoryException() Assert.Throws(()=> Handlebars.Compile(source)); } + + // Issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/546 + // Single quote should be HTML-encoded as ' in standard {{expression}} output + [Fact] + public void Issue546_SingleQuoteIsHtmlEncoded() + { + var handlebars = Handlebars.Create(); + var render = handlebars.Compile("{{input}}"); + object data = new { input = "test'1" }; + var actual = render(data); + + Assert.DoesNotContain("'", actual); + Assert.Contains("'", actual); + } + + [Theory] + [InlineData("&", "&")] + [InlineData("<", "<")] + [InlineData(">", ">")] + [InlineData("\"", """)] + [InlineData("'", "'")] + [InlineData("`", "`")] + [InlineData("=", "=")] + public void Issue546_HtmlSpecialCharsAreEncoded(string input, string expected) + { + var handlebars = Handlebars.Create(); + var render = handlebars.Compile("{{value}}"); + var actual = render(new { value = input }); + + Assert.Equal(expected, actual); + } } } \ No newline at end of file diff --git a/source/Handlebars/Configuration/HandlebarsConfiguration.cs b/source/Handlebars/Configuration/HandlebarsConfiguration.cs index 9342e067..f2668ed9 100644 --- a/source/Handlebars/Configuration/HandlebarsConfiguration.cs +++ b/source/Handlebars/Configuration/HandlebarsConfiguration.cs @@ -88,7 +88,7 @@ public HandlebarsConfiguration() RegisteredTemplates = new ObservableIndex, StringEqualityComparer>(stringEqualityComparer); HelperResolvers = new ObservableList(); - TextEncoder = new HtmlEncoderLegacy(); + TextEncoder = new HtmlEncoder(); FormatterProviders.Add(_undefinedFormatter); } }