Layer extraction/gunzip has no decompression size cap (io.Copy //nolint:gosec "trusted layer content")

Open
#116 0 comments 1 reaction 2 assignees View on GitHub

@brunoborges is already working on this.

Since Sep 21, 2026.

  • #117 by @copilot-swe-agent — open

Assessment

This issue has not been assessed yet.

Description

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.

Dominant language
Go
Stars
0
Forks
0
Avg merge
9h 20m
Merged PRs (30d)
90

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from microsoft/brewlet

All issues in microsoft/brewlet

Similar issues

More Go issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.