Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 28 additions & 33 deletions source/Handlebars.Test/HandlebarsSpecCoverageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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&#x27;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&#x27;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&#x60;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&#x60;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&#x3D;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&#x3D;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("&amp;&lt;&gt;&quot;'`=", hbs.Compile("{{val}}")(new { val = "&<>\"'`=" }));
Assert.Equal("&amp;&lt;&gt;&quot;&#x27;&#x60;&#x3D;", 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("&amp;&lt;&gt;&quot;&#x27;&#x60;&#x3D;", hbs.Compile("{{val}}")(new { val = "&<>\"'`=" }));
// Legacy encoder: &, <, >, " are encoded; ', `, = are not
var hbs = Handlebars.Create(new HandlebarsConfiguration { TextEncoder = new HtmlEncoderLegacy() });
Assert.Equal("&amp;&lt;&gt;&quot;'`=", hbs.Compile("{{val}}")(new { val = "&<>\"'`=" }));
}

[Theory, ClassData(typeof(HandlebarsEnvGenerator))]
Expand Down
31 changes: 31 additions & 0 deletions source/Handlebars.Test/IssueTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,37 @@ public void UnrecognisedExpressionThrowsOutOfMemoryException()
Assert.Throws<HandlebarsCompilerException>(()=> Handlebars.Compile(source));
}

// Issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/546
// Single quote should be HTML-encoded as &#x27; 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("&#x27;", actual);
}

[Theory]
[InlineData("&", "&amp;")]
[InlineData("<", "&lt;")]
[InlineData(">", "&gt;")]
[InlineData("\"", "&quot;")]
[InlineData("'", "&#x27;")]
[InlineData("`", "&#x60;")]
[InlineData("=", "&#x3D;")]
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);
}

// Issue: https://github.com/Handlebars-Net/Handlebars.Net/issues/462
// Compile replaces \\ (double backslash) with \ (single backslash)
[Fact]
Expand Down
2 changes: 1 addition & 1 deletion source/Handlebars/Configuration/HandlebarsConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@

public ViewEngineFileSystem FileSystem { get; set; }

[Obsolete("Register custom formatters using `Formatters` property")]

Check warning on line 40 in source/Handlebars/Configuration/HandlebarsConfiguration.cs

View workflow job for this annotation

GitHub Actions / SonarCloud

Do not forget to remove this deprecated code someday.
public string UnresolvedBindingFormatter
{
get => _undefinedFormatter.FormatString;
Expand Down Expand Up @@ -88,7 +88,7 @@
RegisteredTemplates = new ObservableIndex<string, HandlebarsTemplate<TextWriter, object, object>, StringEqualityComparer>(stringEqualityComparer);

HelperResolvers = new ObservableList<IHelperResolver>();
TextEncoder = new HtmlEncoderLegacy();
TextEncoder = new HtmlEncoder();
FormatterProviders.Add(_undefinedFormatter);
}
}
Expand Down
Loading