Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions core/internal/artifact/blobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
98 changes: 98 additions & 0 deletions core/internal/artifact/blobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
package artifact

import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"os"
"path/filepath"
Expand Down Expand Up @@ -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)
}
}
5 changes: 4 additions & 1 deletion core/internal/runtime/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
20 changes: 20 additions & 0 deletions core/internal/runtime/launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Empty file added integration-tests/go.sum
Empty file.
Loading