mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 04:53:11 +00:00
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:
parent
dc251c42d4
commit
07bc81cc52
6 changed files with 103 additions and 30 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue