mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +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");
|
(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]
|
[Fact]
|
||||||
public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes()
|
public async Task UploadCompletionDocument_RejectsVideoOverHundredMegabytes()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,27 @@ public class WorkOrderMediaControllerTests
|
||||||
storage.VerifyNoOtherCalls();
|
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]
|
[Fact]
|
||||||
public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity()
|
public async Task AddMedia_DocumentOverFiftyMegabytes_ReturnsStableUnprocessableEntity()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// SH-116 contract: per-kind caps (photos 10 MB, videos 100 MB, documents 50 MB).
|
// 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(
|
var sizeMessage = WorkOrderMediaContract.ValidateSize(
|
||||||
file.ContentType, file.FileName, file.Length);
|
WorkOrderMediaFileRules.ResolveUploadContentType(file), file.FileName, file.Length);
|
||||||
if (sizeMessage != null)
|
if (sizeMessage != null)
|
||||||
{
|
{
|
||||||
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);
|
throw new WorkOrderBoardValidationException("FileTooLarge", sizeMessage);
|
||||||
|
|
|
||||||
|
|
@ -33,34 +33,51 @@ namespace SeaHaven.Services.Helpers
|
||||||
Unknown
|
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)
|
public static UploadKind ResolveKind(string? contentType, string? fileName)
|
||||||
{
|
{
|
||||||
var type = (contentType ?? string.Empty).Trim();
|
var type = (contentType ?? string.Empty).Trim();
|
||||||
|
if (KindByContentType.TryGetValue(type, out var byType))
|
||||||
|
return byType;
|
||||||
|
|
||||||
var extension = Path.GetExtension(fileName ?? string.Empty);
|
var extension = Path.GetExtension(fileName ?? string.Empty);
|
||||||
|
return KindByExtension.TryGetValue(extension, out var byExtension)
|
||||||
if (type.StartsWith("video/", StringComparison.OrdinalIgnoreCase)
|
? byExtension
|
||||||
|| extension.Equals(".mp4", StringComparison.OrdinalIgnoreCase)
|
: UploadKind.Unknown;
|
||||||
|| 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Stable, generic per-kind size message; never echoes file metadata.</summary>
|
/// <summary>Stable, generic per-kind size message; never echoes file metadata.</summary>
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,19 @@ namespace SeaHaven.Services.Helpers
|
||||||
return contentType;
|
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(
|
public static bool IsAllowed(
|
||||||
IFormFile file,
|
IFormFile file,
|
||||||
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
|
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
|
||||||
|
|
@ -74,13 +87,11 @@ namespace SeaHaven.Services.Helpers
|
||||||
if (file == null || file.Length <= 0)
|
if (file == null || file.Length <= 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var declaredType = (file.ContentType ?? string.Empty).Trim();
|
|
||||||
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
var extension = Path.GetExtension(file.FileName ?? string.Empty);
|
||||||
var resolvedType = ResolveContentType(declaredType, extension);
|
var contentType = ResolveUploadContentType(file);
|
||||||
if (resolvedType == null)
|
if (contentType == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var contentType = CanonicalContentType(resolvedType);
|
|
||||||
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
|
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
|
||||||
if (IsDocument(contentType)
|
if (IsDocument(contentType)
|
||||||
&& resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta)
|
&& 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.");
|
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
|
if (kind == WorkOrderMediaContract.UploadKind.Photo
|
||||||
&& file.Length > WorkOrderMediaContract.MaxPhotoBytes)
|
&& file.Length > WorkOrderMediaContract.MaxPhotoBytes)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue