From cf834d8b60ec707fe077cda2083f03c674a57686 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 12 Jul 2026 00:07:55 +0200 Subject: [PATCH 1/4] security(#283): sniff artwork content type from bytes, remove serve-side ?contentType= reflection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S4 stored-XSS + S9 upload-size DoS from the #197 cold API review. The artwork path trusted client-supplied content types at both ends: upload validated only the declared multipart Content-Type (never decoded the bytes), and serving reflected a client `?contentType=` straight into the response Content-Type on unauthenticated GET sinks (/iptv/logos, /artwork/watermarks). Chain: upload "); + + ImageContentTypes.DetectContentType(html).IsNone.ShouldBeTrue(); + } + + [Test] + public void DetectContentType_Should_Return_None_For_Empty_Or_Null() + { + ImageContentTypes.DetectContentType([]).IsNone.ShouldBeTrue(); + ImageContentTypes.DetectContentType(null).IsNone.ShouldBeTrue(); + } + + [TestCase("image/png", true)] + [TestCase("image/jpeg", true)] + [TestCase("image/gif", true)] + [TestCase("image/webp", true)] + [TestCase("IMAGE/PNG", true)] + [TestCase("text/html", false)] + [TestCase("image/svg+xml", false)] + [TestCase("", false)] + [TestCase(null, false)] + public void IsAccepted_Should_Allow_Only_Raster_Image_Types(string contentType, bool expected) => + ImageContentTypes.IsAccepted(contentType).ShouldBe(expected); + + private static byte[] EncodeTinyImage(SKEncodedImageFormat format) + { + using var bitmap = new SKBitmap(2, 2); + using SKImage image = SKImage.FromBitmap(bitmap); + using SKData data = image.Encode(format, 100); + return data.ToArray(); + } +} diff --git a/ErsatzTV.Core/Images/ImageContentTypes.cs b/ErsatzTV.Core/Images/ImageContentTypes.cs new file mode 100644 index 000000000..26acd47c2 --- /dev/null +++ b/ErsatzTV.Core/Images/ImageContentTypes.cs @@ -0,0 +1,64 @@ +using SkiaSharp; + +namespace ErsatzTV.Core.Images; + +/// +/// The single source of truth for which image content types the artwork surfaces accept, plus a +/// byte-sniffing detector used to derive the true content type from an upload's actual contents +/// rather than trusting the client-declared value (issue #283 — stored XSS via a spoofed +/// content type). png/jpeg/gif/webp are the formats SkiaSharp decodes and FFmpeg reads, matching +/// what the artwork upload has always accepted; format expansion is tracked by ersatztv#66. +/// +public static class ImageContentTypes +{ + private static readonly IReadOnlyDictionary FormatContentTypes = + new Dictionary + { + [SKEncodedImageFormat.Png] = "image/png", + [SKEncodedImageFormat.Jpeg] = "image/jpeg", + [SKEncodedImageFormat.Gif] = "image/gif", + [SKEncodedImageFormat.Webp] = "image/webp" + }; + + public static readonly IReadOnlySet Accepted = + new System.Collections.Generic.HashSet(FormatContentTypes.Values, StringComparer.OrdinalIgnoreCase); + + /// + /// Sniffs the true image content type from using SkiaSharp's codec + /// detection. Only the image header is parsed to identify the format — pixels are never + /// decoded here, so a crafted decompression-bomb image cannot be triggered by this call. + /// Returns None for anything that is not one of the accepted raster formats (including + /// non-image payloads such as HTML/script uploaded under a spoofed content type). + /// + public static Option DetectContentType(byte[] bytes) + { + if (bytes is null || bytes.Length == 0) + { + return Option.None; + } + + try + { + using SKData data = SKData.CreateCopy(bytes); + using SKCodec codec = SKCodec.Create(data); + if (codec is null) + { + return Option.None; + } + + return FormatContentTypes.TryGetValue(codec.EncodedFormat, out string contentType) + ? contentType + : Option.None; + } + catch (Exception) + { + return Option.None; + } + } + + /// + /// True when is one of the accepted image content types. + /// + public static bool IsAccepted(string contentType) => + !string.IsNullOrWhiteSpace(contentType) && Accepted.Contains(contentType.Trim()); +} diff --git a/ErsatzTV.Tests/Application/Artworks/ArtworkContentTypeModelTests.cs b/ErsatzTV.Tests/Application/Artworks/ArtworkContentTypeModelTests.cs new file mode 100644 index 000000000..2c61d764e --- /dev/null +++ b/ErsatzTV.Tests/Application/Artworks/ArtworkContentTypeModelTests.cs @@ -0,0 +1,39 @@ +using ErsatzTV.Application.Artworks; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Artworks; + +[TestFixture] +public class ArtworkContentTypeModelTests +{ + [Test] + public void Sanitized_Should_Keep_An_Allow_Listed_Content_Type() + { + var model = new ArtworkContentTypeModel("iptv/logos/abc", "image/png"); + + model.Sanitized().ShouldBe(model); + } + + [TestCase("text/html")] + [TestCase("application/javascript")] + [TestCase("image/svg+xml")] + [TestCase("")] + public void Sanitized_Should_Blank_A_Non_Allow_Listed_Content_Type(string contentType) + { + var model = new ArtworkContentTypeModel("iptv/logos/abc", contentType); + + ArtworkContentTypeModel sanitized = model.Sanitized(); + + // Path is preserved; only the untrusted content type is neutralized. + sanitized.Path.ShouldBe("iptv/logos/abc"); + sanitized.ContentType.ShouldBe(string.Empty); + } + + [Test] + public void UrlWithContentType_Should_Be_Just_The_Path() + { + // The serve route no longer honors ?contentType= (issue #283), so the URL carries no query. + new ArtworkContentTypeModel("iptv/logos/abc", "image/png").UrlWithContentType.ShouldBe("iptv/logos/abc"); + } +} diff --git a/ErsatzTV.Tests/Application/Artworks/UploadArtworkHandlerTests.cs b/ErsatzTV.Tests/Application/Artworks/UploadArtworkHandlerTests.cs index 3a4792a90..35c8e05fd 100644 --- a/ErsatzTV.Tests/Application/Artworks/UploadArtworkHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Artworks/UploadArtworkHandlerTests.cs @@ -14,6 +14,11 @@ namespace ErsatzTV.Tests.Application.Artworks; [TestFixture] public class UploadArtworkHandlerTests { + // A minimal valid 1x1 PNG. The handler derives the content type from bytes like these, never + // from a client-declared value (issue #283), so the tests exercise the real sniffer. + private static readonly byte[] PngBytes = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMCAoAB/QwAAAAASUVORK5CYII="); + private IImageCache _imageCache = null!; private UploadArtworkHandler _handler = null!; @@ -25,17 +30,18 @@ public class UploadArtworkHandlerTests } [Test] - public async Task Handle_Should_Return_Logo_Path_With_Iptv_Logos_Prefix() + public async Task Handle_Should_Store_Logo_And_Return_Sniffed_ContentType() { _imageCache.SaveArtworkToCache(Arg.Any(), ArtworkKind.Logo) - .Returns(Right("abc123.png")); + .Returns(Right("abc123")); - using var stream = new MemoryStream(); + using var stream = new MemoryStream(PngBytes); Either result = - await _handler.Handle(new UploadArtwork(stream, "image/png", ArtworkKind.Logo), CancellationToken.None); + await _handler.Handle(new UploadArtwork(stream, ArtworkKind.Logo), CancellationToken.None); ArtworkUploadResponseModel response = RightOf(result); - response.Path.ShouldBe("iptv/logos/abc123.png"); + response.Path.ShouldBe("iptv/logos/abc123"); + // Content type comes from the actual PNG bytes, not from anything the client said. response.ContentType.ShouldBe("image/png"); } @@ -43,25 +49,41 @@ public class UploadArtworkHandlerTests public async Task Handle_Should_Return_Bare_File_Name_For_Watermark() { _imageCache.SaveArtworkToCache(Arg.Any(), ArtworkKind.Watermark) - .Returns(Right("def456.webp")); + .Returns(Right("def456")); - using var stream = new MemoryStream(); + using var stream = new MemoryStream(PngBytes); Either result = await _handler.Handle( - new UploadArtwork(stream, "image/webp", ArtworkKind.Watermark), + new UploadArtwork(stream, ArtworkKind.Watermark), CancellationToken.None); - RightOf(result).Path.ShouldBe("def456.webp"); + RightOf(result).Path.ShouldBe("def456"); } [Test] - public async Task Handle_Should_Reject_Unsupported_Content_Type() + public async Task Handle_Should_Reject_Non_Image_Payload_Spoofed_As_Image() + { + // The XSS chain: a "); + using var stream = new MemoryStream(htmlBytes); + + Either result = await _handler.Handle( + new UploadArtwork(stream, ArtworkKind.Logo), + CancellationToken.None); + + LeftOf(result).Value.ShouldContain("not a supported image"); + await _imageCache.DidNotReceive().SaveArtworkToCache(Arg.Any(), Arg.Any()); + } + + [Test] + public async Task Handle_Should_Reject_Empty_Upload() { using var stream = new MemoryStream(); Either result = await _handler.Handle( - new UploadArtwork(stream, "image/bmp", ArtworkKind.Logo), + new UploadArtwork(stream, ArtworkKind.Logo), CancellationToken.None); - LeftOf(result).Value.ShouldContain("Unsupported image content type"); + LeftOf(result).Value.ShouldContain("not a supported image"); await _imageCache.DidNotReceive().SaveArtworkToCache(Arg.Any(), Arg.Any()); } @@ -71,9 +93,9 @@ public class UploadArtworkHandlerTests _imageCache.SaveArtworkToCache(Arg.Any(), ArtworkKind.Logo) .Returns(Left(BaseError.New("disk full"))); - using var stream = new MemoryStream(); + using var stream = new MemoryStream(PngBytes); Either result = await _handler.Handle( - new UploadArtwork(stream, "image/png", ArtworkKind.Logo), + new UploadArtwork(stream, ArtworkKind.Logo), CancellationToken.None); LeftOf(result).Value.ShouldBe("disk full"); diff --git a/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs b/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs index 8d2f44457..0c507ddba 100644 --- a/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ArtworkUploadControllerTests.cs @@ -113,12 +113,13 @@ public class ArtworkUploadControllerTests var created = result.ShouldBeOfType(); created.StatusCode.ShouldBe(201); - created.Location.ShouldBe("/iptv/logos/abc.png?contentType=image%2Fpng"); + // No ?contentType= — the serve route sniffs the stored file (issue #283). + created.Location.ShouldBe("/iptv/logos/abc.png"); created.Value.ShouldBeOfType() .Path.ShouldBe("iptv/logos/abc.png"); await _mediator.Received(1).Send( - Arg.Is(c => c.ArtworkKind == ArtworkKind.Logo && c.ContentType == "image/png"), + Arg.Is(c => c.ArtworkKind == ArtworkKind.Logo), Arg.Any()); } @@ -133,7 +134,7 @@ public class ArtworkUploadControllerTests IActionResult result = await _controller.Upload(file, "watermark", CancellationToken.None); var created = result.ShouldBeOfType(); - created.Location.ShouldBe("/artwork/watermarks/def.webp?contentType=image%2Fwebp"); + created.Location.ShouldBe("/artwork/watermarks/def.webp"); await _mediator.Received(1).Send( Arg.Is(c => c.ArtworkKind == ArtworkKind.Watermark), diff --git a/ErsatzTV/Controllers/Api/ArtworkUploadController.cs b/ErsatzTV/Controllers/Api/ArtworkUploadController.cs index 487820a73..aead9f0f1 100644 --- a/ErsatzTV/Controllers/Api/ArtworkUploadController.cs +++ b/ErsatzTV/Controllers/Api/ArtworkUploadController.cs @@ -44,11 +44,11 @@ public class ArtworkUploadController(IMediator mediator) : ControllerBase await using Stream stream = file.OpenReadStream(); Either result = await mediator.Send( - new UploadArtwork(stream, file.ContentType, artworkKind), + new UploadArtwork(stream, artworkKind), cancellationToken); return result.ToCreatedResult( - value => LocationFor(artworkKind, value.Path, value.ContentType), + value => LocationFor(artworkKind, value.Path), value => value); } @@ -70,16 +70,13 @@ public class ArtworkUploadController(IMediator mediator) : ControllerBase } } - // Both GetImage (IptvController) and GetWatermark (ArtworkController) require a contentType - // query param to serve the cached file, so the Location header must carry it too. - private static string LocationFor(ArtworkKind artworkKind, string path, string contentType) - { - string encodedContentType = Uri.EscapeDataString(contentType); - return artworkKind switch + // The serve routes (GetImage / GetWatermark) sniff the content type from the stored file, so the + // Location no longer carries a ?contentType= (issue #283 — that reflection was the XSS sink). + private static string LocationFor(ArtworkKind artworkKind, string path) => + artworkKind switch { // logo paths already carry the servable prefix ("iptv/logos/{file}") - ArtworkKind.Logo => $"/{path}?contentType={encodedContentType}", - _ => $"/artwork/watermarks/{path}?contentType={encodedContentType}" + ArtworkKind.Logo => $"/{path}", + _ => $"/artwork/watermarks/{path}" }; - } } diff --git a/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs index 60df675b4..f901b72e6 100644 --- a/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs +++ b/ErsatzTV/Controllers/Api/Requests/CreateChannelFromLineupRequest.cs @@ -32,7 +32,7 @@ public record CreateChannelFromLineupRequest( Number, Group, Categories, - Logo, + (Logo ?? ArtworkContentTypeModel.None).Sanitized(), IsEnabled, ShowInEpg, TemplateId, diff --git a/ErsatzTV/Controllers/Api/Requests/CreateChannelRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateChannelRequest.cs index c1fafb073..fb2d64e3a 100644 --- a/ErsatzTV/Controllers/Api/Requests/CreateChannelRequest.cs +++ b/ErsatzTV/Controllers/Api/Requests/CreateChannelRequest.cs @@ -45,7 +45,7 @@ public record CreateChannelRequest( Categories, FFmpegProfileId, SlugSeconds, - Logo, + (Logo ?? ArtworkContentTypeModel.None).Sanitized(), StreamSelectorMode, StreamSelector, PreferredAudioLanguageCode, diff --git a/ErsatzTV/Controllers/Api/Requests/CreateWatermarkRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateWatermarkRequest.cs index a5898e4d7..d5dbd80be 100644 --- a/ErsatzTV/Controllers/Api/Requests/CreateWatermarkRequest.cs +++ b/ErsatzTV/Controllers/Api/Requests/CreateWatermarkRequest.cs @@ -27,7 +27,7 @@ public record CreateWatermarkRequest( public CreateWatermark ToCommand() => new( Name, - new ArtworkContentTypeModel(Image ?? string.Empty, ImageContentType ?? string.Empty), + new ArtworkContentTypeModel(Image ?? string.Empty, ImageContentType ?? string.Empty).Sanitized(), Mode, ImageSource, Location, diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs index f028084e6..1cce873f2 100644 --- a/ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs +++ b/ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs @@ -46,7 +46,7 @@ public record UpdateChannelRequest( Categories, FFmpegProfileId, SlugSeconds, - Logo, + (Logo ?? ArtworkContentTypeModel.None).Sanitized(), StreamSelectorMode, StreamSelector, PreferredAudioLanguageCode, diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateWatermarkRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateWatermarkRequest.cs index d999d42c1..ad2f49653 100644 --- a/ErsatzTV/Controllers/Api/Requests/UpdateWatermarkRequest.cs +++ b/ErsatzTV/Controllers/Api/Requests/UpdateWatermarkRequest.cs @@ -28,7 +28,7 @@ public record UpdateWatermarkRequest( new( id, Name, - new ArtworkContentTypeModel(Image ?? string.Empty, ImageContentType ?? string.Empty), + new ArtworkContentTypeModel(Image ?? string.Empty, ImageContentType ?? string.Empty).Sanitized(), Mode, ImageSource, Location, diff --git a/ErsatzTV/Controllers/ArtworkController.cs b/ErsatzTV/Controllers/ArtworkController.cs index 17d714cb9..2e8c9afc5 100644 --- a/ErsatzTV/Controllers/ArtworkController.cs +++ b/ErsatzTV/Controllers/ArtworkController.cs @@ -65,7 +65,7 @@ public class ArtworkController : ControllerBase { Either cachedImagePath = await _mediator.Send( - new GetCachedImagePath(fileName, ArtworkKind.Poster, string.Empty, 440), + new GetCachedImagePath(fileName, ArtworkKind.Poster, 440), cancellationToken); return cachedImagePath.Match( Left: _ => new NotFoundResult(), @@ -75,13 +75,11 @@ public class ArtworkController : ControllerBase [HttpGet("/artwork/watermarks/{fileName}")] public async Task GetWatermark( string fileName, - [FromQuery] - string contentType, CancellationToken cancellationToken) { Either cachedImagePath = await _mediator.Send( - new GetCachedImagePath(fileName, ArtworkKind.Watermark, contentType), + new GetCachedImagePath(fileName, ArtworkKind.Watermark), cancellationToken); return cachedImagePath.Match( Left: _ => new NotFoundResult(), @@ -92,7 +90,7 @@ public class ArtworkController : ControllerBase public async Task GetFanArt(string fileName, CancellationToken cancellationToken) { Either cachedImagePath = - await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.FanArt, string.Empty), cancellationToken); + await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.FanArt), cancellationToken); return cachedImagePath.Match( Left: _ => new NotFoundResult(), Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); @@ -167,7 +165,7 @@ public class ArtworkController : ControllerBase { Either cachedImagePath = await _mediator.Send( - new GetCachedImagePath(fileName, ArtworkKind.Thumbnail, string.Empty, 220), + new GetCachedImagePath(fileName, ArtworkKind.Thumbnail, 220), cancellationToken); return cachedImagePath.Match( Left: _ => new NotFoundResult(), diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index d70ed71af..8868577d7 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -283,10 +283,10 @@ public class IptvController : StreamingControllerBase [HttpGet("iptv/logos/{fileName}")] [HttpHead("iptv/logos/{fileName}.jpg")] [HttpGet("iptv/logos/{fileName}.jpg")] - public async Task GetImage(string fileName, [FromQuery] string contentType) + public async Task GetImage(string fileName) { Either cachedImagePath = - await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Logo, contentType)); + await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Logo)); return cachedImagePath.Match( Left: _ => new NotFoundResult(), Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); diff --git a/ErsatzTV/Program.cs b/ErsatzTV/Program.cs index 2bfcae1f0..02e1ada54 100644 --- a/ErsatzTV/Program.cs +++ b/ErsatzTV/Program.cs @@ -172,6 +172,11 @@ public class Program } options.AddServerHeader = false; + + // Bound the request body to the configured upload cap so an oversized (e.g. + // multipart artwork) body is rejected as it's read, before it is buffered + // (issue #283 S9 — the controller's file.Length check only fired after binding). + options.Limits.MaxRequestBodySize = (long)SystemEnvironment.MaximumUploadMb * 1024 * 1024; }) .UseContentRoot(BasePath)) .UseSerilog(); diff --git a/docs/api-conventions.md b/docs/api-conventions.md index fb0543a37..1c4f440cf 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -216,6 +216,22 @@ Do **not** reuse the Application-layer Mappers used by Blazor (e.g. `MediaCards` mappers) for new API DTOs — those still return the old Blazor-convention relative paths. Map from the domain/VM directly and root the path yourself, following the PR #181 pattern. +### 4a. Artwork content type is sniffed, never client-supplied (issue #283) + +The uploaded-artwork surfaces (channel logo, watermark) must never trust a client-declared content +type — doing so was a stored-XSS chain (upload `