Skip to content
Open
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
59 changes: 56 additions & 3 deletions src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
#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
{
/// <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 @@ -21,6 +23,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 @@ -53,19 +66,59 @@ private async Task<HandlebarsTemplate<object, object>> CompileTemplateInternalAs
return handlebars.Compile(source);
}

private static async Task<string> ReadSourceAsync(Assembly assembly, string template)
Copy link
Member Author

@Hinton Hinton Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had to remove static from most functions because reading from disk relies on non static data.

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));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 1: Null reference exception when MailTemplateDirectory is null

Details

When SelfHosted is true but MailTemplateDirectory is not explicitly configured, GlobalSettings.BuildDirectory returns null (line 143 in GlobalSettings.cs). This causes Path.Combine and Path.GetFullPath to throw an ArgumentNullException.

The BuildDirectory method only returns a non-null value when:

  1. An explicit value is set, OR
  2. SelfHosted is true (returns /etc/bitwarden/mail-templates)

However, the check on line 89 only validates SelfHosted, not whether MailTemplateDirectory is null.

Suggested fix:

if (!_globalSettings.SelfHosted || string.IsNullOrWhiteSpace(_globalSettings.MailTemplateDirectory))
{
    return null;
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's caught by the catch right?

var baseDirectory = Path.GetFullPath(_globalSettings.MailTemplateDirectory);

// Ensure the resolved path is within the configured directory
if (!diskPath.StartsWith(baseDirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Finding 2: Path traversal protection may fail on case-sensitive filesystems

The path validation uses StringComparison.OrdinalIgnoreCase, but this only affects string comparison logic, not actual filesystem behavior. On Linux (case-sensitive), an attacker could potentially bypass this check by exploiting case differences if symbolic links or actual directory names differ in case from the configured path.

Consider using Path.GetFullPath on both sides after resolving any symbolic links, or add explicit checks for symbolic link traversal.

!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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 Finding 3: Variable name fileContents can be simplified to content

The variable assignment on line 109 followed by immediate return on line 110 can be simplified to:

return await File.ReadAllTextAsync(diskPath);

This reduces unnecessary variable allocation and improves readability.

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
154 changes: 153 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.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;
Expand All @@ -9,12 +12,161 @@ 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);
}
}
}

[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<ILogger<HandlebarMailRenderer>>();
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<string?>)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<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());

// Cleanup malicious file
if (File.Exists(maliciousFile))
{
File.Delete(maliciousFile);
}
}
finally
{
// Cleanup
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, true);
}
}
}

[Fact]
public async Task ReadSourceFromDiskAsync_AllowsValidFileWithDifferentCase_WhenCaseInsensitiveFileSystem()
{
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 a test template file
var templateFileName = "TestTemplate.hbs";
var templatePath = Path.Combine(tempDir, templateFileName);
await File.WriteAllTextAsync(templatePath, "Test Content");

var renderer = new HandlebarMailRenderer(logger, globalSettings);

// Try to read with different case (should work on case-insensitive file systems like Windows/macOS)
var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var task = (Task<string?>)method!.Invoke(renderer, new object[] { templateFileName })!;
var result = await task;

// Should successfully read the file
Assert.Equal("Test Content", result);

// Verify no warning was logged
logger.DidNotReceive().Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
finally
{
// Cleanup
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, true);
}
}
}
}
8 changes: 7 additions & 1 deletion test/Core.Test/Platform/Mailer/MailerTest.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
using Bit.Core.Models.Mail;
using Bit.Core.Platform.Mail.Delivery;
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;

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.Mail.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService);

var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(logger, globalSettings), deliveryService);

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