diff --git a/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs
index 608d6d6be016..baba5b801544 100644
--- a/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs
+++ b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs
@@ -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.Mail.Mailer;
public class HandlebarMailRenderer : IMailRenderer
@@ -9,7 +11,7 @@ public class HandlebarMailRenderer : IMailRenderer
///
/// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once.
///
- private readonly Lazy> _handlebarsTask = new(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication);
+ private readonly Lazy> _handlebarsTask;
///
/// Helper function that returns the handlebar instance.
@@ -21,6 +23,17 @@ public class HandlebarMailRenderer : IMailRenderer
///
private readonly ConcurrentDictionary>>> _templateCache = new();
+ private readonly ILogger _logger;
+ private readonly GlobalSettings _globalSettings;
+
+ public HandlebarMailRenderer(ILogger logger, GlobalSettings globalSettings)
+ {
+ _logger = logger;
+ _globalSettings = globalSettings;
+
+ _handlebarsTask = new Lazy>(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication);
+ }
+
public async Task<(string html, string txt)> RenderAsync(BaseMailView model)
{
var html = await CompileTemplateAsync(model, "html");
@@ -53,19 +66,59 @@ private async Task> CompileTemplateInternalAs
return handlebars.Compile(source);
}
- private static async Task ReadSourceAsync(Assembly assembly, string template)
+ private async Task 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 InitializeHandlebarsAsync()
+ private async Task 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, StringComparison.OrdinalIgnoreCase) &&
+ !diskPath.Equals(baseDirectory, StringComparison.OrdinalIgnoreCase))
+ {
+ _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 InitializeHandlebarsAsync()
{
var handlebars = Handlebars.Create();
diff --git a/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs b/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs
index 1cc750470221..2559ae2b5f50 100644
--- a/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs
+++ b/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs
@@ -1,5 +1,8 @@
using Bit.Core.Platform.Mail.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;
@@ -9,7 +12,10 @@ public class HandlebarMailRendererTests
[Fact]
public async Task RenderAsync_ReturnsExpectedHtmlAndTxt()
{
- var renderer = new HandlebarMailRenderer();
+ var logger = Substitute.For>();
+ 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);
@@ -17,4 +23,150 @@ public async Task RenderAsync_ReturnsExpectedHtmlAndTxt()
Assert.Equal("Hello John Smith", html.Trim());
Assert.Equal("Hello John Smith", txt.Trim());
}
+
+ [Fact]
+ public async Task RenderAsync_LoadsFromDisk_WhenSelfHostedAndFileExists()
+ {
+ var logger = Substitute.For>();
+ 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: {{Name}}");
+ 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: Jane Doe", html.Trim());
+ Assert.Equal("Custom TXT: Jane Doe", txt.Trim());
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData("../../../etc/passwd")]
+ [InlineData("../../../../malicious.txt")]
+ [InlineData("../../malicious.txt")]
+ [InlineData("../malicious.txt")]
+ public async Task ReadSourceFromDiskAsync_PrevenetsPathTraversal_WhenMaliciousPathProvided(string maliciousPath)
+ {
+ var logger = Substitute.For>();
+ var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ var globalSettings = new GlobalSettings
+ {
+ SelfHosted = true,
+ MailTemplateDirectory = tempDir
+ };
+
+ // Create a malicious file outside the template directory
+ var maliciousFile = Path.Combine(Path.GetTempPath(), "malicious.txt");
+ await File.WriteAllTextAsync(maliciousFile, "Malicious Content");
+
+ var renderer = new HandlebarMailRenderer(logger, globalSettings);
+
+ // Use reflection to call the private ReadSourceFromDiskAsync method
+ var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ var task = (Task)method!.Invoke(renderer, new object[] { maliciousPath })!;
+ var result = await task;
+
+ // Should return null and not load the malicious file
+ Assert.Null(result);
+
+ // Verify that a warning was logged for the path traversal attempt
+ logger.Received(1).Log(
+ LogLevel.Warning,
+ Arg.Any(),
+ Arg.Any