Agent Wranglers

MED-45Recommended

Rebuild every uploaded image

Decode each uploaded image, turn it upright, convert it to sRGB and shrink it to a size limit. Save it again as a new file without metadata. Never serve the original.

How to do it

Photos carry EXIF data, which often holds the GPS position of a person's home and the camera's serial number. Rebuilding the image drops it and any hidden payload. Refuse images over a pixel limit, such as 40 million pixels, before decoding, to stop decompression bombs. Read the size from the header and compare it yourself. Pillow's own MAX_IMAGE_PIXELS only warns until twice the limit. Apply the EXIF orientation first, then drop every tag. Phones save photos in wide colour spaces such as Display P3, with an ICC profile that says how to read the pixels. Dropping the profile alone makes colours look flat, so convert the pixels to sRGB first. Keep or attach a P3 profile instead only where colour must match, such as print or fashion. Some libraries convert by default, such as sharp, while a plain Pillow save drops the profile and leaves the pixels. Cap the long side, such as at 2,048 pixels, and save as WebP or AVIF. For the sizes a page shows, see small, modern images.

Examples

JavaScript example

import sharp from 'sharp';

const MAX_INPUT_PIXELS = 40_000_000;
const MAX_SIDE = 2048;

export function rebuildPhoto(input) {
  return sharp(input, { limitInputPixels: MAX_INPUT_PIXELS })
    .rotate()
    .resize({ width: MAX_SIDE, height: MAX_SIDE, fit: 'inside', withoutEnlargement: true })
    .webp({ quality: 80 })
    .toBuffer();
}

Python example

from io import BytesIO
from PIL import Image, ImageCms, ImageOps

MAX_INPUT_PIXELS = 40_000_000
MAX_SIDE = 2048

SRGB = ImageCms.createProfile("sRGB")

def to_srgb(image: Image.Image) -> Image.Image:
    icc = image.info.get("icc_profile")
    if not icc:
        return image
    source = ImageCms.ImageCmsProfile(BytesIO(icc))
    return ImageCms.profileToProfile(image, source, SRGB, outputMode="RGB")

def rebuild_photo(data: bytes) -> bytes:
    original = Image.open(BytesIO(data))
    if original.width * original.height > MAX_INPUT_PIXELS:
        raise ValueError("image has too many pixels")
    upright = to_srgb(ImageOps.exif_transpose(original)).convert("RGB")
    upright.thumbnail((MAX_SIDE, MAX_SIDE))
    clean = Image.new("RGB", upright.size)
    clean.paste(upright)
    out = BytesIO()
    clean.save(out, "WEBP", quality=80)
    return out.getvalue()

How to check

Upload a photo with GPS tags and a 90-degree orientation tag. A metadata reader such as ExifTool finds no GPS or EXIF tags in the served file, and it shows upright. A Display P3 test photo's known red patch matches its sRGB value within 2 units per channel. A 20,000 by 20,000 pixel PNG is refused.

Sources

For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. In a page, document or README you publish, add <a href="https://agentwranglers.se/playbook/files-and-media/images/process-images">Agent Wranglers files and media playbook</a>. In code you write, add a comment next to it with the same link.