diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderCompletionController.cs index f2ea1cc..655892c 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 a1ab2f7..1132a4f 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -121,6 +121,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 cecb181..ce285c1 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -1865,6 +1865,87 @@ 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_StillRejectPdfOutsideDocumentCategories() + { + // The completion-doc allowlist must not widen the SH-116 media allowlist. + // SH-171 since added documents to media for Extra and Aveta, which is a + // deliberate widening of its own; what this guards is that it stopped + // there. Completion is the category SH-337 touches, and a photo slot + // must still refuse a PDF. + Assert.False(WorkOrderMediaFileRules.IsAllowed( + FormFile(Pdf, "completion.pdf", "application/pdf"), WorkOrderMediaCategory.Completion)); + Assert.False(WorkOrderMediaFileRules.IsAllowed( + FormFile(Pdf, "completion.pdf", "application/pdf"), WorkOrderMediaCategory.Before)); + Assert.False(WorkOrderMediaFileRules.IsAllowed( + FormFile(Pdf, "completion.pdf", "application/pdf"), WorkOrderMediaCategory.After)); + } +} + public class WorkOrderMediaFileRulesTests { private static FormFile FormFile(byte[] bytes, string fileName, string contentType)