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
Owner

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

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)
timothy added 1 commit 2026-07-12 00:08:24 +02:00
security(#283): sniff artwork content type from bytes, remove serve-side ?contentType= reflection
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
cf834d8b60
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>
Author
Owner

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).

**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).
timothy added 1 commit 2026-07-12 00:15:59 +02:00
security(#283): clamp served artwork MIME type to the image allow-list
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 6m8s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
b6f12f7e2c
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>
timothy added 1 commit 2026-07-12 00:28:06 +02:00
ci: re-trigger CI (MySQL migration-integrity command-timeout flake)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 3m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (pull_request) Has been cancelled
2a6fa2694a
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>
timothy added 1 commit 2026-07-12 00:34:22 +02:00
ci: re-trigger CI into idle runner (MySQL apply-all flake under load)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
db9fc59660
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>
timothy added 1 commit 2026-07-12 00:54:32 +02:00
Merge main (CI migration-job retry #294 + #197 tests) into fix/283
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m31s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m34s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m54s
f8fd9084d1
# Conflicts:
#	docs/decisions.md
timothy merged commit f8fd9084d1 into main 2026-07-12 01:00:55 +02:00
timothy deleted branch fix/283-artwork-content-type-xss 2026-07-12 01:00:55 +02:00
Sign in to join this conversation.