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

+4
View File
@@ -9,5 +9,9 @@ namespace PrivaPub.Domain.Media
public long ProxyCacheBytes { get; set; } = 5L * 1024 * 1024 * 1024;
public int MaxImageSide { get; set; } = 4096;
public int PreviewSide { get; set; } = 640;
public long MaxPixels { get; set; } = 40_000_000;//an image (or a GIF's frames together) may not decode to more
public int MaxFrames { get; set; } = 500;
public int MaxGifSide { get; set; } = 1280;//a GIF becomes an mp4 at most this wide
public int Concurrency { get; set; } = 2;//uploads processed at once
}
}
+158 -41
View File
@@ -22,6 +22,13 @@ namespace PrivaPub.Domain.Media
public sealed record ProcessedImage(byte[] Bytes, string Extension, string ContentType, int Width, int Height, byte[] Preview, string Blurhash);
// what libvips makes of an upload's header alone, before anything is decoded
public sealed record ImageHeader(string Loader, int Width, int PageHeight, int Pages)
{
public bool Animated => Pages > 1 && Loader?.StartsWith("gifload", StringComparison.Ordinal) == true;
public long Pixels => (long)Width * PageHeight * (Animated ? Pages : 1);
}
public interface IMediaService
{
string Root { get; }
@@ -45,7 +52,11 @@ namespace PrivaPub.Domain.Media
public class MediaService : IMediaService
{
static readonly string[] ImageTypes = { "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif" };
// HEIC and HEIF are not among them: the libvips bundled here decodes AVIF but has no HEVC decoder
static readonly string[] ImageTypes = { "image/jpeg", "image/png", "image/gif", "image/webp", "image/avif" };
// the only loaders an upload ever reaches: everything else libvips could read (SVG, PDF, TIFF, ImageMagick, ...) is
// blocked, whatever type the upload claims
static readonly string[] Loaders = { "VipsForeignLoadJpeg", "VipsForeignLoadPng", "VipsForeignLoadNsgif", "VipsForeignLoadWebp", "VipsForeignLoadHeif" };
static readonly Dictionary<string, string> AvTypes = new()
{
["video/mp4"] = "mp4", ["video/quicktime"] = "mp4", ["video/webm"] = "webm",
@@ -56,8 +67,18 @@ namespace PrivaPub.Domain.Media
// what an upload may be, as the instance API advertises it
public static IReadOnlyList<string> SupportedTypes => ImageTypes.Concat(AvTypes.Keys).ToList();
static MediaService()
{
NetVips.NetVips.BlockUntrusted = true;
Operation.Block("VipsForeignLoad", true);
foreach (var loader in Loaders)
Operation.Block(loader, false);
Cache.Max = 0;//an upload is decoded once: nothing to keep between operations
}
readonly IOptionsMonitor<MediaOptions> _options;
readonly ILocalActorService _localActors;
readonly SemaphoreSlim _processing;
readonly IWebHostEnvironment _environment;
readonly ILogger<MediaService> _logger;
@@ -65,6 +86,7 @@ namespace PrivaPub.Domain.Media
{
_options = options;
_localActors = localActors;
_processing = new SemaphoreSlim(Math.Max(1, options.CurrentValue.Concurrency));
_environment = environment;
_logger = logger;
}
@@ -94,27 +116,41 @@ namespace PrivaPub.Domain.Media
{
if (file.Length > options.MaxImageBytes)
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
await using var stream = file.OpenReadStream();
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, token);
ProcessedImage processed;
var bytes = await Read(file, token);
if (Refusal(bytes, options) is { } refused)
return refused;
await _processing.WaitAsync(token);
try
{
processed = ProcessImage(buffer.ToArray(), options.MaxImageSide, options.PreviewSide, contentType == "image/gif");
var header = Inspect(bytes);
if (header.Animated)
{
var animated = await ProcessAnimation(bytes, header, attachment, token);
if (!animated.Ok)
return animated;
}
else
{
var processed = ProcessImage(bytes, options.MaxImageSide, options.PreviewSide);
attachment.Kind = "image";
attachment.ContentType = processed.ContentType;
attachment.FilePath = await Save(processed.Bytes, processed.Extension, token);
attachment.PreviewPath = await Save(processed.Preview, "jpg", token);
attachment.Width = processed.Width;
attachment.Height = processed.Height;
attachment.Blurhash = processed.Blurhash;
attachment.Size = processed.Bytes.Length;
}
}
catch (VipsException ex)
{
_logger.LogInformation("Refused an image upload: {Error}", ex.Message);
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
}
attachment.Kind = contentType == "image/gif" ? "gifv" : "image";
attachment.ContentType = processed.ContentType;
attachment.FilePath = await Save(processed.Bytes, processed.Extension, token);
attachment.PreviewPath = await Save(processed.Preview, "jpg", token);
attachment.Width = processed.Width;
attachment.Height = processed.Height;
attachment.Blurhash = processed.Blurhash;
attachment.Size = processed.Bytes.Length;
finally
{
_processing.Release();
}
}
else if (contentType != default && AvTypes.TryGetValue(contentType, out var extension))
{
@@ -140,14 +176,15 @@ namespace PrivaPub.Domain.Media
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File type is not supported");
if (file.Length > _options.CurrentValue.MaxImageBytes)
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big");
await using var stream = file.OpenReadStream();
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, token);
var input = await Read(file, token);
if (Refusal(input, _options.CurrentValue) is { } refused)
return refused;
byte[] bytes;
int outWidth, outHeight;
await _processing.WaitAsync(token);
try
{
using var image = Image.ThumbnailBuffer(buffer.ToArray(), width, height: height, crop: Enums.Interesting.Centre, size: Enums.Size.Down);
using var image = Image.ThumbnailBuffer(input, width, height: height, crop: Enums.Interesting.Centre, size: Enums.Size.Down);
using var flat = Flatten(image);
bytes = flat.WriteToBuffer(".jpg[Q=85,keep=none]");
(outWidth, outHeight) = (flat.Width, flat.Height);
@@ -157,6 +194,10 @@ namespace PrivaPub.Domain.Media
_logger.LogInformation("Refused a profile picture: {Error}", ex.Message);
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
}
finally
{
_processing.Release();
}
var attachment = new MediaAttachment
{
OwnerAvatarId = avatarId,
@@ -213,38 +254,101 @@ namespace PrivaPub.Domain.Media
await DB.Default.DeleteAsync<MediaAttachment>(m => m.ID == trashed.ID && m.TrashedAt != null);
}
public static ProcessedImage ProcessImage(byte[] input, int maxSide, int previewSide, bool animated)
// reads only the header: which loader takes the bytes and how big they would decode (throws VipsException when none)
public static ImageHeader Inspect(byte[] input)
{
using var loaded = animated ? Image.NewFromBuffer(input, kwargs: new VOption { { "n", -1 } }) : Image.NewFromBuffer(input);
using var rotated = animated ? loaded.Copy() : loaded.Autorot();
var pageHeight = animated && rotated.Contains("page-height") ? (int)rotated.Get("page-height") : rotated.Height;
var longest = Math.Max(rotated.Width, pageHeight);
using var image = Image.NewFromBuffer(input, access: Enums.Access.Sequential);
var pages = image.Contains("n-pages") ? Math.Max(1, (int)image.Get("n-pages")) : 1;
var pageHeight = image.Contains("page-height") ? (int)image.Get("page-height") : image.Height;
return new ImageHeader(image.Contains("vips-loader") ? (string)image.Get("vips-loader") : default, image.Width, pageHeight, pages);
}
byte[] bytes;
string extension, contentType;
int width, height;
if (animated)
// an upload refused before anything is decoded: no loader takes it, or it would decode to too many pixels or frames
static MediaOutcome Refusal(byte[] input, MediaOptions options)
{
ImageHeader header;
try
{
bytes = rotated.WriteToBuffer(".gif");
(extension, contentType, width, height) = ("gif", "image/gif", rotated.Width, pageHeight);
header = Inspect(input);
}
else
catch (VipsException)
{
using var resized = longest > maxSide ? rotated.ThumbnailImage(maxSide, height: maxSide, size: Enums.Size.Down) : rotated.Copy();
var keepsAlpha = resized.HasAlpha();
bytes = keepsAlpha ? resized.WriteToBuffer(".png[keep=none]") : resized.WriteToBuffer(".jpg[Q=88,keep=none]");
(extension, contentType, width, height) = keepsAlpha ? ("png", "image/png", resized.Width, resized.Height) : ("jpg", "image/jpeg", resized.Width, resized.Height);
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image");
}
if (header.Pixels > options.MaxPixels || header.Pages > options.MaxFrames)
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The image is too large");
return default;
}
using var firstFrame = animated ? rotated.Crop(0, 0, rotated.Width, pageHeight) : rotated.Copy();
using var preview = firstFrame.ThumbnailImage(previewSide, height: previewSide, size: Enums.Size.Down);
// a still image: shrunk on load to the largest side allowed, turned by its orientation, its colours brought into sRGB
// (thumbnail does all three), then written without its metadata, with a preview and a blurhash
public static ProcessedImage ProcessImage(byte[] input, int maxSide, int previewSide)
{
// rendered into memory once (it is read three times below, and a shrunk-on-load image can be read only in order)
using var shrunk = Image.ThumbnailBuffer(input, maxSide, height: maxSide, size: Enums.Size.Down);
using var resized = shrunk.CopyMemory();
var keepsAlpha = resized.HasAlpha();
var bytes = keepsAlpha ? resized.WriteToBuffer(".png[keep=none]") : resized.WriteToBuffer(".jpg[Q=88,keep=none]");
var (extension, contentType) = keepsAlpha ? ("png", "image/png") : ("jpg", "image/jpeg");
using var preview = resized.ThumbnailImage(previewSide, height: previewSide, size: Enums.Size.Down);
using var previewFlat = Flatten(preview);
var previewBytes = previewFlat.WriteToBuffer(".jpg[Q=80,keep=none]");
using var tiny = firstFrame.ThumbnailImage(32, height: 32);
using var tiny = resized.ThumbnailImage(32, height: 32);
using var tinyFlat = Flatten(tiny);
var pixels = tinyFlat.WriteToMemory();
return new ProcessedImage(bytes, extension, contentType, width, height, previewBytes, Blurhash.Encode(pixels, tinyFlat.Width, tinyFlat.Height));
return new ProcessedImage(bytes, extension, contentType, resized.Width, resized.Height, previewBytes, Blurhash.Encode(pixels, tinyFlat.Width, tinyFlat.Height));
}
// an animated GIF becomes what Mastodon makes of one: a silent, looping H.264 mp4 (a gifv) at most MaxGifSide wide,
// its first frame the poster
async Task<MediaOutcome> ProcessAnimation(byte[] input, ImageHeader header, MediaAttachment attachment, CancellationToken token)
{
var options = _options.CurrentValue;
var temp = Path.Combine(Path.GetTempPath(), $"privapub-{Guid.NewGuid():N}");
var gif = temp + ".gif";
var mp4 = temp + ".mp4";
try
{
await File.WriteAllBytesAsync(gif, input, token);
var width = Math.Min(header.Width, options.MaxGifSide) / 2 * 2;
var height = Math.Max(2, (int)Math.Round(header.PageHeight * (double)width / header.Width / 2) * 2);
await FFMpegArguments.FromFileInput(gif, true, o => o.ForceFormat("gif").WithCustomArgument("-protocol_whitelist file"))
.OutputToFile(mp4, true, o => o.WithCustomArgument(
$"-an -map_metadata -1 -map_chapters -1 -c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p -movflags +faststart -vf scale={width}:{height}"))
.CancellableThrough(token)
.ProcessAsynchronously();
var poster = ProcessImage(input, options.PreviewSide, options.PreviewSide);
attachment.Kind = "gifv";
attachment.ContentType = "video/mp4";
attachment.Width = width;
attachment.Height = height;
attachment.Size = new FileInfo(mp4).Length;
attachment.Blurhash = poster.Blurhash;
attachment.PreviewPath = await Save(poster.Preview, "jpg", token);
attachment.FilePath = SaveFile(mp4, "mp4");
return new MediaOutcome(attachment);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogInformation(ex, "Refused a GIF upload");
return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file could not be processed");
}
finally
{
foreach (var path in new[] { gif, mp4 })
if (File.Exists(path))
File.Delete(path);
}
}
static async Task<byte[]> Read(IFormFile file, CancellationToken token)
{
await using var stream = file.OpenReadStream();
using var buffer = new MemoryStream((int)Math.Min(file.Length, int.MaxValue));
await stream.CopyToAsync(buffer, token);
return buffer.ToArray();
}
static Image Flatten(Image image)
@@ -284,7 +388,7 @@ namespace PrivaPub.Domain.Media
attachment.Height = probe.PrimaryVideoStream.Height;
var frame = temp + ".png";
await FFMpeg.SnapshotAsync(input, frame, captureTime: TimeSpan.FromSeconds(Math.Min(1, probe.Duration.TotalSeconds / 2)));
var still = ProcessImage(await File.ReadAllBytesAsync(frame, token), 640, _options.CurrentValue.PreviewSide, false);
var still = ProcessImage(await File.ReadAllBytesAsync(frame, token), 640, _options.CurrentValue.PreviewSide);
attachment.PreviewPath = await Save(still.Preview, "jpg", token);
attachment.Blurhash = still.Blurhash;
File.Delete(frame);
@@ -304,15 +408,28 @@ namespace PrivaPub.Domain.Media
}
}
async Task<string> Save(byte[] bytes, string extension, CancellationToken token)
string SaveFile(string source, string extension)
{
var (relative, full) = NewPath(extension);
File.Move(source, full);
return relative;
}
(string Relative, string Full) NewPath(string extension)
{
var now = DateTime.UtcNow;
var relative = Path.Combine(now.ToString("yyyy", CultureInfo.InvariantCulture), now.ToString("MM", CultureInfo.InvariantCulture),
$"{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16))}.{extension}");
var full = Path.Combine(Root, relative);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
return (relative.Replace('\\', '/'), full);
}
async Task<string> Save(byte[] bytes, string extension, CancellationToken token)
{
var (relative, full) = NewPath(extension);
await File.WriteAllBytesAsync(full, bytes, token);
return relative.Replace('\\', '/');
return relative;
}
static string Clean(string value, int max) =>