diff --git a/ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs b/ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs index d583e11bd..380ac3b4f 100644 --- a/ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs +++ b/ErsatzTV.Application/Images/Queries/GetCachedImagePathHandler.cs @@ -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; @@ -96,9 +97,12 @@ public class else { // 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). Fall back - // to a non-renderable default if the sniffer can't identify the bytes. - mimeType = MimeTypes.GetMimeTypeFromFile(cachePath)?.Name ?? "application/octet-stream"; + // (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); diff --git a/docs/decisions.md b/docs/decisions.md index 5d7246e48..42292746c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -959,9 +959,11 @@ honors regardless of `nosniff`. The trust was the bug; the fix removes it at bot - **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`), defaulting to `application/octet-stream` if unidentifiable. 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 + 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