diff --git a/core/internal/artifact/blobs.go b/core/internal/artifact/blobs.go index 6946d4e8..4152cbe8 100644 --- a/core/internal/artifact/blobs.go +++ b/core/internal/artifact/blobs.go @@ -408,6 +408,27 @@ func stageLayerTars(src BlobSource, layers []Descriptor, stageDir, prefix string return out, nil } +// MaxLayerDecompressedBytes caps how many bytes a single layer may expand to +// during staging. Layer content is publisher-supplied and only digest-verified, +// so a gzip or sparse-tar bomb would otherwise fill the shared staging root. +// The ceiling is deliberately generous; it exists to bound the damage, not to +// constrain legitimate payloads. Tests may lower it. +var MaxLayerDecompressedBytes int64 = 8 << 30 + +// CopyBounded copies src into dst, failing once more than limit bytes have been +// written rather than expanding a decompression bomb without bound. It returns +// the number of bytes copied (at most limit+1 on a cap breach). +func CopyBounded(dst io.Writer, src io.Reader, limit int64) (int64, error) { + n, err := io.Copy(dst, io.LimitReader(src, limit+1)) + if err != nil { + return n, err + } + if n > limit { + return n, fmt.Errorf("layer exceeds decompression cap of %d bytes", MaxLayerDecompressedBytes) + } + return n, nil +} + // gunzipToFile decompresses the verified gzip blob the descriptor names into dst. func gunzipToFile(src BlobSource, desc Descriptor, dst string) error { raw, err := ReadVerifiedBlob(src, desc) @@ -424,7 +445,9 @@ func gunzipToFile(src BlobSource, desc Descriptor, dst string) error { return err } defer f.Close() - if _, err := io.Copy(f, gr); err != nil { //nolint:gosec // trusted layer content + if _, err := CopyBounded(f, gr, MaxLayerDecompressedBytes); err != nil { + f.Close() + os.Remove(dst) // never leave a half-written layer behind return err } return f.Close() @@ -447,10 +470,11 @@ func extractGzTar(src BlobSource, desc Descriptor, destDir string) error { return err } tr := tar.NewReader(gr) + budget := MaxLayerDecompressedBytes for { hdr, err := tr.Next() if err == io.EOF { - _, err := io.Copy(io.Discard, gr) + _, err := CopyBounded(io.Discard, gr, budget) return err } if err != nil { @@ -481,7 +505,9 @@ func extractGzTar(src BlobSource, desc Descriptor, destDir string) error { if err != nil { return err } - if _, err := io.Copy(out, tr); err != nil { //nolint:gosec // trusted layer content + n, err := CopyBounded(out, tr, budget) + budget -= n + if err != nil { out.Close() return err } diff --git a/core/internal/artifact/blobs_test.go b/core/internal/artifact/blobs_test.go index df87c2e7..f477d27b 100644 --- a/core/internal/artifact/blobs_test.go +++ b/core/internal/artifact/blobs_test.go @@ -4,6 +4,9 @@ package artifact import ( + "archive/tar" + "bytes" + "compress/gzip" "encoding/json" "os" "path/filepath" @@ -522,3 +525,98 @@ func TestStagedPathRejectsEscape(t *testing.T) { t.Errorf("stagedPath = %q, want %q", got, want) } } + +// withDecompressionCap lowers the layer decompression cap for the duration of a +// test so a bomb can be simulated with a few kilobytes. +func withDecompressionCap(t *testing.T, limit int64) { + t.Helper() + prev := MaxLayerDecompressedBytes + MaxLayerDecompressedBytes = limit + t.Cleanup(func() { MaxLayerDecompressedBytes = prev }) +} + +func gzipBlob(t *testing.T, payload []byte) (Descriptor, testBlobSource) { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + if _, err := gw.Write(payload); err != nil { + t.Fatal(err) + } + if err := gw.Close(); err != nil { + t.Fatal(err) + } + desc := Descriptor{Digest: digestOf(buf.Bytes()), Size: int64(buf.Len())} + return desc, testBlobSource{desc.Digest: buf.Bytes()} +} + +func TestGunzipToFileRejectsDecompressionBomb(t *testing.T) { + withDecompressionCap(t, 1024) + desc, src := gzipBlob(t, make([]byte, 4096)) + dst := filepath.Join(t.TempDir(), "layer.tar") + if err := gunzipToFile(src, desc, dst); err == nil { + t.Fatal("expected gunzipToFile to reject a layer over the decompression cap") + } else if !strings.Contains(err.Error(), "decompression cap") { + t.Fatalf("gunzipToFile error = %v, want decompression cap", err) + } +} + +func TestGunzipToFileAcceptsLayerWithinCap(t *testing.T) { + withDecompressionCap(t, 4096) + desc, src := gzipBlob(t, make([]byte, 4096)) + dst := filepath.Join(t.TempDir(), "layer.tar") + if err := gunzipToFile(src, desc, dst); err != nil { + t.Fatalf("gunzipToFile: %v", err) + } + fi, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + if fi.Size() != 4096 { + t.Errorf("staged layer size = %d, want 4096", fi.Size()) + } +} + +func TestExtractGzTarRejectsDecompressionBomb(t *testing.T) { + withDecompressionCap(t, 1024) + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + // Two entries, each within the cap on its own but jointly over it: the + // budget is per extraction, not per entry. + for _, name := range []string{"a.jar", "b.jar"} { + if err := tw.WriteHeader(&tar.Header{ + Name: name, Mode: 0o644, Size: 800, Typeflag: tar.TypeReg, + }); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(make([]byte, 800)); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gw.Close(); err != nil { + t.Fatal(err) + } + desc := Descriptor{Digest: digestOf(buf.Bytes()), Size: int64(buf.Len())} + err := extractGzTar(testBlobSource{desc.Digest: buf.Bytes()}, desc, t.TempDir()) + if err == nil { + t.Fatal("expected extractGzTar to reject entries exceeding the decompression cap") + } + if !strings.Contains(err.Error(), "decompression cap") { + t.Fatalf("extractGzTar error = %v, want decompression cap", err) + } +} + +func TestGunzipToFileRemovesPartialOutputOnCapBreach(t *testing.T) { + withDecompressionCap(t, 1024) + desc, src := gzipBlob(t, make([]byte, 4096)) + dst := filepath.Join(t.TempDir(), "layer.tar") + if err := gunzipToFile(src, desc, dst); err == nil { + t.Fatal("expected gunzipToFile to reject a layer over the decompression cap") + } + if _, err := os.Stat(dst); !os.IsNotExist(err) { + t.Errorf("half-written layer left behind at %s (stat err %v)", dst, err) + } +} diff --git a/core/internal/runtime/launch.go b/core/internal/runtime/launch.go index 2b1be8b1..bfde9701 100644 --- a/core/internal/runtime/launch.go +++ b/core/internal/runtime/launch.go @@ -472,6 +472,7 @@ func extractTar(tarPath, destDir string) error { } defer f.Close() tr := tar.NewReader(f) + budget := artifact.MaxLayerDecompressedBytes for { hdr, err := tr.Next() if err == io.EOF { @@ -505,7 +506,9 @@ func extractTar(tarPath, destDir string) error { if err != nil { return err } - if _, err := io.Copy(out, tr); err != nil { //nolint:gosec // trusted layer content + n, err := artifact.CopyBounded(out, tr, budget) + budget -= n + if err != nil { out.Close() return err } diff --git a/core/internal/runtime/launch_test.go b/core/internal/runtime/launch_test.go index 55ac02d2..824b95d3 100644 --- a/core/internal/runtime/launch_test.go +++ b/core/internal/runtime/launch_test.go @@ -437,6 +437,26 @@ func TestExtractTarRejectsTraversal(t *testing.T) { } } +func TestExtractTarRejectsOversizedPayload(t *testing.T) { + prev := artifact.MaxLayerDecompressedBytes + artifact.MaxLayerDecompressedBytes = 8 + t.Cleanup(func() { artifact.MaxLayerDecompressedBytes = prev }) + + dir := t.TempDir() + tarPath := filepath.Join(dir, "big.tar") + // Two entries, each within the cap on its own but jointly over it: the + // budget is per tar, not per entry. + writeTar(t, tarPath, map[string]string{"a.jar": "aaaaa", "b.jar": "bbbbb"}) + + err := extractTar(tarPath, filepath.Join(dir, "lib")) + if err == nil { + t.Fatal("expected extractTar to reject a tar exceeding the decompression cap") + } + if !strings.Contains(err.Error(), "decompression cap") { + t.Fatalf("extractTar error = %v, want decompression cap", err) + } +} + func TestAssembleSandboxWithClasspathLayers(t *testing.T) { dir := t.TempDir() jarSrc := filepath.Join(dir, "app.jar") diff --git a/integration-tests/go.sum b/integration-tests/go.sum new file mode 100644 index 00000000..e69de29b