Skip to content

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

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions