security(#283): sniff artwork content type from bytes, remove serve-side ?contentType= reflection #291

Merged
timothy merged 5 commits from fix/283-artwork-content-type-xss into main 2026-07-12 01:00:55 +02:00
24 changed files with 330 additions and 75 deletions
@@ -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();
}
}
+64
View File
@@ -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,
+4 -6
View File
@@ -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(),
+2 -2
View File
@@ -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));
+5
View File
@@ -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();
+16
View File
@@ -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:
+36
View File
@@ -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.
+2 -2
View File
@@ -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');
});
});
+5 -7
View File
@@ -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 {
+1 -1
View File
@@ -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' }}
/>
)}