Skip to content

Ship hardened ImageMagick policy to mitigate image-bomb DoS#74

Merged
ChiragAgg5k merged 3 commits into
mainfrom
fix/imagemagick-policy-image-bomb
Jul 3, 2026
Merged

Ship hardened ImageMagick policy to mitigate image-bomb DoS#74
ChiragAgg5k merged 3 commits into
mainfrom
fix/imagemagick-policy-image-bomb

Conversation

@ChiragAgg5k

Copy link
Copy Markdown
Member

What

Ship a hardened ImageMagick policy.xml in the final base image to mitigate an image-decompression-bomb DoS reported against the Appwrite storage file-preview endpoint (GET /v1/storage/buckets/:bucketId/files/:fileId/preview), which also affects the avatars pipeline.

Why

The preview/avatars handlers decode untrusted images through Imagick. The utopia-php/image constructor calls readImageBlob(), which fully decodes the raster before any dimension is inspected — the endpoint's width/height params (capped at 4000) only apply to the output resize afterward. Appwrite's only guard is a compressed-file-size check (_APP_STORAGE_PREVIEW_LIMIT, 20MB) plus a memory resource limit (64MB).

A crafted image — tiny on disk, enormous dimensions (a "zip-bomb"-style image) — sails past the size check. When the decoded raster exceeds the memory limit, ImageMagick doesn't fail; it spills its pixel cache to disk unbounded (default disk limit is effectively unlimited, no policy.xml ships today). The reporter observed ~32GB of disk growth from repeated concurrent requests to one file, killing MongoDB on a 64GB volume and crashing the container on a 4GB one. Capping only memory actually makes it worse by forcing the spill to disk sooner.

The base image is the correct home: ImageMagick lives here, and every downstream image (appwrite, cloud, edge, tooling) inherits the fix — rather than patching each call site in each repo.

What the policy does

Only width/height/disk raise an exception when exceeded (memory/map/area merely move the cache to disk), so they are the real guards:

  • width/height = 16KP — hard reject oversized dimensions before the raster is allocated (well above the 4000px output cap)
  • disk = 1GiB — caps the pixel-cache spill instead of letting it fill the volume
  • memory 256MiB, map 512MiB, area 128MP, thread 2, time 60s, max-memory-request 256MiB
  • Disables delegate coders never used for previews (PS/EPS/PDF/XPS/MSL/MVG/HTTP/HTTPS/URL/…) — classic ImageMagick RCE/SSRF vectors. Preview inputs are limited to JPEG/PNG/HEIC/WEBP/GIF (output adds AVIF).
  • Blocks indirect reads (@*).

Installed into ImageMagick's configure dir, discovered at build time (identify -list configure) so it survives package path changes, and the build fails loudly if the policy doesn't load (identify -list policy | grep 16KP). Note: MAGICK_CONFIGURE_PATH is not honored by the Alpine ImageMagick build — verified during development — hence the direct install into the discovered dir.

Verification

Tested against alpine + imagemagick (same package as the final stage):

  • 20000×20000 bomb → rejected: width or height exceeds limit (20000x20000) > (16000x16000)
  • 800×600 resize → succeeds
  • 15000×100 (wide, under 16KP) → succeeds
  • PDF coder → denied
  • identify -list policy shows the width/height/disk limits active

Added a tests.yaml assertion so a regression (policy not loading) fails CI.

Rollout

After this merges and a new appwrite/base tag is cut, bump FROM appwrite/base:1.4.3 in appwrite/appwrite. A disk resource-limit backstop in app/config/storage/resource_limits.php there is worth adding too, so protection doesn't hinge solely on the base bump.

🤖 Generated with Claude Code

The storage/avatars preview pipeline decodes untrusted images through
Imagick with no bound on decoded dimensions. A crafted image (small on
disk, enormous dimensions) decodes unbounded and spills ImageMagick's
pixel cache to disk, filling the volume and killing MongoDB or the
container.

Install a hardened policy.xml capping width/height (hard reject before
raster allocation), disk spill, and memory/map/area, and disabling the
delegate coders never used for previews. Placed in ImageMagick's
configure dir (discovered at build time); the build fails if it does not
load. Added a tests.yaml assertion.
@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown

Greptile Summary

This PR ships a hardened ImageMagick policy.xml into the base image to mitigate the image-decompression-bomb DoS: a crafted file with tiny compressed size but huge decoded dimensions could previously spill ImageMagick's pixel cache unboundedly to disk, filling the volume and crashing the container. The policy is installed by discovering the configure dir at build time, and the build fails loudly if the policy does not load.

  • Resource limits: disk (4GiB, the real DoS guard), width/height (50KP fast-fail backstops), area (512MP), memory (256MiB), plus list-length and time limits to guard against animated-GIF and stalled-decode abuse.
  • Coder/delegate denials: blocks PS/EPS/PDF/XPS/MSL/MVG/SVG (coder + module to close the rsvg delegate route), URL-family coders, and HTTP/HTTPS delegates — all classic RCE/SSRF vectors unused by the preview pipeline.
  • Path deny: @* blocks indirect-read tricks that can leak local files.

Confidence Score: 5/5

Safe to merge; the policy correctly targets the real attack vectors and the build-time verification ensures it cannot silently not load.

The resource limits, coder denials, and installation mechanism are all sound. The two findings are minor quality notes — a no-op policy entry that could mislead future maintainers, and a CI assertion that could be slightly more precise about the disk value — neither affects runtime behavior or security coverage.

No files require special attention beyond the minor notes on policy.xml and tests.yaml.

Important Files Changed

Filename Overview
policy.xml New hardened ImageMagick policy; resource limits and coder/delegate denials are well-chosen with good inline documentation. The domain="Undefined" entry is a no-op but otherwise the policy is sound.
Dockerfile Adds policy installation block: discovers configure dir at build time, uses strip-and-colon-split to build a safe path, and verifies the policy loaded via grep — straightforward and correct.
tests.yaml Adds CI assertion for policy loading; checks width/height/disk policy names and the 50KP value, but omits the 4GiB disk value that is the primary DoS guard.
CHANGES.md Adds version 1.3.2 entry accurately describing the policy limits (50KP, 4GiB) — consistent with the actual policy.xml values.

Reviews (3): Last reviewed commit: "Raise dimension/disk limits so high-res ..." | Re-trigger Greptile

Comment thread Dockerfile Outdated
Comment thread policy.xml
- Dockerfile: strip trailing slash and take first entry of a
  colon-separated CONFIGURE_PATH so the policy is always written to a
  valid path.
- policy.xml: deny the SVG/SVGZ/MSVG module. A coder deny alone leaves
  the rsvg delegate route open; the module deny closes SVG's SSRF/XXE
  vector entirely. Verified jpg/png/gif/heic/avif still round-trip.
16KP (16000px) rejected legitimate high-res phone photos - a 200MP
sensor is ~16320px on its long side. Raise width/height to 50KP as a
fast-fail backstop only, and make disk (4GiB) the primary DoS control.

ImageMagick in the base image is Q16-HDRI (16 bytes/pixel), so a 150MP
photo needs ~2.4GB of cache; 4GiB disk gives headroom while memory stays
at 256MiB to bound per-worker RAM. Verified a 9180x16320 PNG and a
realistic 150MP JPEG both preview-resize successfully, while a
60000px image is rejected on dimensions and a ~1.6GP image is bounded by
the disk cap.
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

Re: the 16KP dimension cap being too low for high-res phone photos (e.g. 9180×16320) — fixed in 1e1370c.

Reworked the sizing so dimension limits are only a fast-fail backstop and disk is the real DoS control:

  • width/height: 16KP → 50KP (well above any real camera; a 200MP sensor is ~16320px long-side)
  • disk: 1GiB → 4GiB — the base image ships ImageMagick Q16-HDRI (16 bytes/pixel), so a 150MP photo needs ~2.4GB of pixel cache; 4GiB gives headroom
  • memory stays at 256MiB so per-worker RAM is bounded under Swoole concurrency (large images use the memory-mapped/disk path)

Verified against your exact resolution:

  • 9180×16320 PNG and a realistic 944KB 150MP JPEG → both preview-resize to a thumbnail successfully
  • normal png/gif/jpg/heic/avif → unaffected
  • 60000×60000 → rejected fast on dimensions
  • ~1.6GP image (under 50KP) → bounded by the 4GiB disk cap instead of filling the volume

So legitimate photos are no longer rejected, while a bomb is still capped to ~4GiB transient disk per operation (vs the unbounded ~32GB in the original report).

@ChiragAgg5k ChiragAgg5k merged commit c11836d into main Jul 3, 2026
11 checks passed
@ChiragAgg5k ChiragAgg5k deleted the fix/imagemagick-policy-image-bomb branch July 3, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants