Summary
Three copy loops move layer bytes without any size bound, each suppressed from gosec with a comment asserting the content is trusted:
- core/internal/artifact/blobs.go:427 (
gunzipToFile)
- core/internal/artifact/blobs.go:484 (
extractGzTar)
- core/internal/runtime/launch.go:508 (
extractTar)
if _, err := io.Copy(out, tr); err != nil { //nolint:gosec // trusted layer content
The premise of the suppression does not hold: layer content is publisher-supplied. A runnable image's layers are whatever the publisher pushed to the registry; the staging pipeline verifies integrity (digests) but not size. A gzip bomb or sparse-tar bomb inside a digest-verified layer therefore decompresses without limit into the node's staging root (/tmp/brewlet-runnable/...) or the bundle's classpath dirs — ENOSPC on a shared node, which is exactly the outcome the rest of the staging design works to bound.
For the gzip paths this is the decompression-bomb class gosec's G110 check exists for; the right handling is a bounded copy, not a suppression.
Repro
Any highly-compressible payload inside a layer, published as a legitimate digest-verified image:
$ dd if=/dev/zero bs=1M count=100 | gzip > payload.bin
# pack payload.bin into the image layer, publish, start the workload
$ df -h /tmp # before vs during extraction
The io.Copy in gunzipToFile/extractGzTar writes the full decompressed size with no cap; extractTar likewise copies uncompressed entry bodies with no per-entry or total bound.
Suggested fix
A bounded-copy helper used by all three sites:
// generous absolute ceiling; per-layer limits can ride the manifest's
// declared sizes where available
const maxLayerDecompressedBytes = 8 << 30
func copyBounded(dst io.Writer, src io.Reader) (int64, error) {
n, err := io.Copy(dst, io.LimitReader(src, maxLayerDecompressedBytes+1))
if err == nil && n > maxLayerDecompressedBytes {
return n, fmt.Errorf("layer exceeds decompression cap (%d bytes)", maxLayerDecompressedBytes)
}
return n, err
}
Fail the stage loudly on cap exceed rather than half-writing, then remove the nolint — the finding it suppresses is actually addressed.
Impact framing
Availability hardening, same class as node disk exhaustion from staging retention: publisher-controlled bytes can exhaust a shared node resource the publisher does not own. It does not cross the tenant boundary (a tenant can already fill its own persistent volumes); the gap is the shared staging root and bundle dirs.
Summary
Three copy loops move layer bytes without any size bound, each suppressed from gosec with a comment asserting the content is trusted:
gunzipToFile)extractGzTar)extractTar)The premise of the suppression does not hold: layer content is publisher-supplied. A runnable image's layers are whatever the publisher pushed to the registry; the staging pipeline verifies integrity (digests) but not size. A gzip bomb or sparse-tar bomb inside a digest-verified layer therefore decompresses without limit into the node's staging root (
/tmp/brewlet-runnable/...) or the bundle's classpath dirs — ENOSPC on a shared node, which is exactly the outcome the rest of the staging design works to bound.For the gzip paths this is the decompression-bomb class gosec's G110 check exists for; the right handling is a bounded copy, not a suppression.
Repro
Any highly-compressible payload inside a layer, published as a legitimate digest-verified image:
The
io.CopyingunzipToFile/extractGzTarwrites the full decompressed size with no cap;extractTarlikewise copies uncompressed entry bodies with no per-entry or total bound.Suggested fix
A bounded-copy helper used by all three sites:
Fail the stage loudly on cap exceed rather than half-writing, then remove the nolint — the finding it suppresses is actually addressed.
Impact framing
Availability hardening, same class as node disk exhaustion from staging retention: publisher-controlled bytes can exhaust a shared node resource the publisher does not own. It does not cross the tenant boundary (a tenant can already fill its own persistent volumes); the gap is the shared staging root and bundle dirs.