mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 13:03:12 +00:00
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.
209 lines
8.4 KiB
C#
209 lines
8.4 KiB
C#
using Microsoft.AspNetCore.Http;
|
|
using Data.SeaHavenIndustries.Enums;
|
|
using System.IO.Compression;
|
|
|
|
namespace SeaHaven.Services.Helpers
|
|
{
|
|
/// <summary>SH-116 media type allowlist for board work-order uploads.</summary>
|
|
public static class WorkOrderMediaFileRules
|
|
{
|
|
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"image/jpeg",
|
|
"image/jpg",
|
|
"image/png",
|
|
"video/mp4",
|
|
"video/quicktime",
|
|
"application/pdf",
|
|
"application/msword",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
};
|
|
|
|
private static readonly Dictionary<string, HashSet<string>> ExtensionsByContentType =
|
|
new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["image/jpeg"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg" },
|
|
["image/png"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png" },
|
|
["video/mp4"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mp4" },
|
|
["video/quicktime"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".mov" },
|
|
["application/pdf"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".pdf" },
|
|
["application/msword"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".doc" },
|
|
["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] =
|
|
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))
|
|
return "image/jpeg";
|
|
return contentType;
|
|
}
|
|
|
|
public static bool IsAllowed(
|
|
IFormFile file,
|
|
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
|
|
{
|
|
if (file == null || file.Length <= 0)
|
|
return false;
|
|
|
|
var declaredType = (file.ContentType ?? string.Empty).Trim();
|
|
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
|
var resolvedType = ResolveContentType(declaredType, extension);
|
|
if (resolvedType == null)
|
|
return false;
|
|
|
|
var contentType = CanonicalContentType(resolvedType);
|
|
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
|
|
if (IsDocument(contentType)
|
|
&& resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(extension)
|
|
|| !ExtensionsByContentType.TryGetValue(contentType, out var allowedExtensions)
|
|
|| !allowedExtensions.Contains(extension))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var stream = file.OpenReadStream();
|
|
var headerLength = (int)Math.Min(Math.Max(file.Length, 0), 512);
|
|
if (headerLength == 0)
|
|
return false;
|
|
|
|
var header = new byte[headerLength];
|
|
var read = stream.Read(header, 0, header.Length);
|
|
if (read <= 0)
|
|
return false;
|
|
|
|
if (read < header.Length)
|
|
Array.Resize(ref header, read);
|
|
|
|
if (IsDocx(contentType))
|
|
return IsWordDocumentArchive(stream);
|
|
|
|
return MatchesSignature(contentType, header);
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public static void EnsureAllowed(
|
|
IFormFile file,
|
|
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
|
|
{
|
|
if (!IsAllowed(file, category))
|
|
{
|
|
throw new Exceptions.WorkOrderBoardValidationException(
|
|
"UnsupportedMediaType",
|
|
"Supported file types are JPG, PNG, MP4, MOV, PDF, DOC, and DOCX for Extra Docs; photos accept media only.");
|
|
}
|
|
}
|
|
|
|
internal static bool MatchesSignature(string contentType, byte[] bytes)
|
|
{
|
|
if (bytes.Length == 0)
|
|
return false;
|
|
|
|
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return bytes.Length >= 8
|
|
&& bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47
|
|
&& bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A;
|
|
}
|
|
|
|
if (contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
|
}
|
|
|
|
if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// %PDF-
|
|
return bytes.Length >= 5
|
|
&& bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44
|
|
&& bytes[3] == 0x46 && bytes[4] == 0x2D;
|
|
}
|
|
|
|
if (contentType.Equals("video/mp4", StringComparison.OrdinalIgnoreCase)
|
|
|| contentType.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return HasFtypBox(bytes);
|
|
}
|
|
|
|
if (contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
|
|
return bytes.Length >= 4 && bytes.AsSpan(0, 4).SequenceEqual("%PDF"u8);
|
|
|
|
if (contentType.Equals("application/msword", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
ReadOnlySpan<byte> oleSignature = stackalloc byte[]
|
|
{
|
|
0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1
|
|
};
|
|
return bytes.Length >= oleSignature.Length
|
|
&& bytes.AsSpan(0, oleSignature.Length).SequenceEqual(oleSignature);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool IsDocument(string contentType)
|
|
=> contentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)
|
|
|| contentType.Equals("application/msword", StringComparison.OrdinalIgnoreCase)
|
|
|| IsDocx(contentType);
|
|
|
|
private static bool IsDocx(string contentType)
|
|
=> contentType.Equals(
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static bool IsWordDocumentArchive(Stream stream)
|
|
{
|
|
if (!stream.CanSeek)
|
|
return false;
|
|
|
|
stream.Position = 0;
|
|
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
|
return archive.Entries.Any(entry =>
|
|
entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static bool HasFtypBox(byte[] bytes)
|
|
{
|
|
if (bytes.Length < 12)
|
|
return false;
|
|
|
|
// ISO BMFF: [size:4][ftyp:4][major_brand:4]...
|
|
return bytes[4] == (byte)'f'
|
|
&& bytes[5] == (byte)'t'
|
|
&& bytes[6] == (byte)'y'
|
|
&& bytes[7] == (byte)'p';
|
|
}
|
|
}
|
|
}
|