fix(work-orders): enforce a file allowlist on completion-doc upload (SH-337)

The completion-document endpoint persisted whatever file it received: the
only checks were non-null, non-empty, and a 30 MB request limit. Its sibling
media endpoint has enforced a MIME allowlist, MIME-to-extension pairing, and
a magic-byte signature check since SH-116.

Validate before the file reaches storage, so a rejected upload leaves nothing
behind. An undetermined content type is accepted only alongside a .pdf name
and a %PDF- signature, because the browser leaves File.type empty when the OS
cannot classify the file and the completion-doc dialog already allows that.
This commit is contained in:
Alexandre Brandizzi 2026-09-08 21:24:10 -03:00
parent a0fdd19934
commit 7e4db749d0
4 changed files with 152 additions and 0 deletions

View file

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
@ -109,6 +110,7 @@ namespace Api.SeaHavenIndustries.Controllers
string? fileUrl = null;
try
{
WorkOrderCompletionDocFileRules.EnsureAllowed(file);
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _workOrderCompletionService.EnsureCanUploadCompletionDocAsync(id, User, actorId);

View file

@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Http;
namespace SeaHaven.Services.Helpers
{
/// <summary>SH-337 file type allowlist for work-order completion documents.</summary>
public static class WorkOrderCompletionDocFileRules
{
private const string PdfContentType = "application/pdf";
private static readonly HashSet<string> AllowedExtensions =
new(StringComparer.OrdinalIgnoreCase) { ".pdf" };
// A browser sets the multipart part's Content-Type from File.type, which the OS
// sometimes leaves empty for a PDF. The completion-doc dialog already accepts
// that case on the client (a .pdf name with no type passes), so rejecting it
// here would fail uploads that work today. The %PDF- signature is the real gate.
private static readonly HashSet<string> UndeterminedContentTypes =
new(StringComparer.OrdinalIgnoreCase) { "application/octet-stream" };
public static bool IsAllowed(IFormFile file)
{
if (file == null || file.Length <= 0)
return false;
var declaredType = (file.ContentType ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(declaredType)
&& !declaredType.Equals(PdfContentType, StringComparison.OrdinalIgnoreCase)
&& !UndeterminedContentTypes.Contains(declaredType))
{
return false;
}
var extension = Path.GetExtension(file.FileName ?? string.Empty);
if (string.IsNullOrWhiteSpace(extension) || !AllowedExtensions.Contains(extension))
return false;
try
{
using var stream = file.OpenReadStream();
var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 64);
if (headerLength == 0)
return false;
var header = new byte[headerLength];
var read = stream.Read(header, 0, header.Length);
if (read <= 0)
return false;
if (read < header.Length)
Array.Resize(ref header, read);
return WorkOrderMediaFileRules.MatchesSignature(PdfContentType, header);
}
catch
{
return false;
}
}
public static void EnsureAllowed(IFormFile file)
{
if (!IsAllowed(file))
{
throw new Exceptions.WorkOrderBoardValidationException(
"UnsupportedMediaType",
"The completion document must be a PDF file.");
}
}
}
}

View file

@ -99,6 +99,14 @@ namespace SeaHaven.Services.Helpers
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
}
if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
{
// %PDF-
return bytes.Length >= 5
&& bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44
&& bytes[3] == 0x46 && bytes[4] == 0x2D;
}
if (contentType.Equals("video/mp4", StringComparison.OrdinalIgnoreCase)
|| contentType.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase))
{

View file

@ -1732,6 +1732,78 @@ public class WorkOrderMediaServiceTests
}
}
public class WorkOrderCompletionDocFileRulesTests
{
private static readonly byte[] Pdf = Encoding.ASCII.GetBytes("%PDF-1.7\n1 0 obj\n");
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
=> new(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
[Fact]
public void IsAllowed_ValidPdf_ReturnsTrue()
{
Assert.True(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "application/pdf")));
}
[Fact]
public void IsAllowed_PdfWithUndeterminedContentType_ReturnsTrue()
{
// The browser leaves File.type empty when the OS cannot determine it, and the
// completion-doc dialog already lets that through on a .pdf name. Rejecting it
// would break uploads that work today.
Assert.True(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "")));
Assert.True(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "application/octet-stream")));
}
[Fact]
public void IsAllowed_PdfExtensionWithNonPdfContent_ReturnsFalse()
{
var html = Encoding.UTF8.GetBytes("<html>not a pdf</html>");
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(html, "completion.pdf", "application/pdf")));
}
[Fact]
public void IsAllowed_PdfContentUnderDisallowedExtension_ReturnsFalse()
{
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.exe", "application/pdf")));
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion", "application/pdf")));
}
[Fact]
public void IsAllowed_DisallowedContentType_ReturnsFalse()
{
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg")));
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "text/html")));
}
[Fact]
public void IsAllowed_EmptyFile_ReturnsFalse()
{
Assert.False(WorkOrderCompletionDocFileRules.IsAllowed(FormFile(Array.Empty<byte>(), "completion.pdf", "application/pdf")));
}
[Fact]
public void EnsureAllowed_RejectedFile_ThrowsUnsupportedMediaType()
{
var html = Encoding.UTF8.GetBytes("<html>not a pdf</html>");
var ex = Assert.Throws<WorkOrderBoardValidationException>(
() => WorkOrderCompletionDocFileRules.EnsureAllowed(FormFile(html, "completion.pdf", "application/pdf")));
Assert.Equal("UnsupportedMediaType", ex.Code);
}
[Fact]
public void MediaRules_StillRejectPdf()
{
// The completion-doc allowlist must not widen the SH-116 media allowlist.
Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(Pdf, "completion.pdf", "application/pdf")));
}
}
public class WorkOrderMediaFileRulesTests
{
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)