mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 15:23:12 +00:00
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:
parent
caba84ea08
commit
776c8be5cb
2 changed files with 123 additions and 3 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue