From 46eed22707aad9321eacb4002d6b8bcc4bb0ed05 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 18 Sep 2026 16:33:24 -0300 Subject: [PATCH] fix(work-orders): accept mobile media whose declared MIME is foreign (SH-381) Mobile browsers attach an unreliable Content-Type to a picked file: empty or application/octet-stream when the OS cannot classify it, and sometimes a foreign-but-plausible type for a supported container (video/3gpp for an .mp4, video/x-quicktime for a .mov). The media allowlist already resolved empty and octet-stream types from the extension, but a concrete foreign type was rejected outright, so a real .MP4/.MOV picked on a phone passed the client dialog and was refused by the API. Any declared type that is not itself on the allowlist now falls back to the extension. The extension pairing and magic-byte signature still decide, so the accepted set of files is unchanged; an allowlisted declared type stays authoritative and must still match its own extension. --- .../Helpers/WorkOrderMediaFileRules.cs | 19 +++++----- .../WorkOrderPhase6Tests.cs | 35 +++++++++++++++++-- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs index e1281d4..aa6439a 100644 --- a/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs +++ b/SeaHaven.Services/Helpers/WorkOrderMediaFileRules.cs @@ -32,17 +32,18 @@ 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" }; - + // The multipart part's Content-Type comes from the browser's File.type, which is + // unreliable on mobile: it is left empty or sent as application/octet-stream when the + // OS cannot classify a picked file, and is sometimes a foreign-but-plausible video + // type the OS attaches to a supported container (e.g. video/3gpp for an .mp4, + // video/x-quicktime for a .mov). An allowlisted declared type stays authoritative and + // pairs against its own extension; anything else falls back to the extension. The + // extension pairing and magic-byte signature below still decide, so this never widens + // the accepted set of files. private static string? ResolveContentType(string declaredType, string extension) { - if (!UndeterminedContentTypes.Contains(declaredType)) - return AllowedContentTypes.Contains(declaredType) ? declaredType : null; + if (AllowedContentTypes.Contains(declaredType)) + return declaredType; foreach (var (contentType, extensions) in ExtensionsByContentType) { diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs index 468e2b0..cd24883 100644 --- a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -2084,11 +2084,40 @@ public class WorkOrderMediaFileRulesTests } [Fact] - public void IsAllowed_DeclaredMimeMismatchingExtension_StillRejected() + public void IsAllowed_AllowlistedDeclaredMimeMismatchingExtension_StillRejected() { - // A concrete declared type is authoritative; only an undetermined one falls back to the extension. + // An allowlisted declared type stays authoritative and pairs against its own + // extension, so declaring video/mp4 for a .mov (or the reverse) is still a spoof. Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMov, "clip.mov", "video/mp4"))); - Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "photo.jpg", "image/heic"))); + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMp4, "clip.mp4", "video/quicktime"))); + } + + [Theory] + // SH-381: mobile browsers attach a foreign-but-plausible type to a supported container. + // The extension plus magic bytes must decide, not the unreliable declared MIME. + [InlineData("clip.mp4", "video/3gpp")] + [InlineData("VID_0001.MP4", "video/3gpp")] + [InlineData("IMG_1587.MOV", "video/x-quicktime")] + [InlineData("IMG_1587.mov", "video/mpeg")] + [InlineData("photo.jpg", "image/heic")] + public void IsAllowed_ForeignDeclaredMime_ResolvesFromExtensionAndSignature( + string fileName, + string contentType) + { + Assert.True(WorkOrderMediaFileRules.IsAllowed( + FormFile(BytesFor(fileName), fileName, contentType), WorkOrderMediaCategory.Before)); + } + + [Fact] + public void IsAllowed_ForeignDeclaredMime_StillRequiresMatchingSignatureAndExtension() + { + // Falling back to the extension does not weaken the gate: the bytes must still match + // the resolved type, the extension must still be supported, and a resolved document + // stays out of photo/video categories. + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealJpeg, "clip.mp4", "video/3gpp"))); + Assert.False(WorkOrderMediaFileRules.IsAllowed(FormFile(RealMp4, "clip.exe", "video/3gpp"))); + Assert.False(WorkOrderMediaFileRules.IsAllowed( + FormFile(RealPdf, "report.pdf", "application/x-unknown"), WorkOrderMediaCategory.Before)); } private static byte[] BytesFor(string fileName)