Skip to content

fix(image): cap metadata, layer, and daemon-spool reads against resource exhaustion#80

Open
deveshctl wants to merge 3 commits into
mainfrom
fix/resource-exhaustion-caps
Open

fix(image): cap metadata, layer, and daemon-spool reads against resource exhaustion#80
deveshctl wants to merge 3 commits into
mainfrom
fix/resource-exhaustion-caps

Conversation

@deveshctl

Copy link
Copy Markdown
Owner

Summary

Closes three resource-exhaustion paths in the archive/spool pipeline that sat outside the existing per-blob cap. Each is reachable from layerx <archive.tar> with no daemon, and each could exhaust memory or disk before any meaningful parsing began.

Changes

  • Gzip-bomb layer (analysis path). A tiny compressed layer blob could expand without bound because the analysis path handed the decompressed reader straight to the tar parser. It is now bounded by an io.LimitReader at the same 16 GiB ceiling the extraction path already enforced, so both paths are equally bomb-resistant.
  • Archive descriptors. manifest.json, the image config, and legacy root-level *.json files were read with an unbounded io.ReadAll that trusted the tar header's declared size. A new 64 MiB cap (MaxMetadataSize) plus a shared readMetadataEntry helper fails fast when a header overstates the size and bounds the stream when the header understates it — a hostile archive can lie in either direction.
  • Daemon image-save stream. The ImageSave response was spooled to a temp file with no ceiling, letting a rogue or compromised container-engine endpoint fill the local disk with an endless response. A new 64 GiB cap (MaxArchiveSize) via spoolFromDaemon bounds both daemon spool sites.

Cap sizes are ordered so legitimate images are unaffected: 64 MiB metadata < 16 GiB per layer < 64 GiB whole archive.

Also adds a golden test pinning the full efficiency result for a fixed multi-layer fixture, so a refactor that silently changes the waste denominator fails loudly, and pins the nightly workflow's Go toolchain to a patch release.

Testing

  • go build ./... and go vet ./... clean.
  • New unit tests cover: descriptor rejection for both overstated and understated headers, the daemon-spool cap firing against an unbounded stream without allocating, and the golden efficiency result.
  • Verified against a real Docker daemon and an on-disk OCI/tar archive: normal images analyze unchanged, and a crafted archive with a forged oversized descriptor header returns a clear "too large" error instead of stalling.

deveshctl and others added 3 commits July 13, 2026 11:29
Three resource-exhaustion paths in the archive/spool pipeline sat outside
the existing MaxLayerBlobSize cap, each reachable from 'layerx <archive.tar>'
with no daemon:

- A gzip-bomb layer expanded without bound because the analysis path passed
  the decompressed reader straight to ParseLayerTar. It now mirrors the
  extraction path with io.LimitReader(dec, MaxLayerBlobSize+1).
- manifest.json / image config / legacy root *.json were read with an
  unbounded io.ReadAll that trusted the tar header's declared size. A new
  MaxMetadataSize (64 MiB) cap plus a shared readMetadataEntry helper fails
  fast on an inflated header and bounds the stream when the header understates.
- The daemon ImageSave response was spooled to a temp file with no ceiling,
  letting a rogue DOCKER_HOST fill the disk. A new MaxArchiveSize (64 GiB)
  cap via spoolFromDaemon bounds both daemon spool sites.

Also add TestEfficiency_Golden pinning the full EfficiencyResult for a fixed
multi-layer fixture, so a refactor touching the waste denominator fails loudly.
The golden fixture's /etc/config appears at the same 300-byte size in L1
and L2. The test assumed Stack would classify the L2 re-emit as Unchanged
carryover and omit it from the waste run, expecting WastedBytes=2200 and a
single WastedFile.

Stack has no identical-size shortcut for files: a path present in both the
cumulative tree and the incoming layer is always Modified, so /etc/config
opens a second Added/Modified occurrence that pathRuns records. The real
result is a run [(L1,300),(L2,300)] charging 300 wasted bytes, giving
WastedBytes=2500, Score=1-2500/4800, and two WastedFiles (/bin/app 2200,
/etc/config 300).

Correct the expectations and the derivation comment to match the actual
waste-accounting semantics. Production code is unchanged; only the test's
predicted numbers were wrong.
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.

1 participant