Harden legacy file paths and Twilio webhook validation

This commit is contained in:
2026-04-26 18:14:16 -04:00
parent 8491b308eb
commit 0ea192d55b
4 changed files with 317 additions and 15 deletions
@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.Services;
namespace PowderCoating.UnitTests;
public class FileServiceTests
{
[Fact]
public async Task DeleteFileAsync_ReturnsError_WhenPathEscapesUploadsRoot()
{
using var harness = new FileServiceHarness();
var result = await harness.Service.DeleteFileAsync("uploads/../secrets.txt");
Assert.False(result.Success);
Assert.Equal("Invalid file path.", result.ErrorMessage);
}
[Fact]
public async Task GetFileAsync_ReturnsError_WhenPathEscapesUploadsRoot()
{
using var harness = new FileServiceHarness();
var result = await harness.Service.GetFileAsync("uploads/../../appsettings.json");
Assert.False(result.Success);
Assert.Equal("Invalid file path.", result.ErrorMessage);
Assert.Empty(result.FileContent);
}
[Fact]
public async Task SaveFileAsync_ReturnsError_WhenSubfolderEscapesUploadsRoot()
{
using var harness = new FileServiceHarness();
var result = await harness.Service.SaveFileAsync(
CreateFormFile("manual.pdf"),
"../outside",
new[] { ".pdf" },
1024 * 1024);
Assert.False(result.Success);
Assert.Equal("Invalid upload subfolder.", result.ErrorMessage);
}
[Fact]
public async Task GetFileAsync_ReturnsFile_WhenPathIsUnderUploadsRoot()
{
using var harness = new FileServiceHarness();
var uploadsPath = Path.Combine(harness.WebRootPath, "uploads", "equipment-manuals");
Directory.CreateDirectory(uploadsPath);
var fullPath = Path.Combine(uploadsPath, "manual.pdf");
await File.WriteAllBytesAsync(fullPath, [1, 2, 3]);
var result = await harness.Service.GetFileAsync("uploads/equipment-manuals/manual.pdf");
Assert.True(result.Success);
Assert.Equal("application/pdf", result.ContentType);
Assert.Equal(new byte[] { 1, 2, 3 }, result.FileContent);
}
private static IFormFile CreateFormFile(string fileName)
{
var bytes = new byte[] { 1, 2, 3, 4 };
var stream = new MemoryStream(bytes);
return new FormFile(stream, 0, bytes.Length, "file", fileName);
}
private sealed class FileServiceHarness : IDisposable
{
public FileServiceHarness()
{
WebRootPath = Path.Combine(Path.GetTempPath(), "powdercoating-fileservice-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(WebRootPath);
var environment = Mock.Of<IWebHostEnvironment>(x => x.WebRootPath == WebRootPath);
Service = new FileService(environment, Mock.Of<ILogger<FileService>>());
}
public string WebRootPath { get; }
public FileService Service { get; }
public void Dispose()
{
if (Directory.Exists(WebRootPath))
{
Directory.Delete(WebRootPath, recursive: true);
}
}
}
}
@@ -0,0 +1,89 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Web.Controllers;
namespace PowderCoating.UnitTests;
public class WebhooksControllerTests
{
[Fact]
public async Task TwilioSms_ReturnsForbid_WhenAuthTokenMissingOutsideDevelopment()
{
await using var dbContext = CreateDbContext();
var controller = CreateController(dbContext, Array.Empty<KeyValuePair<string, string?>>(), "Production");
controller.ControllerContext = new ControllerContext
{
HttpContext = CreateHttpContext()
};
var result = await controller.TwilioSms(new TwilioSmsPayload { From = "+15551234567", Body = "STOP" });
Assert.IsType<ForbidResult>(result);
}
[Fact]
public async Task TwilioSms_AllowsMissingAuthToken_InDevelopment()
{
await using var dbContext = CreateDbContext();
var controller = CreateController(dbContext, Array.Empty<KeyValuePair<string, string?>>(), "Development");
controller.ControllerContext = new ControllerContext
{
HttpContext = CreateHttpContext()
};
var result = await controller.TwilioSms(new TwilioSmsPayload());
var content = Assert.IsType<ContentResult>(result);
Assert.Equal("application/xml", content.ContentType);
Assert.Equal("<Response/>", content.Content);
}
private static WebhooksController CreateController(
ApplicationDbContext dbContext,
IEnumerable<KeyValuePair<string, string?>> configValues,
string environmentName)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configValues)
.Build();
var environment = Mock.Of<IWebHostEnvironment>(x => x.EnvironmentName == environmentName);
return new WebhooksController(
dbContext,
configuration,
environment,
Mock.Of<ILogger<WebhooksController>>());
}
private static DefaultHttpContext CreateHttpContext()
{
var context = new DefaultHttpContext();
context.Request.Method = HttpMethods.Post;
context.Request.Scheme = "https";
context.Request.Host = new HostString("example.com");
context.Request.Path = "/Webhooks/TwilioSms";
context.Features.Set<IFormFeature>(new FormFeature(new FormCollection(new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["From"] = "+15551234567",
["Body"] = "STOP"
})));
return context;
}
private static ApplicationDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}