fix(media): size uploads by the validated content type

A misleading file name could move a file into a larger size class: a real
PDF or JPEG named .mp4/.mov got the 100 MB video cap on the vendor portal,
and a .jpg declared with a foreign video type got it on the media endpoint.
Classify by the same resolved type the signature check validates; the
extension only decides when no allowlisted type is known.
This commit is contained in:
Alexandre Brandizzi 2026-09-24 21:18:00 -03:00
parent dc251c42d4
commit 07bc81cc52
6 changed files with 103 additions and 30 deletions

View file

@ -431,6 +431,28 @@ public sealed class VendorPortalDocumentTests : IDisposable
(await context.VendorCompletionDocuments.SingleAsync()).ContentType.Should().Be("image/jpeg");
}
[Theory]
// Genuine PDF/JPEG bytes and declared type, but a video file name: the size class must
// follow the validated type, not the name, or the document/photo caps are bypassed.
[InlineData("report.mp4", "application/pdf", 12_000_000, new byte[] { 0x25, 0x50, 0x44, 0x46 })]
[InlineData("site.mov", "image/jpeg", 11_000_000, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 })]
public async Task UploadCompletionDocument_VideoExtensionDoesNotWidenSizeCap(
string fileName,
string contentType,
int size,
byte[] header)
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(MediaBytes(size, header), fileName, contentType));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes()
{

View file

@ -91,6 +91,27 @@ public class WorkOrderMediaControllerTests
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_PhotoDeclaredAsForeignVideoType_IsSizedAsAPhoto()
{
// A .jpg with a foreign video type resolves to image/jpeg for validation, so it must
// also be sized as a photo, not given the 100 MB video allowance.
var file = new Mock<IFormFile>();
file.SetupGet(candidate => candidate.Length).Returns(60_000_000);
file.SetupGet(candidate => candidate.FileName).Returns("photo.jpg");
file.SetupGet(candidate => candidate.ContentType).Returns("video/3gpp");
var storage = new Mock<IFileStoragePort>(MockBehavior.Strict);
var controller = CreateController(storage: storage);
var result = await controller.AddMedia(1, null, file.Object, CancellationToken.None);
var response = Assert.IsType<UnprocessableEntityObjectResult>(result);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(response.Value);
Assert.Equal("FileTooLarge", error.Code);
Assert.Equal("Photos must be 10 MB or smaller.", error.Message);
storage.VerifyNoOtherCalls();
}
[Fact]
public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity()
{

View file

@ -89,8 +89,9 @@ namespace Api.SeaHavenIndustries.Controllers
try
{
// SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB).
// Classify by the same resolved type EnsureAllowed validates against.
var sizeMessage = WorkOrderMediaContract.ValidateSize(
file.ContentType, file.FileName, file.Length);
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length);
if (sizeMessage != null)
{
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);

View file

@ -33,34 +33,51 @@ namespace SeaHaven.Services.Helpers
Unknown
}
private static readonly Dictionary<string, UploadKind> KindByContentType =
new(StringComparer.OrdinalIgnoreCase)
{
["image/jpeg"] = UploadKind.Photo,
["image/jpg"] = UploadKind.Photo,
["image/png"] = UploadKind.Photo,
["image/heic"] = UploadKind.Photo,
["video/mp4"] = UploadKind.Video,
["video/quicktime"] = UploadKind.Video,
["application/pdf"] = UploadKind.Document,
["application/msword"] = UploadKind.Document,
["application/vnd.openxmlformats-officedocument.wordprocessingml.document"] =
UploadKind.Document,
};
private static readonly Dictionary<string, UploadKind> KindByExtension =
new(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = UploadKind.Photo,
[".jpeg"] = UploadKind.Photo,
[".png"] = UploadKind.Photo,
[".heic"] = UploadKind.Photo,
[".mp4"] = UploadKind.Video,
[".mov"] = UploadKind.Video,
[".pdf"] = UploadKind.Document,
[".doc"] = UploadKind.Document,
[".docx"] = UploadKind.Document,
};
/// <summary>
/// Classifies an upload. Pass the validated (resolved) content type: it decides whenever
/// it is an allowlisted type, so a misleading file name cannot move a file into a larger
/// size class. The extension only decides when no allowlisted type is known (for example
/// counting stored attachment URLs).
/// </summary>
public static UploadKind ResolveKind(string? contentType, string? fileName)
{
var type = (contentType ?? string.Empty).Trim();
if (KindByContentType.TryGetValue(type, out var byType))
return byType;
var extension = Path.GetExtension(fileName ?? string.Empty);
if (type.StartsWith("video/", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".mov", StringComparison.OrdinalIgnoreCase))
return UploadKind.Video;
if (type.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".png", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".heic", StringComparison.OrdinalIgnoreCase))
return UploadKind.Photo;
if (type.Equals("application/pdf", StringComparison.OrdinalIgnoreCase)
|| type.Equals("application/msword", StringComparison.OrdinalIgnoreCase)
|| type.Equals(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".doc", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".docx", StringComparison.OrdinalIgnoreCase))
return UploadKind.Document;
return UploadKind.Unknown;
return KindByExtension.TryGetValue(extension, out var byExtension)
? byExtension
: UploadKind.Unknown;
}
/// <summary>Stable, generic per-kind size message; never echoes file metadata.</summary>

View file

@ -67,6 +67,19 @@ namespace SeaHaven.Services.Helpers
return contentType;
}
/// <summary>
/// The canonical content type <see cref="IsAllowed"/> validates the file as, or null when
/// no allowlisted type applies. Size classification must use this same type so a declared
/// type or a misleading extension cannot move a file into a larger size class.
/// </summary>
public static string? ResolveUploadContentType(IFormFile file)
{
var declaredType = (file.ContentType ?? string.Empty).Trim();
var extension = Path.GetExtension(file.FileName ?? string.Empty);
var resolvedType = ResolveContentType(declaredType, extension);
return resolvedType == null ? null : CanonicalContentType(resolvedType);
}
public static bool IsAllowed(
IFormFile file,
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
@ -74,13 +87,11 @@ namespace SeaHaven.Services.Helpers
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)
var contentType = ResolveUploadContentType(file);
if (contentType == null)
return false;
var contentType = CanonicalContentType(resolvedType);
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
if (IsDocument(contentType)
&& resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta)

View file

@ -856,7 +856,8 @@ namespace SeaHaven.Services.Implementation
throw new InvalidOperationException("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are accepted.");
}
var kind = WorkOrderMediaContract.ResolveKind(contentType, file.FileName);
// Classify by the resolved type the signature check validates, never by the file name.
var kind = WorkOrderMediaContract.ResolveKind(contentType, fileName: null);
if (kind == WorkOrderMediaContract.UploadKind.Photo
&& file.Length > WorkOrderMediaContract.MaxPhotoBytes)
{