Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
59 changes: 56 additions & 3 deletions src/Core/Platform/Mailer/HandlebarMailRenderer.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#nullable enable
using System.Collections.Concurrent;
using System.Reflection;
using Bit.Core.Settings;
using HandlebarsDotNet;
using Microsoft.Extensions.Logging;

namespace Bit.Core.Platform.Mailer;

Expand All @@ -10,7 +12,7 @@ public class HandlebarMailRenderer : IMailRenderer
/// <summary>
/// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once.
/// </summary>
private readonly Lazy<Task<IHandlebars>> _handlebarsTask = new(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication);
private readonly Lazy<Task<IHandlebars>> _handlebarsTask;

/// <summary>
/// Helper function that returns the handlebar instance.
Expand All @@ -22,6 +24,17 @@ public class HandlebarMailRenderer : IMailRenderer
/// </summary>
private readonly ConcurrentDictionary<string, Lazy<Task<HandlebarsTemplate<object, object>>>> _templateCache = new();

private readonly ILogger<HandlebarMailRenderer> _logger;
private readonly GlobalSettings _globalSettings;

public HandlebarMailRenderer(ILogger<HandlebarMailRenderer> logger, GlobalSettings globalSettings)
{
_logger = logger;
_globalSettings = globalSettings;

_handlebarsTask = new Lazy<Task<IHandlebars>>(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication);
}

public async Task<(string html, string txt)> RenderAsync(BaseMailView model)
{
var html = await CompileTemplateAsync(model, "html");
Expand Down Expand Up @@ -54,19 +67,59 @@ private async Task<HandlebarsTemplate<object, object>> CompileTemplateInternalAs
return handlebars.Compile(source);
}

private static async Task<string> ReadSourceAsync(Assembly assembly, string template)
private async Task<string> ReadSourceAsync(Assembly assembly, string template)
{
if (assembly.GetManifestResourceNames().All(f => f != template))
{
throw new FileNotFoundException("Template not found: " + template);
}

var diskSource = await ReadSourceFromDiskAsync(template);
if (!string.IsNullOrWhiteSpace(diskSource))
{
return diskSource;
}

await using var s = assembly.GetManifestResourceStream(template)!;
using var sr = new StreamReader(s);
return await sr.ReadToEndAsync();
}

private static async Task<IHandlebars> InitializeHandlebarsAsync()
private async Task<string?> ReadSourceFromDiskAsync(string template)
{
if (!_globalSettings.SelfHosted)
{
return null;
}

try
{
var diskPath = Path.GetFullPath(Path.Combine(_globalSettings.MailTemplateDirectory, template));
var baseDirectory = Path.GetFullPath(_globalSettings.MailTemplateDirectory);

// Ensure the resolved path is within the configured directory
if (!diskPath.StartsWith(baseDirectory + Path.DirectorySeparatorChar) &&
diskPath != baseDirectory)
{
_logger.LogWarning("Template path traversal attempt detected: {Template}", template);
return null;
}

if (File.Exists(diskPath))
{
var fileContents = await File.ReadAllTextAsync(diskPath);
return fileContents;
}
}
catch (Exception e)
{
_logger.LogError(e, "Failed to read mail template from disk: {TemplateName}", template);
}

return null;
}

private async Task<IHandlebars> InitializeHandlebarsAsync()
{
var handlebars = Handlebars.Create();

Expand Down
47 changes: 46 additions & 1 deletion test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using Bit.Core.Platform.Mailer;
using Bit.Core.Settings;
using Bit.Core.Test.Platform.Mailer.TestMail;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;

namespace Bit.Core.Test.Platform.Mailer;
Expand All @@ -9,12 +12,54 @@ public class HandlebarMailRendererTests
[Fact]
public async Task RenderAsync_ReturnsExpectedHtmlAndTxt()
{
var renderer = new HandlebarMailRenderer();
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var globalSettings = new GlobalSettings { SelfHosted = false };
var renderer = new HandlebarMailRenderer(logger, globalSettings);

var view = new TestMailView { Name = "John Smith" };

var (html, txt) = await renderer.RenderAsync(view);

Assert.Equal("Hello <b>John Smith</b>", html.Trim());
Assert.Equal("Hello John Smith", txt.Trim());
}

[Fact]
public async Task RenderAsync_LoadsFromDisk_WhenSelfHostedAndFileExists()
{
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);

try
{
var globalSettings = new GlobalSettings
{
SelfHosted = true,
MailTemplateDirectory = tempDir
};

// Create test template files on disk
var htmlTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.html.hbs");
var txtTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.text.hbs");
await File.WriteAllTextAsync(htmlTemplatePath, "Custom HTML: <b>{{Name}}</b>");
await File.WriteAllTextAsync(txtTemplatePath, "Custom TXT: {{Name}}");

var renderer = new HandlebarMailRenderer(logger, globalSettings);
var view = new TestMailView { Name = "Jane Doe" };

var (html, txt) = await renderer.RenderAsync(view);

Assert.Equal("Custom HTML: <b>Jane Doe</b>", html.Trim());
Assert.Equal("Custom TXT: Jane Doe", txt.Trim());
}
finally
{
// Cleanup
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, true);
}
}
}
}
6 changes: 5 additions & 1 deletion test/Core.Test/Platform/Mailer/MailerTest.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using Bit.Core.Models.Mail;
using Bit.Core.Platform.Mailer;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Test.Platform.Mailer.TestMail;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;

Expand All @@ -12,8 +14,10 @@ public class MailerTest
[Fact]
public async Task SendEmailAsync()
{
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var globalSettings = new GlobalSettings { SelfHosted = false };
var deliveryService = Substitute.For<IMailDeliveryService>();
var mailer = new Core.Platform.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService);
var mailer = new Core.Platform.Mailer.Mailer(new HandlebarMailRenderer(logger, globalSettings), deliveryService);

var mail = new TestMail.TestMail()
{
Expand Down
Loading