Closes#283 (S4 + S9 from the #197 cold API review). Supersedes #66.
Root cause
The artwork path trusted client-supplied content types at both ends:
Upload (UploadArtworkHandler) validated only the declared multipart Content-Type string — it never decoded the bytes. <script> bytes labeled image/png passed and were stored verbatim.
Serve (GetImage/iptv/logos, GetWatermark/artwork/watermarks) reflected a client ?contentType= straight into the response Content-Type on unauthenticated GET sinks.
Chain: upload <script> as image/png → GET /iptv/logos/{hash}?contentType=text/html serves it as HTML in the ErsatzTV origin → stored XSS. The #279nosniff header does not cover this — the server was explicitly declaringtext/html, which the browser honors regardless.
Fix
Upload derives the type from the bytes. New ErsatzTV.Core/Images/ImageContentTypes.DetectContentType uses SkiaSharp SKCodec (header sniff only — pixels are never decoded, so no decompression-bomb path) to identify the true format; non-images are rejected 422. The untrusted declared Content-Type was dropped from the UploadArtwork command. ImageContentTypes is now the single allow-list source (png/jpeg/gif/webp).
Serve reflection removed structurally.GetCachedImagePath no longer carries ContentType; GetImage/GetWatermark dropped their [FromQuery] contentType binding. The handler always sniffs the file (MimeTypes.GetMimeTypeFromFile), defaulting application/octet-stream. No request path can choose the served Content-Type anymore. ArtworkContentTypeModel.UrlWithContentType is now the bare path; SPA watermark/logo previews no longer append the query.
Defense-in-depth on persisted DTOs. Channel-logo / watermark {path, contentType} bodies run through ArtworkContentTypeModel.Sanitized() on write, blanking anything outside the allow-list.
S9 upload-size DoS. Kestrel MaxRequestBodySize is set from ETV_MAXIMUM_UPLOAD_MB so an oversized body is rejected during read, before buffering (the controller file.Length check stays as the friendly-error backstop).
Notes
Both serve sinks are [ApiExplorerSettings(IgnoreApi = true)], and no request/response DTO shape changed → no OpenAPI drift (check:api clean).
The M3U logo URL uses /iptv/logos/{path}.jpg with no ?contentType=, so removing reflection doesn't affect M3U.
Tests / verification
New: ImageContentTypesTests (sniff png/jpeg/webp, reject HTML/empty, allow-list), ArtworkContentTypeModelTests (Sanitized(), path-only URL). Rewrote UploadArtworkHandlerTests for byte-sniffing (accepts a real PNG, rejects a <script> payload). Updated ArtworkUploadControllerTests (Location no longer carries ?contentType=). Updated watermarks.test.ts.
Green locally: ErsatzTV.Tests 1421, Core.Tests ImageContentTypes 14, web 20 + typecheck + lint + check:api.
Closes #283 (S4 + S9 from the #197 cold API review). Supersedes #66.
## Root cause
The artwork path trusted client-supplied content types at **both** ends:
- **Upload** (`UploadArtworkHandler`) validated only the *declared* multipart `Content-Type` string — it never decoded the bytes. `<script>` bytes labeled `image/png` passed and were stored verbatim.
- **Serve** (`GetImage` `/iptv/logos`, `GetWatermark` `/artwork/watermarks`) reflected a client `?contentType=` straight into the response `Content-Type` on **unauthenticated** GET sinks.
Chain: upload `<script>` as `image/png` → `GET /iptv/logos/{hash}?contentType=text/html` serves it as HTML in the ErsatzTV origin → stored XSS. The #279 `nosniff` header does **not** cover this — the server was *explicitly declaring* `text/html`, which the browser honors regardless.
## Fix
- **Upload derives the type from the bytes.** New `ErsatzTV.Core/Images/ImageContentTypes.DetectContentType` uses SkiaSharp `SKCodec` (header sniff only — pixels are never decoded, so no decompression-bomb path) to identify the true format; non-images are rejected 422. The untrusted declared `Content-Type` was dropped from the `UploadArtwork` command. `ImageContentTypes` is now the single allow-list source (png/jpeg/gif/webp).
- **Serve reflection removed structurally.** `GetCachedImagePath` no longer carries `ContentType`; `GetImage`/`GetWatermark` dropped their `[FromQuery] contentType` binding. The handler always sniffs the file (`MimeTypes.GetMimeTypeFromFile`), defaulting `application/octet-stream`. No request path can choose the served `Content-Type` anymore. `ArtworkContentTypeModel.UrlWithContentType` is now the bare path; SPA watermark/logo previews no longer append the query.
- **Defense-in-depth on persisted DTOs.** Channel-logo / watermark `{path, contentType}` bodies run through `ArtworkContentTypeModel.Sanitized()` on write, blanking anything outside the allow-list.
- **S9 upload-size DoS.** Kestrel `MaxRequestBodySize` is set from `ETV_MAXIMUM_UPLOAD_MB` so an oversized body is rejected during read, before buffering (the controller `file.Length` check stays as the friendly-error backstop).
## Notes
- Both serve sinks are `[ApiExplorerSettings(IgnoreApi = true)]`, and no request/response DTO shape changed → **no OpenAPI drift** (`check:api` clean).
- The M3U logo URL uses `/iptv/logos/{path}.jpg` with no `?contentType=`, so removing reflection doesn't affect M3U.
## Tests / verification
- New: `ImageContentTypesTests` (sniff png/jpeg/webp, reject HTML/empty, allow-list), `ArtworkContentTypeModelTests` (`Sanitized()`, path-only URL). Rewrote `UploadArtworkHandlerTests` for byte-sniffing (accepts a real PNG, rejects a `<script>` payload). Updated `ArtworkUploadControllerTests` (Location no longer carries `?contentType=`). Updated `watermarks.test.ts`.
- Green locally: **ErsatzTV.Tests 1421**, Core.Tests ImageContentTypes 14, web 20 + typecheck + lint + `check:api`.
- Docs: `api-conventions.md §4a` + `decisions.md` 2026-07-12.
Refs #197 #66
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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 <script> bytes as image/png -> GET ...?contentType=text/html
serves them as HTML in-origin. nosniff (#279) does not help because the server
explicitly declares text/html.
- Upload: derive the content type from the bytes via SkiaSharp SKCodec
(header-only, no decode -> no decompression-bomb path); reject non-images 422.
New ErsatzTV.Core/Images/ImageContentTypes as the single allow-list source.
Dropped the untrusted declared Content-Type from the UploadArtwork command.
- Serve: removed the ?contentType= reflection structurally -- dropped ContentType
from GetCachedImagePath and the [FromQuery] binding on GetImage/GetWatermark;
the handler always sniffs the file, defaulting application/octet-stream.
ArtworkContentTypeModel.UrlWithContentType is now the bare path; SPA previews
no longer append the query.
- Defense-in-depth: channel-logo / watermark {path, contentType} DTOs run through
ArtworkContentTypeModel.Sanitized(), blanking non-allow-listed types on write.
- S9: Kestrel MaxRequestBodySize from ETV_MAXIMUM_UPLOAD_MB rejects oversized
bodies during read (controller file.Length check kept as friendly-error backstop).
Both serve sinks are IgnoreApi, so no OpenAPI change. Tests: byte-sniff accept/
reject, Sanitized() allow-list, Location no longer carries ?contentType=.
Docs: api-conventions §4a + decisions.md 2026-07-12.
Refs #283#197#66
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
400 — Kestrel aborts the body read mid-stream (distinct from the controller's post-buffer 422), confirming the MaxRequestBodySize limit fires before buffering
The malicious ?contentType=text/html no longer changes the served Content-Type on either unauthenticated sink — the stored-XSS chain is closed. Negative control implicit in #1 (a declared text/html on a genuine PNG is ignored) and #2 (a non-image is refused outright).
**Live E2E — PASS** (fresh instance, port 8412, real HTTP against the built worktree dll):
| # | Check | Result |
|---|-------|--------|
| 1 | `POST /api/artwork/uploads` a **real PNG** declared `type=text/html` | **201**, body `contentType: image/png` (sniffed — declared type ignored), `Location: /iptv/logos/{hash}` with **no `?contentType=`** |
| 2 | `POST` a **`<script>` payload** declared `image/png` | **422** "Uploaded file is not a supported image; supported types are: image/png, image/jpeg, image/gif, image/webp" |
| 3 | `GET /iptv/logos/{hash}?contentType=text/html` (the XSS attack) | **200 `Content-Type: image/png`** + `X-Content-Type-Options: nosniff` — reflection gone |
| 4 | `GET /artwork/watermarks/{hash}?contentType=text/html` | **200 `Content-Type: image/png`** + nosniff |
| 5 | `GET /iptv/logos/{hash}.jpg` (M3U route variant) | **200 `Content-Type: image/png`** — M3U serving unaffected |
| 6 | `POST` an **11 MB** body (default cap 10 MB) | **400** — Kestrel aborts the body read mid-stream (distinct from the controller's post-buffer 422), confirming the `MaxRequestBodySize` limit fires *before* buffering |
The malicious `?contentType=text/html` no longer changes the served `Content-Type` on either unauthenticated sink — the stored-XSS chain is closed. Negative control implicit in #1 (a declared `text/html` on a genuine PNG is ignored) and #2 (a non-image is refused outright).
Cold-review LOW (defense-in-depth): the serve path derived the Content-Type
from the stored file via Winista but only defaulted application/octet-stream on
a NULL sniff. A cache file whose bytes are HTML — a legacy entry poisoned before
the upload-sniff landed, or a hypothetical image/script polyglot — could still be
sniffed as text/html and served renderable (nosniff does not stop an explicitly
declared text/html). Clamp the sniffed type to ImageContentTypes.IsAccepted,
serving application/octet-stream for anything else, so the serve path can never
emit a renderable non-image type regardless of what bytes are on disk.
Refs #283
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run 513's "EF migration integrity" job failed with "The Command Timeout
expired" applying all migrations to a fresh MySQL under concurrent-run
contention. The tree is unchanged from b6f12f7e (Build & test green there and
on the pre-clamp cf834d8b; the change touches no EF/model/migration code).
Empty commit to get a clean run.
Refs #283
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run 516's MySQL "apply all migrations to a fresh DB" died with
MySqlEndOfStreamException (incomplete response) — the shared MySQL service
container under concurrent-run load, same class as run 513's Command Timeout.
Model-drift (has-pending-model-changes) passed for BOTH providers, so the diff
is model-clean; another branch's run (515) passed the identical job. Contention
has cleared; re-triggering. Tree unchanged from b6f12f7e.
Refs #283
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #283 (S4 + S9 from the #197 cold API review). Supersedes #66.
Root cause
The artwork path trusted client-supplied content types at both ends:
UploadArtworkHandler) validated only the declared multipartContent-Typestring — it never decoded the bytes.<script>bytes labeledimage/pngpassed and were stored verbatim.GetImage/iptv/logos,GetWatermark/artwork/watermarks) reflected a client?contentType=straight into the responseContent-Typeon unauthenticated GET sinks.Chain: upload
<script>asimage/png→GET /iptv/logos/{hash}?contentType=text/htmlserves it as HTML in the ErsatzTV origin → stored XSS. The #279nosniffheader does not cover this — the server was explicitly declaringtext/html, which the browser honors regardless.Fix
ErsatzTV.Core/Images/ImageContentTypes.DetectContentTypeuses SkiaSharpSKCodec(header sniff only — pixels are never decoded, so no decompression-bomb path) to identify the true format; non-images are rejected 422. The untrusted declaredContent-Typewas dropped from theUploadArtworkcommand.ImageContentTypesis now the single allow-list source (png/jpeg/gif/webp).GetCachedImagePathno longer carriesContentType;GetImage/GetWatermarkdropped their[FromQuery] contentTypebinding. The handler always sniffs the file (MimeTypes.GetMimeTypeFromFile), defaultingapplication/octet-stream. No request path can choose the servedContent-Typeanymore.ArtworkContentTypeModel.UrlWithContentTypeis now the bare path; SPA watermark/logo previews no longer append the query.{path, contentType}bodies run throughArtworkContentTypeModel.Sanitized()on write, blanking anything outside the allow-list.MaxRequestBodySizeis set fromETV_MAXIMUM_UPLOAD_MBso an oversized body is rejected during read, before buffering (the controllerfile.Lengthcheck stays as the friendly-error backstop).Notes
[ApiExplorerSettings(IgnoreApi = true)], and no request/response DTO shape changed → no OpenAPI drift (check:apiclean)./iptv/logos/{path}.jpgwith no?contentType=, so removing reflection doesn't affect M3U.Tests / verification
ImageContentTypesTests(sniff png/jpeg/webp, reject HTML/empty, allow-list),ArtworkContentTypeModelTests(Sanitized(), path-only URL). RewroteUploadArtworkHandlerTestsfor byte-sniffing (accepts a real PNG, rejects a<script>payload). UpdatedArtworkUploadControllerTests(Location no longer carries?contentType=). Updatedwatermarks.test.ts.check:api.api-conventions.md §4a+decisions.md2026-07-12.Refs #197 #66
🤖 Generated with Claude Code
Live E2E — PASS (fresh instance, port 8412, real HTTP against the built worktree dll):
POST /api/artwork/uploadsa real PNG declaredtype=text/htmlcontentType: image/png(sniffed — declared type ignored),Location: /iptv/logos/{hash}with no?contentType=POSTa<script>payload declaredimage/pngGET /iptv/logos/{hash}?contentType=text/html(the XSS attack)Content-Type: image/png+X-Content-Type-Options: nosniff— reflection goneGET /artwork/watermarks/{hash}?contentType=text/htmlContent-Type: image/png+ nosniffGET /iptv/logos/{hash}.jpg(M3U route variant)Content-Type: image/png— M3U serving unaffectedPOSTan 11 MB body (default cap 10 MB)MaxRequestBodySizelimit fires before bufferingThe malicious
?contentType=text/htmlno longer changes the servedContent-Typeon either unauthenticated sink — the stored-XSS chain is closed. Negative control implicit in #1 (a declaredtext/htmlon a genuine PNG is ignored) and #2 (a non-image is refused outright).