Skip to content

Commit 7d39efe

Browse files
authored
[PM-27575] Add support for loading Mailer templates from disk (#6520)
Adds support for overloading mail templates from disk.
1 parent 22fe50c commit 7d39efe

File tree

3 files changed

+216
-5
lines changed

3 files changed

+216
-5
lines changed

src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
#nullable enable
22
using System.Collections.Concurrent;
33
using System.Reflection;
4+
using Bit.Core.Settings;
45
using HandlebarsDotNet;
6+
using Microsoft.Extensions.Logging;
57

68
namespace Bit.Core.Platform.Mail.Mailer;
79
public class HandlebarMailRenderer : IMailRenderer
810
{
911
/// <summary>
1012
/// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once.
1113
/// </summary>
12-
private readonly Lazy<Task<IHandlebars>> _handlebarsTask = new(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication);
14+
private readonly Lazy<Task<IHandlebars>> _handlebarsTask;
1315

1416
/// <summary>
1517
/// Helper function that returns the handlebar instance.
@@ -21,6 +23,17 @@ public class HandlebarMailRenderer : IMailRenderer
2123
/// </summary>
2224
private readonly ConcurrentDictionary<string, Lazy<Task<HandlebarsTemplate<object, object>>>> _templateCache = new();
2325

26+
private readonly ILogger<HandlebarMailRenderer> _logger;
27+
private readonly GlobalSettings _globalSettings;
28+
29+
public HandlebarMailRenderer(ILogger<HandlebarMailRenderer> logger, GlobalSettings globalSettings)
30+
{
31+
_logger = logger;
32+
_globalSettings = globalSettings;
33+
34+
_handlebarsTask = new Lazy<Task<IHandlebars>>(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication);
35+
}
36+
2437
public async Task<(string html, string txt)> RenderAsync(BaseMailView model)
2538
{
2639
var html = await CompileTemplateAsync(model, "html");
@@ -53,19 +66,59 @@ private async Task<HandlebarsTemplate<object, object>> CompileTemplateInternalAs
5366
return handlebars.Compile(source);
5467
}
5568

56-
private static async Task<string> ReadSourceAsync(Assembly assembly, string template)
69+
private async Task<string> ReadSourceAsync(Assembly assembly, string template)
5770
{
5871
if (assembly.GetManifestResourceNames().All(f => f != template))
5972
{
6073
throw new FileNotFoundException("Template not found: " + template);
6174
}
6275

76+
var diskSource = await ReadSourceFromDiskAsync(template);
77+
if (!string.IsNullOrWhiteSpace(diskSource))
78+
{
79+
return diskSource;
80+
}
81+
6382
await using var s = assembly.GetManifestResourceStream(template)!;
6483
using var sr = new StreamReader(s);
6584
return await sr.ReadToEndAsync();
6685
}
6786

68-
private static async Task<IHandlebars> InitializeHandlebarsAsync()
87+
private async Task<string?> ReadSourceFromDiskAsync(string template)
88+
{
89+
if (!_globalSettings.SelfHosted)
90+
{
91+
return null;
92+
}
93+
94+
try
95+
{
96+
var diskPath = Path.GetFullPath(Path.Combine(_globalSettings.MailTemplateDirectory, template));
97+
var baseDirectory = Path.GetFullPath(_globalSettings.MailTemplateDirectory);
98+
99+
// Ensure the resolved path is within the configured directory
100+
if (!diskPath.StartsWith(baseDirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
101+
!diskPath.Equals(baseDirectory, StringComparison.OrdinalIgnoreCase))
102+
{
103+
_logger.LogWarning("Template path traversal attempt detected: {Template}", template);
104+
return null;
105+
}
106+
107+
if (File.Exists(diskPath))
108+
{
109+
var fileContents = await File.ReadAllTextAsync(diskPath);
110+
return fileContents;
111+
}
112+
}
113+
catch (Exception e)
114+
{
115+
_logger.LogError(e, "Failed to read mail template from disk: {TemplateName}", template);
116+
}
117+
118+
return null;
119+
}
120+
121+
private async Task<IHandlebars> InitializeHandlebarsAsync()
69122
{
70123
var handlebars = Handlebars.Create();
71124

test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
using Bit.Core.Platform.Mail.Mailer;
2+
using Bit.Core.Settings;
23
using Bit.Core.Test.Platform.Mailer.TestMail;
4+
using Microsoft.Extensions.Logging;
5+
using NSubstitute;
36
using Xunit;
47

58
namespace Bit.Core.Test.Platform.Mailer;
@@ -9,12 +12,161 @@ public class HandlebarMailRendererTests
912
[Fact]
1013
public async Task RenderAsync_ReturnsExpectedHtmlAndTxt()
1114
{
12-
var renderer = new HandlebarMailRenderer();
15+
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
16+
var globalSettings = new GlobalSettings { SelfHosted = false };
17+
var renderer = new HandlebarMailRenderer(logger, globalSettings);
18+
1319
var view = new TestMailView { Name = "John Smith" };
1420

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

1723
Assert.Equal("Hello <b>John Smith</b>", html.Trim());
1824
Assert.Equal("Hello John Smith", txt.Trim());
1925
}
26+
27+
[Fact]
28+
public async Task RenderAsync_LoadsFromDisk_WhenSelfHostedAndFileExists()
29+
{
30+
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
31+
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
32+
Directory.CreateDirectory(tempDir);
33+
34+
try
35+
{
36+
var globalSettings = new GlobalSettings
37+
{
38+
SelfHosted = true,
39+
MailTemplateDirectory = tempDir
40+
};
41+
42+
// Create test template files on disk
43+
var htmlTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.html.hbs");
44+
var txtTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.text.hbs");
45+
await File.WriteAllTextAsync(htmlTemplatePath, "Custom HTML: <b>{{Name}}</b>");
46+
await File.WriteAllTextAsync(txtTemplatePath, "Custom TXT: {{Name}}");
47+
48+
var renderer = new HandlebarMailRenderer(logger, globalSettings);
49+
var view = new TestMailView { Name = "Jane Doe" };
50+
51+
var (html, txt) = await renderer.RenderAsync(view);
52+
53+
Assert.Equal("Custom HTML: <b>Jane Doe</b>", html.Trim());
54+
Assert.Equal("Custom TXT: Jane Doe", txt.Trim());
55+
}
56+
finally
57+
{
58+
// Cleanup
59+
if (Directory.Exists(tempDir))
60+
{
61+
Directory.Delete(tempDir, true);
62+
}
63+
}
64+
}
65+
66+
[Theory]
67+
[InlineData("../../../etc/passwd")]
68+
[InlineData("../../../../malicious.txt")]
69+
[InlineData("../../malicious.txt")]
70+
[InlineData("../malicious.txt")]
71+
public async Task ReadSourceFromDiskAsync_PrevenetsPathTraversal_WhenMaliciousPathProvided(string maliciousPath)
72+
{
73+
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
74+
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
75+
Directory.CreateDirectory(tempDir);
76+
77+
try
78+
{
79+
var globalSettings = new GlobalSettings
80+
{
81+
SelfHosted = true,
82+
MailTemplateDirectory = tempDir
83+
};
84+
85+
// Create a malicious file outside the template directory
86+
var maliciousFile = Path.Combine(Path.GetTempPath(), "malicious.txt");
87+
await File.WriteAllTextAsync(maliciousFile, "Malicious Content");
88+
89+
var renderer = new HandlebarMailRenderer(logger, globalSettings);
90+
91+
// Use reflection to call the private ReadSourceFromDiskAsync method
92+
var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync",
93+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
94+
var task = (Task<string?>)method!.Invoke(renderer, new object[] { maliciousPath })!;
95+
var result = await task;
96+
97+
// Should return null and not load the malicious file
98+
Assert.Null(result);
99+
100+
// Verify that a warning was logged for the path traversal attempt
101+
logger.Received(1).Log(
102+
LogLevel.Warning,
103+
Arg.Any<EventId>(),
104+
Arg.Any<object>(),
105+
Arg.Any<Exception>(),
106+
Arg.Any<Func<object, Exception, string>>());
107+
108+
// Cleanup malicious file
109+
if (File.Exists(maliciousFile))
110+
{
111+
File.Delete(maliciousFile);
112+
}
113+
}
114+
finally
115+
{
116+
// Cleanup
117+
if (Directory.Exists(tempDir))
118+
{
119+
Directory.Delete(tempDir, true);
120+
}
121+
}
122+
}
123+
124+
[Fact]
125+
public async Task ReadSourceFromDiskAsync_AllowsValidFileWithDifferentCase_WhenCaseInsensitiveFileSystem()
126+
{
127+
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
128+
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
129+
Directory.CreateDirectory(tempDir);
130+
131+
try
132+
{
133+
var globalSettings = new GlobalSettings
134+
{
135+
SelfHosted = true,
136+
MailTemplateDirectory = tempDir
137+
};
138+
139+
// Create a test template file
140+
var templateFileName = "TestTemplate.hbs";
141+
var templatePath = Path.Combine(tempDir, templateFileName);
142+
await File.WriteAllTextAsync(templatePath, "Test Content");
143+
144+
var renderer = new HandlebarMailRenderer(logger, globalSettings);
145+
146+
// Try to read with different case (should work on case-insensitive file systems like Windows/macOS)
147+
var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync",
148+
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
149+
var task = (Task<string?>)method!.Invoke(renderer, new object[] { templateFileName })!;
150+
var result = await task;
151+
152+
// Should successfully read the file
153+
Assert.Equal("Test Content", result);
154+
155+
// Verify no warning was logged
156+
logger.DidNotReceive().Log(
157+
LogLevel.Warning,
158+
Arg.Any<EventId>(),
159+
Arg.Any<object>(),
160+
Arg.Any<Exception>(),
161+
Arg.Any<Func<object, Exception, string>>());
162+
}
163+
finally
164+
{
165+
// Cleanup
166+
if (Directory.Exists(tempDir))
167+
{
168+
Directory.Delete(tempDir, true);
169+
}
170+
}
171+
}
20172
}

test/Core.Test/Platform/Mailer/MailerTest.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
using Bit.Core.Models.Mail;
22
using Bit.Core.Platform.Mail.Delivery;
33
using Bit.Core.Platform.Mail.Mailer;
4+
using Bit.Core.Settings;
45
using Bit.Core.Test.Platform.Mailer.TestMail;
6+
using Microsoft.Extensions.Logging;
57
using NSubstitute;
68
using Xunit;
79

810
namespace Bit.Core.Test.Platform.Mailer;
11+
912
public class MailerTest
1013
{
1114
[Fact]
1215
public async Task SendEmailAsync()
1316
{
17+
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
18+
var globalSettings = new GlobalSettings { SelfHosted = false };
1419
var deliveryService = Substitute.For<IMailDeliveryService>();
15-
var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService);
20+
21+
var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(logger, globalSettings), deliveryService);
1622

1723
var mail = new TestMail.TestMail()
1824
{

0 commit comments

Comments
 (0)