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.
This commit is contained in:
Alexandre Brandizzi 2026-09-18 16:33:24 -03:00
parent 23f69307dd
commit 46eed22707
2 changed files with 42 additions and 12 deletions

View file

@ -32,17 +32,18 @@ namespace SeaHaven.Services.Helpers
new HashSet<string>(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<string> 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)
{

View file

@ -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)