An image is checked before it is decoded

An upload went straight to libvips: whatever loader recognised the bytes ran (an SVG sent as image/png was rasterised),
nothing bounded how many pixels it would decode to (a small PNG could decode to gigabytes, three times over), a GIF
was loaded frame by frame and never resized, and all of it ran inside the request with nothing limiting how many at
once. A GIF was typed gifv but stayed a .gif, which a gifv player can't play; its metadata was kept; colours lost
their ICC profile without being converted; HEIC was advertised but the bundled libvips can't decode it.

Now:
- only libvips' JPEG, PNG, GIF, WebP and HEIF loaders ever run on an upload (every other loader is blocked);
- the header alone says how big an image would decode, refused above Media:MaxPixels (40 MP) or MaxFrames;
- a still image is shrunk on load, turned by its orientation and brought into sRGB (thumbnail), then written without
  metadata, a profile picture the same way;
- an animated GIF becomes a looping silent H.264 mp4 typed gifv, as on Mastodon (PostMedia.Kind keeps it a gifv),
  and a remote GIF is an image;
- processing runs Media:Concurrency at a time, and uploads have their own rate limit per credential;
- HEIC and HEIF are no longer offered.

Tests: only the upload formats load, the header tells the size, an SVG posing as a PNG and an image too large are
refused before decoding, an animated GIF becomes a gifv and a still one an image, HEIC isn't advertised. The media
scenarios against the pasture (GoToSocial, Mastodon, Misskey, Akkoma, Pixelfed, Smithereen, Vernissage, Castopod,
PeerTube) pass: 329 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 10:48:44 +02:00
1 parent 65938bb2a0
commit e4f9d0b61e
14 files changed
+284 -55

No files matched your search

+18 -6
View File
@@ -296,9 +296,21 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
1. **No upload keeps its metadata.** Images are re-encoded by libvips with `keep=none`; audio and video are remuxed with
`-map_metadata -1`. `MediaProcessingTests` checks EXIF and XMP are gone.
2. Files live under `Media:Root` (`/var/lib/privapub/media`), never in the published directory; the proxy cache is the
2. **An image is checked before it is decoded.**
- **Loaders:** only libvips' JPEG, PNG, GIF, WebP and HEIF loaders ever run on an upload (`MediaService`'s static
constructor blocks every other loader), so an SVG, PDF or TIFF claiming another type never loads.
- **Size:** the header alone tells width × height (× frames for a GIF), refused above `Media:MaxPixels` (40 MP) or
`Media:MaxFrames`.
- **Then:** the image is shrunk on load to `MaxImageSide`, turned by its orientation, and its colours brought into
sRGB (`thumbnail`).
- **GIFs:** an animated GIF becomes a looping silent H.264 mp4 typed `gifv`, as on Mastodon (ffmpeg with libx264;
`PostMedia.Kind` says so), and a still GIF is an image.
- **Types:** HEIC and HEIF are not accepted, since the bundled libvips has no HEVC decoder.
- **Load:** processing runs `Media:Concurrency` at a time, and uploads have their own rate limit (`uploads`, per
credential).
3. Files live under `Media:Root` (`/var/lib/privapub/media`), never in the published directory; the proxy cache is the
sibling `media-proxy` and the trash the sibling `media-trash`, neither of which `/media/files` serves.
3. **A file lives exactly as long as something holds it.** Every upload is a `MediaAttachment` row, profile pictures
4. **A file lives exactly as long as something holds it.** Every upload is a `MediaAttachment` row, profile pictures
too (`Kind` avatar or header, `ProfileOfAvatarId`). Deleting a post, an edit leaving media out, a replaced picture,
a dropped scheduled post, a removed root, and an upload never posted for a day each trash theirs
(`IMediaService.Trash`):
@@ -309,18 +321,18 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
Nothing is deleted because it looks unused. `PrivaPub admin media audit [--fix]` compares disk and database: with
`--fix` (as www-data) it gives pictures shown from before their rows a row, and trashes media of deleted posts or
personas, rows whose files are missing, and files nothing holds.
4. **A client never contacts a remote server for media:** every remote URL the API returns goes through
5. **A client never contacts a remote server for media:** every remote URL the API returns goes through
`IMediaProxy.Wrap`, an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia`.
5. **The proxy serves three ways:**
6. **The proxy serves three ways:**
- **Cached:** a file already cached is served from disk, ranges included.
- **Downloaded:** a request without a `Range` is downloaded whole, up to `Media:MaxProxiedBytes`, then cached.
- **Streamed:** a ranged request, or anything too big to cache, is streamed from the origin with the range passed on,
and never cached. That is how remote video plays.
nginx has a `/media/proxy/` location with `proxy_buffering off` and a 600 s read timeout for those streams.
6. **A focal point is two finite numbers** within -1..1 (`FocalPoint.Parse`); anything else is ignored. A stored NaN made
7. **A focal point is two finite numbers** within -1..1 (`FocalPoint.Parse`); anything else is ignored. A stored NaN made
every status, timeline and Note holding its post fail to serialise; migration `_016` removed the ones stored before.
7. **Remote video and audio become one playable attachment** in the Mastodon API (`MastodonMapper.Playable`): the best MP4
8. **Remote video and audio become one playable attachment** in the Mastodon API (`MastodonMapper.Playable`): the best MP4
up to 720p that carries both sound and picture, including PeerTube's fragmented files inside an HLS entry. HLS
playlists themselves are not rewritten.