diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index 1132a4f..e1281d4 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -32,6 +32,27 @@ namespace SeaHaven.Services.Helpers new HashSet(StringComparer.OrdinalIgnoreCase) { ".docx" } }; + // A browser fills the multipart part's Content-Type from File.type, which mobile + // browsers leave empty (or the client sends octet-stream) when the OS cannot + // classify a picked file. Only then is the type resolved from the extension; the + // category rule, extension pairing, and magic-byte signature still decide. + private static readonly HashSet UndeterminedContentTypes = + new(StringComparer.OrdinalIgnoreCase) { string.Empty, "application/octet-stream" }; + + private static string? ResolveContentType(string declaredType, string extension) + { + if (!UndeterminedContentTypes.Contains(declaredType)) + return AllowedContentTypes.Contains(declaredType) ? declaredType : null; + + foreach (var (contentType, extensions) in ExtensionsByContentType) + { + if (extensions.Contains(extension)) + return contentType; + } + + return null; + } + private static string CanonicalContentType(string contentType) { if (contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase)) @@ -47,10 +68,12 @@ namespace SeaHaven.Services.Helpers return false; var declaredType = (file.ContentType ?? string.Empty).Trim(); - if (string.IsNullOrWhiteSpace(declaredType) || !AllowedContentTypes.Contains(declaredType)) + var extension = Path.GetExtension(file.FileName ?? string.Empty); + var resolvedType = ResolveContentType(declaredType, extension); + if (resolvedType == null) return false; - var contentType = CanonicalContentType(declaredType); + var contentType = CanonicalContentType(resolvedType); var resolvedCategory = category ?? WorkOrderMediaCategory.Extra; if (IsDocument(contentType) && resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta) @@ -58,7 +81,6 @@ namespace SeaHaven.Services.Helpers return false; } - var extension = Path.GetExtension(file.FileName ?? string.Empty); if (string.IsNullOrWhiteSpace(extension) || !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions) || !allowedExtensions.Contains(extension)) diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index ce285c1..468e2b0 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -2004,6 +2004,104 @@ public class WorkOrderMediaFileRulesTests Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(new byte[] { 1, 2, 3 }, "photo.jpg", "image/jpeg"))); } + // Signatures below are the leading bytes of files produced by real encoders: + // ffmpeg (mp4 isom, mov qt, jpeg JFIF, png) and an iPhone/macOS .MOV (ftyp qt). + private static readonly byte[] RealJpeg = + { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x02, 0x00, 0x00, 0x01 }; + private static readonly byte[] RealPng = + { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52 }; + private static readonly byte[] RealMp4 = + { 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D, 0x00, 0x00, 0x02, 0x00 }; + private static readonly byte[] RealMov = + { 0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70, 0x71, 0x74, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00 }; + private static readonly byte[] RealPdf = Encoding.ASCII.GetBytes("%PDF-1.7\n%\u00e2\u00e3\n1 0 obj\n"); + + [Theory] + [InlineData("photo.jpg", "image/jpeg")] + [InlineData("IMG_0001.JPG", "image/jpeg")] + [InlineData("photo.jpeg", "image/jpg")] + [InlineData("scan.png", "image/png")] + [InlineData("clip.mp4", "video/mp4")] + [InlineData("VID_0001.MP4", "video/mp4")] + [InlineData("IMG_1587.MOV", "video/quicktime")] + public void IsAllowed_RealMediaSignatures_ReturnsTrueForEveryCategory(string fileName, string contentType) + { + var bytes = BytesFor(fileName); + foreach (var category in new[] + { + WorkOrderMediaCategory.Extra, WorkOrderMediaCategory.Before, WorkOrderMediaCategory.After + }) + { + Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(bytes, fileName, contentType), category)); + } + } + + [Theory] + [InlineData("photo.jpg", "")] + [InlineData("IMG_0001.JPG", "application/octet-stream")] + [InlineData("scan.png", "")] + [InlineData("clip.mp4", "application/octet-stream")] + [InlineData("IMG_1587.MOV", "")] + [InlineData("IMG_1587.mov", "application/octet-stream")] + public void IsAllowed_UndeterminedMimeFromMobileBrowser_ResolvesFromExtensionAndSignature( + string fileName, + string contentType) + { + // Some mobile browsers leave File.type empty (or send octet-stream) when the OS + // cannot classify a picked file; the extension plus magic bytes must decide. + Assert.True(WorkOrderMediaFileRules.IsAllowed( + FormFile(BytesFor(fileName), fileName, contentType), WorkOrderMediaCategory.Before)); + } + + [Fact] + public void IsAllowed_UndeterminedMime_StillRequiresMatchingSignature() + { + var html = Encoding.UTF8.GetBytes("not an image"); + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(html, "photo.jpg", ""))); + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealPng, "photo.jpg", "application/octet-stream"))); + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "clip.mov", ""))); + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo.heic", ""))); + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo", "application/octet-stream"))); + } + + [Theory] + [InlineData(WorkOrderMediaCategory.Completion)] + [InlineData(WorkOrderMediaCategory.Before)] + [InlineData(WorkOrderMediaCategory.After)] + public void IsAllowed_UndeterminedMimePdf_StillRefusedOutsideDocumentCategories(WorkOrderMediaCategory category) + { + // SH-337 / SH-171: resolving an empty MIME from ".pdf" must not reopen photo slots to documents. + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealPdf, "report.pdf", ""), category)); + Assert.False(WorkOrderMediaFileRules.IsAllowed( + FormFile(RealPdf, "report.pdf", "application/octet-stream"), category)); + } + + [Fact] + public void IsAllowed_UndeterminedMimePdf_AcceptedForExtra() + { + Assert.True(WorkOrderMediaFileRules.IsAllowed( + FormFile(RealPdf, "report.pdf", ""), WorkOrderMediaCategory.Extra)); + } + + [Fact] + public void IsAllowed_DeclaredMimeMismatchingExtension_StillRejected() + { + // A concrete declared type is authoritative; only an undetermined one falls back to the extension. + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMov, "clip.mov", "video/mp4"))); + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo.jpg", "image/heic"))); + } + + private static byte[] BytesFor(string fileName) + => Path.GetExtension(fileName).ToLowerInvariant() switch + { + ".jpg" or ".jpeg" => RealJpeg, + ".png" => RealPng, + ".mp4" => RealMp4, + ".mov" => RealMov, + ".pdf" => RealPdf, + _ => throw new ArgumentOutOfRangeException(nameof(fileName)) + }; + [Fact] public void IsAllowed_ValidMp4Ftyp_ReturnsTrue() {