diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs
index bf90361..88837ef 100644
--- a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs
+++ b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs
@@ -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);
diff --git a/SeaHaven.Services/Helpers/WorkOrderCompletionDocFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderCompletionDocFileRules.cs
new file mode 100644
index 0000000..5d3d4d7
--- /dev/null
+++ b/SeaHaven.Services/Helpers/WorkOrderCompletionDocFileRules.cs
@@ -0,0 +1,70 @@
+using Microsoft.AspNetCore.Http;
+
+namespace SeaHaven.Services.Helpers
+{
+ /// SH-337 file type allowlist for work-order completion documents.
+ public static class WorkOrderCompletionDocFileRules
+ {
+ private const string PdfContentType = "application/pdf";
+
+ private static readonly HashSet 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 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.");
+ }
+ }
+ }
+}
diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs
index 68381d9..d483ba9 100644
--- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs
+++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs
@@ -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))
{
diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
index b770313..0c7a906 100644
--- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
+++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs
@@ -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("not a pdf");
+ 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(), "completion.pdf", "application/pdf")));
+ }
+
+ [Fact]
+ public void EnsureAllowed_RejectedFile_ThrowsUnsupportedMediaType()
+ {
+ var html = Encoding.UTF8.GetBytes("not a pdf");
+ var ex = Assert.Throws(
+ () => 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)