---
key: security.artwork-content-type-sniff
title: 2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)
status: active
since: '2026-07-12'
supersedes: none
superseded-by: none
rule: Artwork content type is always derived from the stored bytes (never the client-declared value or a `?contentType=` query param) at both upload and serve, clamped to an image allow-list, closing the unauthenticated stored-XSS chain; Kestrel `MaxRequestBodySize` bounds upload DoS.
signals: 'stored XSS, content-type sniffing, artwork upload/serve · paths: `ErsatzTV.Core/Images/ImageContentTypes.DetectContentType`, `GetCachedImagePathHandler` · issues: #283, S4, S9'
mechanics: '`ErsatzTV.Core/Images/ImageContentTypes`'
---

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.
