security(#283): sniff artwork content type from bytes, remove serve-side ?contentType= reflection #291
@@ -1,5 +1,5 @@
|
||||
using System.Net;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Images;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
@@ -11,7 +11,14 @@ public record ArtworkContentTypeModel(string Path, string ContentType)
|
||||
|
||||
public bool HasContentType => !string.IsNullOrWhiteSpace(ContentType);
|
||||
|
||||
public string UrlWithContentType => string.IsNullOrWhiteSpace(ContentType)
|
||||
? Path
|
||||
: $"{Path}?contentType={WebUtility.UrlEncode(ContentType)}";
|
||||
// The artwork serve routes now sniff the content type from the stored file and no longer honor a
|
||||
// client-supplied ?contentType= (issue #283 — that reflection was the stored-XSS sink), so the
|
||||
// directly-usable URL is just the path.
|
||||
public string UrlWithContentType => Path;
|
||||
|
||||
// Defense-in-depth: never persist a content type outside the image allow-list, so a value that
|
||||
// slipped in via the {path, contentType} JSON DTOs can't later be reflected anywhere. The serve
|
||||
// path derives the type from the file regardless; this only keeps stored metadata honest.
|
||||
public ArtworkContentTypeModel Sanitized() =>
|
||||
ImageContentTypes.IsAccepted(ContentType) ? this : this with { ContentType = string.Empty };
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@ namespace ErsatzTV.Application.Artworks;
|
||||
/// landing it in the same on-disk cache the Blazor UI uses (via <c>IImageCache</c>),
|
||||
/// so the returned path is equivalent to a Blazor-uploaded image.
|
||||
/// </summary>
|
||||
public record UploadArtwork(Stream Stream, string ContentType, ArtworkKind ArtworkKind)
|
||||
public record UploadArtwork(Stream Stream, ArtworkKind ArtworkKind)
|
||||
: IRequest<Either<BaseError, ArtworkUploadResponseModel>>;
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Artwork;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Images;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
|
||||
{
|
||||
// png/jpeg/gif/webp are all decoded by SkiaSharp and read by FFmpeg, matching the
|
||||
// formats the Blazor logo/watermark upload already accepts. Format expansion is ersatztv#66.
|
||||
private static readonly System.Collections.Generic.HashSet<string> AcceptedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp"
|
||||
};
|
||||
|
||||
private readonly IImageCache _imageCache;
|
||||
|
||||
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
|
||||
@@ -25,15 +16,31 @@ public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseEr
|
||||
UploadArtwork request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string contentType = (request.ContentType ?? string.Empty).Trim();
|
||||
if (!AcceptedContentTypes.Contains(contentType))
|
||||
// Buffer the upload so we can sniff its true format before storing it. The request body is
|
||||
// already bounded by the Kestrel MaxRequestBodySize / the controller's size check, so this
|
||||
// is a bounded read.
|
||||
byte[] bytes;
|
||||
await using (var buffer = new MemoryStream())
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Unsupported image content type '{contentType}'; supported types are: {string.Join(", ", AcceptedContentTypes)}");
|
||||
await request.Stream.CopyToAsync(buffer, cancellationToken);
|
||||
bytes = buffer.ToArray();
|
||||
}
|
||||
|
||||
// Derive the content type from the actual bytes, never from the client-declared value
|
||||
// (issue #283 — a spoofed image/png header let a <script> payload be stored and later served
|
||||
// as HTML). A payload that isn't a supported raster image is rejected here.
|
||||
Option<string> maybeContentType = ImageContentTypes.DetectContentType(bytes);
|
||||
if (maybeContentType.IsNone)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Uploaded file is not a supported image; supported types are: {string.Join(", ", ImageContentTypes.Accepted)}");
|
||||
}
|
||||
|
||||
string contentType = maybeContentType.IfNone(string.Empty);
|
||||
|
||||
using var toCache = new MemoryStream(bytes, writable: false);
|
||||
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
|
||||
request.Stream,
|
||||
toCache,
|
||||
request.ArtworkKind);
|
||||
|
||||
return maybeFileName.Map(fileName => new ArtworkUploadResponseModel(
|
||||
|
||||
@@ -3,6 +3,6 @@ using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Images;
|
||||
|
||||
public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, string ContentType, int? MaxHeight = null)
|
||||
public record GetCachedImagePath(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null)
|
||||
: IRequest<
|
||||
Either<BaseError, CachedImagePathViewModel>>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Images;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -95,9 +96,13 @@ public class
|
||||
}
|
||||
else
|
||||
{
|
||||
mimeType = !string.IsNullOrWhiteSpace(request.ContentType)
|
||||
? request.ContentType
|
||||
: MimeTypes.GetMimeTypeFromFile(cachePath).Name;
|
||||
// Always derive the type from the stored file — never from a client-supplied value
|
||||
// (issue #283 — the old ?contentType= reflection was the stored-XSS sink). Clamp the
|
||||
// sniffed type to the image allow-list so a file whose bytes are not an accepted image
|
||||
// (a legacy cache entry poisoned before the upload sniff landed, or a hypothetical
|
||||
// polyglot) is served as a non-renderable download, never as HTML/script.
|
||||
string sniffed = MimeTypes.GetMimeTypeFromFile(cachePath)?.Name;
|
||||
mimeType = ImageContentTypes.IsAccepted(sniffed) ? sniffed : "application/octet-stream";
|
||||
}
|
||||
|
||||
return new CachedImagePathViewModel(cachePath, mimeType);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text;
|
||||
using ErsatzTV.Core.Images;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Images;
|
||||
|
||||
[TestFixture]
|
||||
public class ImageContentTypesTests
|
||||
{
|
||||
[TestCase(SKEncodedImageFormat.Png, "image/png")]
|
||||
[TestCase(SKEncodedImageFormat.Jpeg, "image/jpeg")]
|
||||
[TestCase(SKEncodedImageFormat.Webp, "image/webp")]
|
||||
public void DetectContentType_Should_Sniff_Real_Image_Bytes(SKEncodedImageFormat format, string expected)
|
||||
{
|
||||
byte[] bytes = EncodeTinyImage(format);
|
||||
|
||||
Option<string> detected = ImageContentTypes.DetectContentType(bytes);
|
||||
|
||||
detected.IfNone(string.Empty).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DetectContentType_Should_Reject_Non_Image_Payload()
|
||||
{
|
||||
// The stored-XSS payload from issue #283 — script bytes that a spoofed content type would
|
||||
// otherwise have let through.
|
||||
byte[] html = Encoding.UTF8.GetBytes("<script>alert(document.domain)</script>");
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using SkiaSharp;
|
||||
|
||||
namespace ErsatzTV.Core.Images;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class ImageContentTypes
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<SKEncodedImageFormat, string> FormatContentTypes =
|
||||
new Dictionary<SKEncodedImageFormat, string>
|
||||
{
|
||||
[SKEncodedImageFormat.Png] = "image/png",
|
||||
[SKEncodedImageFormat.Jpeg] = "image/jpeg",
|
||||
[SKEncodedImageFormat.Gif] = "image/gif",
|
||||
[SKEncodedImageFormat.Webp] = "image/webp"
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> Accepted =
|
||||
new System.Collections.Generic.HashSet<string>(FormatContentTypes.Values, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Sniffs the true image content type from <paramref name="bytes" /> 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).
|
||||
/// </summary>
|
||||
public static Option<string> DetectContentType(byte[] bytes)
|
||||
{
|
||||
if (bytes is null || bytes.Length == 0)
|
||||
{
|
||||
return Option<string>.None;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using SKData data = SKData.CreateCopy(bytes);
|
||||
using SKCodec codec = SKCodec.Create(data);
|
||||
if (codec is null)
|
||||
{
|
||||
return Option<string>.None;
|
||||
}
|
||||
|
||||
return FormatContentTypes.TryGetValue(codec.EncodedFormat, out string contentType)
|
||||
? contentType
|
||||
: Option<string>.None;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Option<string>.None;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="contentType" /> is one of the accepted image content types.
|
||||
/// </summary>
|
||||
public static bool IsAccepted(string contentType) =>
|
||||
!string.IsNullOrWhiteSpace(contentType) && Accepted.Contains(contentType.Trim());
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<Stream>(), ArtworkKind.Logo)
|
||||
.Returns(Right<BaseError, string>("abc123.png"));
|
||||
.Returns(Right<BaseError, string>("abc123"));
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var stream = new MemoryStream(PngBytes);
|
||||
Either<BaseError, ArtworkUploadResponseModel> 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<Stream>(), ArtworkKind.Watermark)
|
||||
.Returns(Right<BaseError, string>("def456.webp"));
|
||||
.Returns(Right<BaseError, string>("def456"));
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var stream = new MemoryStream(PngBytes);
|
||||
Either<BaseError, ArtworkUploadResponseModel> 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 <script> payload uploaded under a spoofed image content type. The handler
|
||||
// decodes the bytes, sees no supported image, and rejects it before anything is stored.
|
||||
byte[] htmlBytes = System.Text.Encoding.UTF8.GetBytes("<script>alert(document.domain)</script>");
|
||||
using var stream = new MemoryStream(htmlBytes);
|
||||
|
||||
Either<BaseError, ArtworkUploadResponseModel> 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<Stream>(), Arg.Any<ArtworkKind>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Reject_Empty_Upload()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
Either<BaseError, ArtworkUploadResponseModel> 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<Stream>(), Arg.Any<ArtworkKind>());
|
||||
}
|
||||
|
||||
@@ -71,9 +93,9 @@ public class UploadArtworkHandlerTests
|
||||
_imageCache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Logo)
|
||||
.Returns(Left<BaseError, string>(BaseError.New("disk full")));
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var stream = new MemoryStream(PngBytes);
|
||||
Either<BaseError, ArtworkUploadResponseModel> result = await _handler.Handle(
|
||||
new UploadArtwork(stream, "image/png", ArtworkKind.Logo),
|
||||
new UploadArtwork(stream, ArtworkKind.Logo),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldBe("disk full");
|
||||
|
||||
@@ -113,12 +113,13 @@ public class ArtworkUploadControllerTests
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
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<ArtworkUploadResponseModel>()
|
||||
.Path.ShouldBe("iptv/logos/abc.png");
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UploadArtwork>(c => c.ArtworkKind == ArtworkKind.Logo && c.ContentType == "image/png"),
|
||||
Arg.Is<UploadArtwork>(c => c.ArtworkKind == ArtworkKind.Logo),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
@@ -133,7 +134,7 @@ public class ArtworkUploadControllerTests
|
||||
IActionResult result = await _controller.Upload(file, "watermark", CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/artwork/watermarks/def.webp?contentType=image%2Fwebp");
|
||||
created.Location.ShouldBe("/artwork/watermarks/def.webp");
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UploadArtwork>(c => c.ArtworkKind == ArtworkKind.Watermark),
|
||||
|
||||
@@ -44,11 +44,11 @@ public class ArtworkUploadController(IMediator mediator) : ControllerBase
|
||||
|
||||
await using Stream stream = file.OpenReadStream();
|
||||
Either<BaseError, ArtworkUploadResponseModel> 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}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ public record CreateChannelFromLineupRequest(
|
||||
Number,
|
||||
Group,
|
||||
Categories,
|
||||
Logo,
|
||||
(Logo ?? ArtworkContentTypeModel.None).Sanitized(),
|
||||
IsEnabled,
|
||||
ShowInEpg,
|
||||
TemplateId,
|
||||
|
||||
@@ -45,7 +45,7 @@ public record CreateChannelRequest(
|
||||
Categories,
|
||||
FFmpegProfileId,
|
||||
SlugSeconds,
|
||||
Logo,
|
||||
(Logo ?? ArtworkContentTypeModel.None).Sanitized(),
|
||||
StreamSelectorMode,
|
||||
StreamSelector,
|
||||
PreferredAudioLanguageCode,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,7 +46,7 @@ public record UpdateChannelRequest(
|
||||
Categories,
|
||||
FFmpegProfileId,
|
||||
SlugSeconds,
|
||||
Logo,
|
||||
(Logo ?? ArtworkContentTypeModel.None).Sanitized(),
|
||||
StreamSelectorMode,
|
||||
StreamSelector,
|
||||
PreferredAudioLanguageCode,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -65,7 +65,7 @@ public class ArtworkController : ControllerBase
|
||||
{
|
||||
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
|
||||
await _mediator.Send(
|
||||
new GetCachedImagePath(fileName, ArtworkKind.Poster, string.Empty, 440),
|
||||
new GetCachedImagePath(fileName, ArtworkKind.Poster, 440),
|
||||
cancellationToken);
|
||||
return cachedImagePath.Match<IActionResult>(
|
||||
Left: _ => new NotFoundResult(),
|
||||
@@ -75,13 +75,11 @@ public class ArtworkController : ControllerBase
|
||||
[HttpGet("/artwork/watermarks/{fileName}")]
|
||||
public async Task<IActionResult> GetWatermark(
|
||||
string fileName,
|
||||
[FromQuery]
|
||||
string contentType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
|
||||
await _mediator.Send(
|
||||
new GetCachedImagePath(fileName, ArtworkKind.Watermark, contentType),
|
||||
new GetCachedImagePath(fileName, ArtworkKind.Watermark),
|
||||
cancellationToken);
|
||||
return cachedImagePath.Match<IActionResult>(
|
||||
Left: _ => new NotFoundResult(),
|
||||
@@ -92,7 +90,7 @@ public class ArtworkController : ControllerBase
|
||||
public async Task<IActionResult> GetFanArt(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
|
||||
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.FanArt, string.Empty), cancellationToken);
|
||||
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.FanArt), cancellationToken);
|
||||
return cachedImagePath.Match<IActionResult>(
|
||||
Left: _ => new NotFoundResult(),
|
||||
Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
|
||||
@@ -167,7 +165,7 @@ public class ArtworkController : ControllerBase
|
||||
{
|
||||
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
|
||||
await _mediator.Send(
|
||||
new GetCachedImagePath(fileName, ArtworkKind.Thumbnail, string.Empty, 220),
|
||||
new GetCachedImagePath(fileName, ArtworkKind.Thumbnail, 220),
|
||||
cancellationToken);
|
||||
return cachedImagePath.Match<IActionResult>(
|
||||
Left: _ => new NotFoundResult(),
|
||||
|
||||
@@ -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<IActionResult> GetImage(string fileName, [FromQuery] string contentType)
|
||||
public async Task<IActionResult> GetImage(string fileName)
|
||||
{
|
||||
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
|
||||
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Logo, contentType));
|
||||
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Logo));
|
||||
return cachedImagePath.Match<IActionResult>(
|
||||
Left: _ => new NotFoundResult(),
|
||||
Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 `<script>` as `image/png`, serve it back as
|
||||
`text/html`). The contract:
|
||||
- **Upload**: `POST /api/artwork/uploads` derives the content type from the actual bytes via
|
||||
`ErsatzTV.Core/Images/ImageContentTypes.DetectContentType` (SkiaSharp header sniff — no full
|
||||
decode), rejecting non-images 422. That helper (`Accepted` set + `IsAccepted`) is the **single
|
||||
source of truth** for which image types are allowed — reuse it, don't re-list content types.
|
||||
- **Serve**: the image routes derive the served `Content-Type` from the stored file; there is **no**
|
||||
`?contentType=` query parameter. Never add one back — a client must not be able to choose the
|
||||
`Content-Type` of an unauthenticated response.
|
||||
- **Persisted `{path, contentType}` DTOs** (logo/watermark) run their content type through
|
||||
`ArtworkContentTypeModel.Sanitized()` before storage, blanking anything outside the allow-list.
|
||||
See `docs/decisions.md` 2026-07-12 (#283) for the fuller rationale.
|
||||
|
||||
## 5. OpenAPI regeneration — commit both generated artifacts
|
||||
|
||||
After any controller/DTO change:
|
||||
|
||||
@@ -942,6 +942,42 @@ The larger #197 posture (fail-closed writes, sensitive-read auth tier, CORS lock
|
||||
the OpenAPI security scheme) is decomposed into #280–#289 with the phased roadmap on #197; those PRs will
|
||||
append their own decisions here as they land.
|
||||
|
||||
## 2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)
|
||||
|
||||
The artwork upload/serve path trusted client-supplied content types at both ends, giving a stored-XSS
|
||||
chain on **unauthenticated** GET sinks: upload `<script>` bytes declared `image/png` →
|
||||
`GET /iptv/logos/{hash}?contentType=text/html` served them as HTML in the ErsatzTV origin. The #279
|
||||
`nosniff` header is not a fix here — the server was *explicitly declaring* `text/html`, which the browser
|
||||
honors regardless of `nosniff`. The trust was the bug; the fix removes it at both ends.
|
||||
|
||||
- **Upload derives the content type from the bytes, never the declared value.** `UploadArtworkHandler`
|
||||
buffers the (size-bounded) upload and calls `ErsatzTV.Core/Images/ImageContentTypes.DetectContentType`,
|
||||
which uses SkiaSharp's `SKCodec` to identify the format from the image header only — pixels are **not**
|
||||
decoded, so this can't be turned into a decompression-bomb vector. A payload that isn't one of the
|
||||
accepted raster formats (png/jpeg/gif/webp) is rejected 422; the declared `Content-Type` is no longer
|
||||
read at all (the field was dropped from the `UploadArtwork` command).
|
||||
- **Serve sniffs the stored file; the `?contentType=` reflection is gone.** `GetCachedImagePath` no longer
|
||||
carries a `ContentType`, and `GetImage` (`/iptv/logos`) / `GetWatermark` (`/artwork/watermarks`) dropped
|
||||
their `[FromQuery] contentType` binding. `GetCachedImagePathHandler` always derives the MIME type from the
|
||||
file (`MimeTypes.GetMimeTypeFromFile`) and **clamps it to the image allow-list** (`ImageContentTypes.IsAccepted`),
|
||||
serving `application/octet-stream` for anything else — so a file whose bytes are not an accepted image (a
|
||||
legacy cache entry poisoned before the upload sniff landed, or a hypothetical polyglot) is a non-renderable
|
||||
download, never HTML/script. The removal is **structural** — there is no longer any request path that lets a
|
||||
client choose the served `Content-Type`. `ArtworkContentTypeModel.UrlWithContentType` now returns the bare path, and the SPA
|
||||
watermark/logo previews no longer append the query.
|
||||
- **Defense-in-depth on the persisted JSON DTOs.** The `{path, contentType}` bodies (channel logo, watermark)
|
||||
run their content type through `ArtworkContentTypeModel.Sanitized()`, which blanks anything outside the
|
||||
image allow-list before it is stored — so a stale/hostile value can't be reflected by any future code path
|
||||
even though the serve route already ignores it.
|
||||
- **S9 upload-size DoS.** Kestrel `Limits.MaxRequestBodySize` is now set from `ETV_MAXIMUM_UPLOAD_MB`, so an
|
||||
oversized body is rejected as it is read rather than only after the controller's post-binding `file.Length`
|
||||
check (kept as the friendly-error backstop). This is a global bound; the app has no other large inbound
|
||||
body (streaming is outbound GET).
|
||||
|
||||
`ImageContentTypes` is the single source of truth for the accepted image types (the allow-list previously
|
||||
duplicated in `UploadArtworkHandler`). Both serve sinks are `[ApiExplorerSettings(IgnoreApi = true)]`, so
|
||||
none of this changes the OpenAPI document.
|
||||
|
||||
## 2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)
|
||||
|
||||
Phase-1 of the #197 remediation — the auth posture that must land before any remote exposure.
|
||||
|
||||
@@ -79,8 +79,8 @@ describe('watermarks api client', () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/watermarks/9', expect.objectContaining({ method: 'DELETE' }));
|
||||
});
|
||||
|
||||
it('watermarkImageUrl encodes the content type query', () => {
|
||||
expect(watermarkImageUrl('abc.png', 'image/png')).toBe('/artwork/watermarks/abc.png?contentType=image%2Fpng');
|
||||
it('watermarkImageUrl builds a path-only URL (no reflected ?contentType=)', () => {
|
||||
// The serve route sniffs the stored file; the client no longer supplies a content type (#283).
|
||||
expect(watermarkImageUrl('abc.png')).toBe('/artwork/watermarks/abc.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,13 +25,11 @@ export function deleteWatermark(id: number): Promise<void> {
|
||||
return request<void>(`/api/watermarks/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Preview URL for a custom watermark image. Mirrors the Blazor editor
|
||||
// (WatermarkEditor.razor) which renders `artwork/watermarks/{path}?contentType=...` via
|
||||
// ArtworkContentTypeModel.UrlWithContentType.
|
||||
export function watermarkImageUrl(path: string, contentType?: null | string): string {
|
||||
return contentType
|
||||
? `/artwork/watermarks/${path}?contentType=${encodeURIComponent(contentType)}`
|
||||
: `/artwork/watermarks/${path}`;
|
||||
// Preview URL for a custom watermark image. The serve route sniffs the content type from the
|
||||
// stored file and no longer honors a client-supplied `?contentType=` (issue #283 — that reflection
|
||||
// was the stored-XSS sink), so the URL is just the path.
|
||||
export function watermarkImageUrl(path: string): string {
|
||||
return `/artwork/watermarks/${path}`;
|
||||
}
|
||||
|
||||
export function messageFromWatermarkError(error: unknown, fallback = 'Unable to load watermarks'): string {
|
||||
|
||||
@@ -530,7 +530,7 @@ function WatermarkEditor({ mode }: { mode: { id: number; kind: 'edit' } | { from
|
||||
{custom && draft.image && (
|
||||
<img
|
||||
alt="Watermark preview"
|
||||
src={watermarkImageUrl(draft.image, draft.imageContentType)}
|
||||
src={watermarkImageUrl(draft.image)}
|
||||
style={{ maxHeight: 60, maxWidth: 200, objectFit: 'contain' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user