A picture's metadata is removed before the picture is opened (#1087) #1088

Merged
claude-bot merged 3 commits from fix/1087-exif-open-bomb into main 2026-10-05 00:38:56 +00:00
Collaborator

Closes #1087. Not to be merged before the independent review reports.

Summary

Pillow parses a file's metadata when it opens it, before any pixel check can run, and the cost is the file's to choose. The worst file found is 70 kB.

  • image_service._sanitise(raw) walks the container and removes every metadata segment before Pillow sees the bytes. _open_sanitised is the one place in app/ that opens upload or import bytes; an AST test holds that.
  • Stripping was chosen over a size bound because a size bound cannot stop a 70 kB file. The stored output keeps no metadata anyway (#1078).
  • Per format:
    • JPEG: every APPn and COM segment is dropped; the scan onward is copied in one piece.
    • PNG: only image, palette, transparency, colour-space, resolution and animation chunks are kept.
    • WebP: EXIF, XMP and ICCP are dropped and the header flags naming them cleared.
    • GIF: comment, plain-text and application extensions are dropped.
  • The orientation (#1079) is read from the metadata as it is dropped, with the same bounded reader.
  • A file built from more than 4,096 segments, chunks or extensions is refused (422), which bounds the walk.
  • No migration, bot, compose or frontend change.

Measured

512 MB container, peak over the state before the open:

File Before After
JPEG, 60 EXIF segments each claiming 6 MB (6.3 MB file) +390 MB +0 MB
JPEG, one EXIF entry claiming 6 MB (70 kB file) +171 MB +0 MB
JPEG, 2,000-entry MPF directory (70 kB file) +1.6 GB, 16 s +0 MB
JPEG, 8 MB of tiny comment segments +286 MB, 13 s refused at once
PNG, 8 MB of tiny chunks +38 to 80 MB, 9.5 s refused at once
PNG text and ICC chunks bounded by Pillow stripped

Honest files still accepted, upright, with no metadata out: a 4032×3024 phone JPEG with a maker note and GPS; an MPO/HDR photo with a gain-map frame (first frame kept); a PNG with 500 kB of XMP, a 200 kB ICC profile and eXIf.

Through nginx on a warm backend: the MPF and EXIF files to the picture, attachment and map routes were accepted as the small harmless pictures inside them. The backend stayed at its warm 457 MiB; an honest 12 MP photo took it to 496 to 500 MiB. oom_kill 0 and no restarts.

Not closed here

  • Time, not memory, and already on main: a JPEG whose scan is megabytes of stuffed FF 00 takes 2.6 s to profile at 8 MB and 17.6 s as a 75 MB map; a GIF of millions of one-byte sub-blocks takes about 2 s, and the new strip adds a second walk of about the same. All are linear in the file's size and flat in memory.
  • An orientation tag in a second EXIF segment is read as upright. Cameras put it in the first.

Changelog

  • changelog.d/1087-exif-open-bomb.security.md

Test plan

  • New tests/wiki/test_exif_open_bomb.py, 30 tests, with the orientation and pixel-bound tests: 329 passed.
  • tests/wiki with the damaged-image and session-hygiene tests: 962 passed.
  • tests/export: the first run had 2 failed, both a truncated PNG no longer reaching the decode; fixed, and both files re-run: 85 passed.
  • Full backend suite: 7672 passed, 14 skipped, 107 deselected. It ran before the merge of main, which added nothing to the image code.
  • Lint clean; version and changelog checks OK.
  • The memory assertion in the tests uses tracemalloc, which counts Python allocations only. The figures in the table are from separate runs.
  • Not run: frontend, bot, synthetic, journeys (untouched); CI.

🤖 Generated with Claude Code

Closes #1087. Not to be merged before the independent review reports. ## Summary Pillow parses a file's metadata when it opens it, before any pixel check can run, and the cost is the file's to choose. The worst file found is 70 kB. - **`image_service._sanitise(raw)`** walks the container and removes every metadata segment before Pillow sees the bytes. `_open_sanitised` is the one place in `app/` that opens upload or import bytes; an AST test holds that. - **Stripping was chosen over a size bound** because a size bound cannot stop a 70 kB file. The stored output keeps no metadata anyway (#1078). - **Per format:** - JPEG: every `APPn` and `COM` segment is dropped; the scan onward is copied in one piece. - PNG: only image, palette, transparency, colour-space, resolution and animation chunks are kept. - WebP: `EXIF`, `XMP` and `ICCP` are dropped and the header flags naming them cleared. - GIF: comment, plain-text and application extensions are dropped. - **The orientation (#1079)** is read from the metadata as it is dropped, with the same bounded reader. - **A file built from more than 4,096 segments, chunks or extensions is refused** (422), which bounds the walk. - No migration, bot, compose or frontend change. ## Measured 512 MB container, peak over the state before the open: | File | Before | After | |---|---|---| | JPEG, 60 EXIF segments each claiming 6 MB (6.3 MB file) | +390 MB | +0 MB | | JPEG, one EXIF entry claiming 6 MB (70 kB file) | +171 MB | +0 MB | | JPEG, 2,000-entry MPF directory (70 kB file) | +1.6 GB, 16 s | +0 MB | | JPEG, 8 MB of tiny comment segments | +286 MB, 13 s | refused at once | | PNG, 8 MB of tiny chunks | +38 to 80 MB, 9.5 s | refused at once | | PNG text and ICC chunks | bounded by Pillow | stripped | **Honest files still accepted, upright, with no metadata out:** a 4032×3024 phone JPEG with a maker note and GPS; an MPO/HDR photo with a gain-map frame (first frame kept); a PNG with 500 kB of XMP, a 200 kB ICC profile and `eXIf`. **Through nginx on a warm backend:** the MPF and EXIF files to the picture, attachment and map routes were accepted as the small harmless pictures inside them. The backend stayed at its warm 457 MiB; an honest 12 MP photo took it to 496 to 500 MiB. `oom_kill 0` and no restarts. ## Not closed here - **Time, not memory, and already on main:** a JPEG whose scan is megabytes of stuffed `FF 00` takes 2.6 s to profile at 8 MB and 17.6 s as a 75 MB map; a GIF of millions of one-byte sub-blocks takes about 2 s, and the new strip adds a second walk of about the same. All are linear in the file's size and flat in memory. - **An orientation tag in a second EXIF segment** is read as upright. Cameras put it in the first. ## Changelog - [x] `changelog.d/1087-exif-open-bomb.security.md` ## Test plan - [x] New `tests/wiki/test_exif_open_bomb.py`, 30 tests, with the orientation and pixel-bound tests: `329 passed`. - [x] `tests/wiki` with the damaged-image and session-hygiene tests: `962 passed`. - [x] `tests/export`: the first run had `2 failed`, both a truncated PNG no longer reaching the decode; fixed, and both files re-run: `85 passed`. - [x] **Full backend suite:** `7672 passed, 14 skipped, 107 deselected`. It ran before the merge of main, which added nothing to the image code. - [x] Lint clean; version and changelog checks OK. - [ ] The memory assertion in the tests uses `tracemalloc`, which counts Python allocations only. The figures in the table are from separate runs. - [ ] Not run: frontend, bot, synthetic, journeys (untouched); CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Opening a picture made Pillow parse its metadata eagerly, and how much work
that is is the file's to choose, not the picture's. A JPEG's EXIF is read in
full at open to find the resolution, following every directory entry to the
bytes it points at; an APP2 MPF directory is followed to decide whether the
file is an MPO. Measured in a 512 MB container, `Image.open` alone:

- a 70 kB JPEG whose EXIF entries all point at the same 6 MB: +171 MB
- a 6 MB JPEG of the same shape: +390 MB (killed at 60 segments)
- a 70 kB JPEG with an abusive MPF directory: +1.6 GB

All of it ran in read_header, before the pixel check that guards the decode
(#1074), and outside the one-picture lock, for anyone who can upload a
picture — on the page-picture, attachment and map routes and through imports.

The output keeps none of this (every re-encode strips it, #1078), so on the
way in it is pure liability. `_sanitise` now removes every metadata segment
from the bytes before Pillow is handed them — the one place in the module
that opens upload or import bytes (`_open_sanitised`) — leaving the structure
and the pixels:

- JPEG: every APPn and COM segment (EXIF, MPF, ICC, Photoshop, Adobe, XMP,
  comments). The scan and everything after it is copied in one piece, so only
  the handful of segments before the scan is walked.
- PNG: every chunk but the image, palette, transparency, colour-space,
  resolution and animation chunks (text, iCCP, eXIf and unknown ancillary
  gone). A truncated final chunk is handed on as it is, so a cut-short upload
  still reads its header and fails in the decode, where it did before.
- WebP: the EXIF, XMP and ICC chunks, and the VP8X flags that name them.
- GIF: the comment, plain-text and application extensions.

What Pillow never sees it cannot parse, whatever the file claims — a strip,
not a size bound, because the MPF bomb is 70 kB and no size bound would stop
it. Every bomb above now costs the header read +0 MB and a few hundredths of
a second. A file built from more than a few thousand segments, chunks or
extensions (a megabyte of four-byte markers) is refused as "built from far
more pieces than any ordinary one" in a 422, rather than walked to the end.

The EXIF orientation (#1079) is read from the metadata as it is dropped, with
the same bounded reader, so turning a photo upright still works. Honest files
are unaffected: a phone photo with a thumbnail and a maker note, an HDR/MPO, an
edited photo with half a megabyte of XMP and an ICC profile are all taken and
turned. Verified end to end through nginx on a warm 512 MB backend: the MPF
and EXIF bombs to all three routes left the backend and the worker at their
warm peak, oom_kill 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into fix/1087-exif-open-bomb
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 1m13s
CI / Bot/backend version sync (pull_request) Successful in 25s
CI / Does this change touch the recording path? (pull_request) Successful in 27s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Feedback site tests (pull_request) Successful in 43s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 2m13s
CI / Bot tests and audit (pull_request) Successful in 3m19s
CI / Docker image build (pull_request) Successful in 4m6s
CI / Frontend (shard 2/2) (pull_request) Successful in 14m36s
CI / Frontend (shard 1/2) (pull_request) Successful in 18m26s
CI / End-to-end journeys (phone) (pull_request) Successful in 23m14s
CI / End-to-end journeys (keyboard) (pull_request) Successful in 24m56s
CI / End-to-end journeys (desktop) (pull_request) Successful in 25m4s
CI / Backend migration, tests, and audit (pull_request) Successful in 51m23s
6a302c47b2
Author
Collaborator

Independent review of 6a302c47: not to be merged yet. One blocking defect, two regressions for honest files, four smaller points. Fixes are in progress on this branch.

  1. Blocking, confirmed by running: the JPEG strip can be bypassed. The stripper and Pillow disagree on which markers carry a length. Pillow reads no length after FFC8, FFF0 to FFFD and FF00, and resumes scanning inside what the stripper copied as one opaque segment. An EXIF block placed there survives: after the strip, Image.open still reported exif, so the parse this PR exists to prevent still ran. Fix: an allowlist that mirrors Pillow's marker table, with a test over all 256 marker bytes so a Pillow upgrade cannot reopen it.
  2. Regression, confirmed by running: a YCCK JPEG is stored with wrong colours. Dropping APP14 removes what libjpeg uses to tell YCCK from CMYK, and Photoshop writes CMYK JPEGs as YCCK. Fix: keep an APP14 that is the fixed 12-byte Adobe segment.
  3. Regression, confirmed by running: large honest PNGs are refused. The 4,096-piece cap counts image-data chunks. Encoders that write 8 KB chunks would be refused above about 33.5 MB, and the map cap is 75 MB. Fix: do not count image data.
  4. Memory, by reading: the map path holds about four copies of the file before decoding (about 300 MB for a 75 MB map), where main held one. Fix: strip once, without intermediate copies; then re-measure.
  5. Low: the orientation comes from the first EXIF block that says "turn", not from the first EXIF block.
  6. Low: three walks are bounded by file size, not by the piece cap, and the comment says otherwise.
  7. Low: the "one place opens upload bytes" test matches only the literal Image.open(...).

Found sound

  • PNG, WebP and GIF part boundaries agree with Pillow's readers.
  • Pixels were identical after the strip for the ordinary files tried in each format.
  • A file cannot supply the orientation itself, and the turn is applied once.
  • No malformed container becomes a 500.
  • Every upload and import path goes through the one opener.

Not checked by the review: the test suites, and memory or timing by measurement. Part of the review was cut short, so items 4 and 6 are from reading.

**Independent review of `6a302c47`: not to be merged yet.** One blocking defect, two regressions for honest files, four smaller points. Fixes are in progress on this branch. 1. **Blocking, confirmed by running: the JPEG strip can be bypassed.** The stripper and Pillow disagree on which markers carry a length. Pillow reads no length after `FFC8`, `FFF0` to `FFFD` and `FF00`, and resumes scanning inside what the stripper copied as one opaque segment. An EXIF block placed there survives: after the strip, `Image.open` still reported `exif`, so the parse this PR exists to prevent still ran. Fix: an allowlist that mirrors Pillow's marker table, with a test over all 256 marker bytes so a Pillow upgrade cannot reopen it. 2. **Regression, confirmed by running: a YCCK JPEG is stored with wrong colours.** Dropping `APP14` removes what libjpeg uses to tell YCCK from CMYK, and Photoshop writes CMYK JPEGs as YCCK. Fix: keep an `APP14` that is the fixed 12-byte Adobe segment. 3. **Regression, confirmed by running: large honest PNGs are refused.** The 4,096-piece cap counts image-data chunks. Encoders that write 8 KB chunks would be refused above about 33.5 MB, and the map cap is 75 MB. Fix: do not count image data. 4. **Memory, by reading: the map path holds about four copies of the file** before decoding (about 300 MB for a 75 MB map), where main held one. Fix: strip once, without intermediate copies; then re-measure. 5. **Low: the orientation comes from the first EXIF block that says "turn"**, not from the first EXIF block. 6. **Low: three walks are bounded by file size, not by the piece cap**, and the comment says otherwise. 7. **Low: the "one place opens upload bytes" test** matches only the literal `Image.open(...)`. **Found sound** - PNG, WebP and GIF part boundaries agree with Pillow's readers. - Pixels were identical after the strip for the ordinary files tried in each format. - A file cannot supply the orientation itself, and the turn is applied once. - No malformed container becomes a 500. - Every upload and import path goes through the one opener. **Not checked by the review:** the test suites, and memory or timing by measurement. Part of the review was cut short, so items 4 and 6 are from reading.
Address the review of the open-time metadata strip (#1088)
All checks were successful
CI / Does this change touch the recording path? (pull_request) Successful in 38s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Bot/backend version sync (pull_request) Successful in 54s
CI / Backend lint (ruff) (pull_request) Successful in 1m13s
CI / Feedback site tests (pull_request) Successful in 1m31s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m44s
CI / Bot tests and audit (pull_request) Successful in 2m45s
CI / Docker image build (pull_request) Successful in 2m59s
CI / End-to-end journeys (phone) (pull_request) Successful in 11m3s
CI / End-to-end journeys (keyboard) (pull_request) Successful in 11m52s
CI / Frontend (shard 2/2) (pull_request) Successful in 17m18s
CI / Frontend (shard 1/2) (pull_request) Successful in 22m21s
CI / End-to-end journeys (desktop) (pull_request) Successful in 27m3s
CI / Backend migration, tests, and audit (pull_request) Successful in 36m19s
ba1c694b12
Seven points from the independent review of #1087:

1. BLOCKING — the JPEG stripper and Pillow disagreed on which markers carry
   a length, so an EXIF/MPF segment hidden behind a JPG/JPG0-13 marker (FFC8,
   FFF0-FFFD) or FF00 survived the strip and Pillow parsed it after all. The
   marker walk now mirrors Pillow's MARKER table exactly (one shared set used
   by _strip_jpeg and _jpeg_profile): length-carrying markers are kept or
   dropped, standalone markers pass, and any marker Pillow would meet with
   "no marker found" is refused, as libjpeg refuses it before the scan. A
   table-driven test over all 256 bytes asserts the classes match Pillow's
   MARKER[i][2] is None, and a 254-marker property test asserts no marker
   lets an EXIF segment reach Pillow. The other three formats were checked:
   their chunk/block boundaries already agree with Pillow, and a per-format
   test asserts no exif/icc/comment/xmp/text/mp/dpi is parsed after a strip.

2. REGRESSION — dropping APP14 changed a YCCK (Adobe transform 2) JPEG's
   colours, because libjpeg reads it to tell YCCK from CMYK and RGB from
   YCbCr. A 12-byte Adobe APP14 is now kept (and the JFIF APP0, which the
   decoder reads for DPI and which lets a clean export pass uncopied). Tests
   build transform=2 and keep_rgb fixtures and assert identical pixels.

3. REGRESSION — _MAX_PIECES counted PNG IDAT (and APNG fdAT) chunks, so an
   honest large PNG written as many small data chunks was refused. Image data
   (PNG IDAT/fdAT, WebP image chunks) is no longer counted; metadata segments
   still are. Tests: a 26 MB PNG of 97k IDATs and a 1000-frame GIF are taken.

4. MEMORY — the map path held several copies of the file. The strip now
   returns the original object when it removes nothing (the common map,
   which carries only JFIF), builds one copy by a single join when it does,
   and _reencode sanitises once and opens twice on the one clean copy,
   releasing the first image first. Measured in the 1 GB worker: a 65 MB 8K
   JPEG map at the bound peaks +216 MB with no metadata (was +278), +278 MB
   with a large EXIF to strip; cgroup peak 336-467 MB. The backend's worst
   still at the picture bound is +61 MB, within the +58 envelope.

5. LOW — the orientation was taken from the first EXIF block that was not
   upright; it is now the first EXIF block, full stop, as every viewer reads
   it. Tested per format with a first block of 1 and a second of 6.

6. LOW — the "bounded in pieces" claim was corrected: the piece cap also
   counts JPEG fill/standalone steps, and the byte-stepping inner walks (a
   GIF's sub-blocks) are bounded by the file size, as _gif_screen_and_frames
   already is. Worst walk ~2-3 s at 8 MB, no extra memory.

7. LOW — the gateway test now resolves any call to PIL's open or
   ImageFile.Parser through the module's imports (aliases, from-imports),
   counts opens per function against an expected count, scans demo/ too, and
   lists every exempt site (the demo seed included) with its reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Collaborator

All seven review findings fixed in ba1c694b, each with a test.

Finding Fix
1. The JPEG strip could be bypassed One marker table, shared by the strip and the scan count, mirrors Pillow's. A marker Pillow reads no length for (FFC8, FFF0 to FFFD, anything absent from its table) is refused; FF00 is dropped. A test over all 256 marker bytes holds the table to Pillow's, and another puts an EXIF block behind each marker and expects a refusal or no exif
2. YCCK colours The fixed 12-byte Adobe APP14 is kept; any other APP14 is dropped. APP0 (JFIF) is kept too. Pixels are identical before and after for a YCCK and an RGB-with-Adobe JPEG
3. Large honest PNGs refused Image data (IDAT, fdAT, the WebP image chunks) is not counted towards the piece cap. A 26 MB PNG of 97,000 small chunks and a 1,000-frame GIF are accepted
4. Copies on the map path The original bytes are returned untouched when nothing is removed; otherwise one copy is built. The strip runs once per picture
5. Which EXIF block gives the orientation The first one, even if it says upright
6. What the piece cap bounds Every JPEG loop step now counts; the comment and docs say which walks are bounded by pieces and which by file size
7. The one-opener test It resolves PIL's open and ImageFile.Parser through each module's imports, counts opens per function, and scans app/ and demo/, with every exempt site listed

Measured after the fixes

  • A 65 MB map at the pixel bound, in the 1 GB worker: +216 MB with nothing to strip, +278 MB with a large EXIF block. Cgroup peaks 336 MB and 467 MB.
  • The backend's worst still at the picture bound: +61 MB.
  • Worst walk at 8 MB: 2 to 3 s, flat in memory.

Ran at ba1c694b

  • The new test file: 300 passed. The orientation and pixel-bound tests: 299 passed.
  • tests/wiki with the damaged-image and session-hygiene tests: 1232 passed. tests/export: 462 passed.
  • Full backend suite: 7942 passed, 14 skipped, 107 deselected.
  • Lint clean; version and changelog checks OK.
  • Through nginx on a warm backend:
    • the bypass file (EXIF behind FFF0): 422, backend flat at 452 MiB;
    • a YCCK JPEG: stored with the right colours (largest pixel difference 1);
    • oom_kill 0, no restarts.

Not tested

  • A full 75 MB map: the measurement used 65 MB.
  • A genuine Photoshop file: the YCCK fixture is a patched transform byte.
  • No second independent review was run on the fix commit.
  • Frontend, bot, synthetic and journeys suites: untouched; CI.

One residual point, by reading: APP0 segments are kept whole, so their total is bounded by the file's size, not by a fixed limit.

**All seven review findings fixed in `ba1c694b`, each with a test.** | Finding | Fix | |---|---| | 1. The JPEG strip could be bypassed | One marker table, shared by the strip and the scan count, mirrors Pillow's. A marker Pillow reads no length for (`FFC8`, `FFF0` to `FFFD`, anything absent from its table) is refused; `FF00` is dropped. A test over all 256 marker bytes holds the table to Pillow's, and another puts an EXIF block behind each marker and expects a refusal or no `exif` | | 2. YCCK colours | The fixed 12-byte Adobe `APP14` is kept; any other `APP14` is dropped. `APP0` (JFIF) is kept too. Pixels are identical before and after for a YCCK and an RGB-with-Adobe JPEG | | 3. Large honest PNGs refused | Image data (`IDAT`, `fdAT`, the WebP image chunks) is not counted towards the piece cap. A 26 MB PNG of 97,000 small chunks and a 1,000-frame GIF are accepted | | 4. Copies on the map path | The original bytes are returned untouched when nothing is removed; otherwise one copy is built. The strip runs once per picture | | 5. Which EXIF block gives the orientation | The first one, even if it says upright | | 6. What the piece cap bounds | Every JPEG loop step now counts; the comment and docs say which walks are bounded by pieces and which by file size | | 7. The one-opener test | It resolves PIL's `open` and `ImageFile.Parser` through each module's imports, counts opens per function, and scans `app/` and `demo/`, with every exempt site listed | **Measured after the fixes** - A 65 MB map at the pixel bound, in the 1 GB worker: +216 MB with nothing to strip, +278 MB with a large EXIF block. Cgroup peaks 336 MB and 467 MB. - The backend's worst still at the picture bound: +61 MB. - Worst walk at 8 MB: 2 to 3 s, flat in memory. **Ran at `ba1c694b`** - The new test file: `300 passed`. The orientation and pixel-bound tests: `299 passed`. - `tests/wiki` with the damaged-image and session-hygiene tests: `1232 passed`. `tests/export`: `462 passed`. - **Full backend suite:** `7942 passed, 14 skipped, 107 deselected`. - Lint clean; version and changelog checks OK. - **Through nginx on a warm backend:** - the bypass file (EXIF behind `FFF0`): 422, backend flat at 452 MiB; - a YCCK JPEG: stored with the right colours (largest pixel difference 1); - `oom_kill 0`, no restarts. **Not tested** - A full 75 MB map: the measurement used 65 MB. - A genuine Photoshop file: the YCCK fixture is a patched transform byte. - No second independent review was run on the fix commit. - Frontend, bot, synthetic and journeys suites: untouched; CI. **One residual point, by reading:** `APP0` segments are kept whole, so their total is bounded by the file's size, not by a fixed limit.
claude-bot deleted branch fix/1087-exif-open-bomb 2026-10-05 00:38:59 +00:00
rbrooks referenced this pull request from a commit 2026-10-05 00:39:29 +00:00
Sign in to join this conversation.
No description provided.