Process untrusted uploads

edit

An image uploaded by a user, or downloaded from a site you do not control, may be anything: a file that is not an image, a format you do not want to serve, a truncated file, or a file crafted to exhaust the memory of your server, such as a small file that declares a canvas of a billion pixels. This guide accepts such a file only when it is a reasonable image, and stores a clean copy of it.

An upload pipeline

public static string? SaveUpload(Stream upload, string outputPath)
{
    // Safety bounds for this input: a file that exceeds one is rejected before the memory is allocated
    var configuration = new ImageConfiguration
    {
        Limits = new ImageResourceLimits
        {
            MaxEncodedBytes = 20 * 1024 * 1024,
            MaxWidth = 8192,
            MaxHeight = 8192,
            MaxFramePixels = 40_000_000,
            MaxLiveAllocationBytes = 256 * 1024 * 1024,
        },
    };

    try
    {
        // Reads the headers only, without decoding any pixel. The bytes it reads are consumed: remember the position
        var start = upload.Position;
        var info = Image.Identify(upload, new ImageIdentifyOptions { Configuration = configuration });
        if (info.Format is not (ImageFormat.Jpeg or ImageFormat.Png or ImageFormat.Gif or ImageFormat.WebP or ImageFormat.Heic or ImageFormat.Avif))
            return $"The {info.Format} format is not accepted";

        if (info.Width < 16 || info.Height < 16)
            return "The image is too small";

        // Decodes from the start again; the first frame of an animation is enough
        upload.Position = start;
        using var image = Image.Load(upload, new ImageDecodeOptions { Configuration = configuration, FrameLimit = 1 });
        image.RemovePosterFrame();
        image.Animation = null;

        // Re-encoding keeps the pixels and nothing else: data hidden in the file, its metadata (once the orientation
        // and the colors are applied to the pixels) and any bytes after the image are gone
        image.AutoOrient();
        var options = new IccColorTransformOptions { ToneMapping = HdrToneMappingOptions.Default };
        image.ConvertColorProfile(PixelFormats.IsGrayscale(image.PixelFormat) ? IccProfile.SrgbGray : IccProfile.Srgb, options);
        image.Save(outputPath, new WebPEncoder
        {
            Compression = WebPCompression.Lossy,
            Quality = 80,
            AllowBitDepthReduction = true,
            MetadataHandling = MetadataHandling.Strip,
        });
        return null;
    }
    catch (UnknownImageFormatException)
    {
        return "The file is not an image";
    }
    catch (ImageResourceLimitException ex)
    {
        return $"The image is too large ({ex.Kind})";
    }
    catch (InvalidImageContentException)
    {
        return "The file is corrupted or truncated";
    }
    catch (UnsupportedImageFeatureException ex)
    {
        return $"The image uses an unsupported feature ({ex.Feature})";
    }
}

The method returns null when the upload is saved, or the reason why it is rejected, which you can show to the user. The sections below explain each step.

Identify before decoding

Image.Identify reads the headers of the file and returns an ImageInfo without decoding the pixels: the format, the size, the pixel format, the frame count when the header gives it. It is cheap, so check everything you can there:

  • The format. It is detected from the content of the file, never from its name or from the content type that the client declares. Accept the formats you serve, and reject the others before decoding them.
  • The size. Reject images that are too small or too large for your use, before any pixel is decoded.
  • Values the header does not give are null, never guessed: a GIF header has no frame count, for example.

ImageIdentifyMode.FullScan also walks the whole structure of the file (chunks, markers, checksums) without decoding pixels, which detects more corrupted files before the decoding, for the cost of reading the whole file. See Identifying images.

Bound the resources

Identification is not enough: the header may be honest and the data still hostile. ImageResourceLimits bounds what a load can read and allocate. Each limit is checked before the memory is allocated or the data consumed, and exceeding one throws an ImageResourceLimitException, never a truncated image:

LimitDefaultWhat it bounds
MaxEncodedBytes256 MiBThe bytes read from the input
MaxWidth, MaxHeight32,768The size of the canvas
MaxFramePixels100,000,000The pixels of one frame
MaxFrames1,000The frames of an animation
MaxTotalPixels1,000,000,000The pixels of all the frames together
MaxMetadataBytes16 MiBEXIF, XMP, ICC profiles and text, decompressed
MaxLiveAllocationBytes512 MiBThe memory that an image and the operation working on it hold at a time

The defaults suit trusted files; lower them for uploads. Pass the same ImageConfiguration to Identify and to Load: the loaded image keeps its configuration, so its later operations (a resize, a color conversion) stay within the same limits. Every limit must be positive; to lift one, set it to the largest value of its type. See Resource limits.

FrameLimit = 1 is not a limit: it deliberately decodes the first frame of an animation and ignores the rest of the file, where MaxFrames rejects a file that has too many frames.

Catch the errors

Each kind of problem throws its own exception, all derived from ImageException:

ExceptionMeaningTypical answer
UnknownImageFormatExceptionNot an image, or a format the library does not read"Not an image"
InvalidImageContentExceptionCorrupted, inconsistent or truncated data"Corrupted file"
UnsupportedImageFeatureExceptionA valid file that uses a feature the library does not support (Feature says which)"Unsupported image"
ImageResourceLimitExceptionA limit is exceeded (Kind, Limit and Requested say which and by how much)"Image too large"

IOException (the stream failed) and OperationCanceledException are not problems of the file: let them propagate. See Handling errors.

Re-encode the image

Never store or serve the uploaded bytes as they are. Decoding and encoding again keeps the picture and drops the rest: the data that the decoder skips (unknown chunks, bytes after the end of the image) is not written, and MetadataHandling.Strip removes the metadata, the GPS position of a photo included. The orientation and the color profile are applied to the pixels first, since Strip drops them too (see Handle photos from phones and cameras).

Re-encoding also gives you one output format and one quality for every upload, whatever the users send.

Streams

  • Identify and Load start reading at the current position of the stream, and consume what they read: the example remembers the position and seeks back to it before decoding. This needs a seekable stream.
  • For a stream that cannot seek, such as a request body read as it arrives, copy the upload to a MemoryStream or a temporary file first, with a size limit, or skip Identify and rely on the limits of Load alone.
  • The library never closes your stream, and does not keep it after the call.
  • IdentifyAsync and LoadAsync read with asynchronous I/O and take a CancellationToken, which decoders check between rows and frames: cancel it to stop working on a file that takes too long.