fix(work-orders): resolve undetermined media MIME from the extension (SH-370) (#122)

The media allowlist refused any upload whose multipart part had an empty or
application/octet-stream Content-Type before looking at the extension or the
bytes. Browsers take that header from File.type, which mobile browsers leave
empty when the OS cannot classify a picked file, while the client-side gate
already accepts such files on extension alone. A real JPG/MP4/MOV could pass
the dialog and still be refused by the API.

Only an undetermined type now falls back to the extension. The resolved type
still goes through the SH-171 document/category rule, the extension pairing,
and the magic-byte signature check, so an octet-stream .pdf stays refused for
Completion, Before and After, and a declared type is never overridden.
This commit is contained in:
Alexandre Brandizzi 2026-09-16 14:53:58 -03:00 • committed by GitHub
parent caba84ea08
commit 776c8be5cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 123 additions and 3 deletions

View file

@ -32,6 +32,27 @@ 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" };
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))

View file

@ -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("<html>not an image</html>");
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()
{