isthisazipbomb.com

Drop an archive and find out whether unpacking it will destroy your machine — without unpacking it.

Featuredcomplete/actively developingGoTypeScriptNext.jsDockersecurityCaddy
Started
Released

A ZIP bomb is a small file that expands into an enormous one. 42.zip is 42 KB and unpacks to 4.5 petabytes. The only safe way to find out what an archive contains is to read its structure rather than its contents — and that is all this tool does.

The verdict#

The analyzer returns a risk score, a confidence figure, and — more importantly — the reasoning behind both. A number on its own is not actionable; the signals that produced it are.

┌──────────────────────────────────────────────┐
│  🔴 Likely ZIP Bomb                          │
│                                              │
│  RISK SCORE            CONFIDENCE            │
│  86%                   85%                   │
└──────────────────────────────────────────────┘

Why?
  ✓ Compression ratio: 1,029 : 1                              +30.2
  ✓ Expansion confirmed by decompression, not just by metadata  +25
  ✓ Estimated extracted size: 1.0 GB (reported by the archive) +16.2
  ✓ Decompressed entropy: 0.00 bits/byte                        +15

Architecture#

Browser ──▶ Caddy ──┬──▶ web  (Next.js, static page)
                    └──▶ api  (Go, the analyzer)

Three containers. Only Caddy publishes a port; web and api sit on an internal Docker network with no gateway, so neither can reach the internet.

Why Caddy is in the path#

The obvious design proxies /api through Next.js with a rewrites() entry — one fewer container, same single origin. That was built first, and it does not work: Next's rewrite proxy drops large request bodies, so a 13 MB upload stalls for 30 seconds and dies with socket hang up.

Caddy streams uploads straight to the analyzer instead. Node never allocates a byte for them.

The analyzer#

bytes
  ├─▶ sniff ──────────▶ format         magic bytes only; the filename is ignored
  ├─▶ open ───────────▶ structure      central directory / headers, zero inflation
  ├─▶ walk ───────────▶ entries        budgeted, depth-limited, streaming
  │     ├─▶ probe      nested archive? entropy?
  │     ├─▶ verify     measured expansion vs. declared
  │     └─▶ identify   self-reference (quine) detection
  ├─▶ aggregate ──────▶ metrics
  ├─▶ score ──────────▶ weighted signals
  └─▶ classify ───────▶ risk · confidence · findings · advice

The Go module has no dependencies beyond the standard library. Every third-party package would be another parser running against deliberately hostile input, so the bar for adding one is high.

Security model#

Nothing is written to disk — ever#

Enforced structurally rather than by policy:

  1. Uploads are read into a bounded in-memory buffer, freed when the request returns.
  2. The handler never calls ParseMultipartForm or FormFile. Both spill parts larger than their memory threshold to temporary files, which would break the guarantee silently. r.MultipartReader() is used directly instead.
  3. A test enforces it. nodisk_test.go parses every non-test source file and fails the build if it finds os.Create, os.CreateTemp, os.WriteFile, exec.Command, ParseMultipartForm, FormFile, or friends.
  4. The container's root filesystem is read-only, with /tmp a 16 MB noexec tmpfs.

"Uploaded files are deleted after analysis" is a weaker claim than what happens here: no file is ever created, so there is nothing to delete.

Nothing is decompressed without a budget#

Every decompressing reader is wrapped in a safety.LimitedReader tied to one shared Budget covering the entire recursive walk — not per entry, since 200,000 entries each claiming the maximum is the same attack.

The reader has one deliberate difference from io.LimitedReader: at its cap it returns ErrBudgetExceeded, not io.EOF. A silent EOF would make a bomb indistinguishable from a small honest file — the caller reads 1 MB, sees a clean end of stream, and concludes the entry was 1 MB.

Limits#

Hitting a limit is a result, not an error. "I stopped counting at 512 MB, and it was still expanding" is one of the most useful things this tool can say.

SettingDefaultBounds
MAX_UPLOAD_BYTES100 MBRequest body, held in memory only
MAX_INFLATE_BYTES512 MBTotal decompression across all depths
MAX_ENTRIES200,000Entries inspected
MAX_DEPTH8Nesting levels
MAX_NESTED_ARCHIVES64Nested archives opened
VERIFY_BYTES32 MBPer-entry verification decompression
ANALYZE_TIMEOUT20sWall clock per request
MAX_CONCURRENT4In-flight analyses
RATE_LIMIT_RPM20Requests per minute per client

Worst-case memory is MAX_UPLOAD_BYTES × MAX_CONCURRENT = 400 MB, against a 1 GB container limit. The figure is logged at boot so an operator does not have to derive it from four separate settings.

Container hardening#

The api container reads hostile input for a living, so it is locked down hardest:

  • FROM gcr.io/distroless/static:nonroot — no shell, no package manager, no interpreter. A parser bug reaching code execution finds nothing to execute.
  • read_only: true, cap_drop: [ALL], no-new-privileges, pids_limit: 128.
  • Attached only to a network with internal: trueno gateway, no egress. An archive that somehow achieved code execution has nothing to call home to.

What this protects against#

AttackControl
Disk exhaustionNothing is written; rootfs is read-only
Memory exhaustionByte budget on every decompress; container limit
CPU exhaustionPer-request deadline, entry/depth caps, CPU quota
Path traversal, symlink escapeDetected and reported; nothing is extracted, so nothing suffers
Malformed archivesDefensive parsing, checked arithmetic, per-request recover()
Parser confusionCentral and local ZIP headers cross-checked, disagreement flagged
SSRF from archive contentsThe analyzer container has no route off its network
Denial of servicePer-client token bucket + global concurrency semaphore
Cross-request data leakageNo disk, no cache, no shared state; one buffer per request