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)