From d2d8986236c9c0d8156eb33644566ed422d10c6b Mon Sep 17 00:00:00 2001 From: texasich Date: Thu, 16 Apr 2026 21:14:57 -0500 Subject: [PATCH 1/2] cli/context/store: cap tls file size on zip import `io.ReadAll(f)` on the decompressed tls/* entries was unbounded, so a zip whose compressed archive is within the 10 MiB outer cap could still decompress to multi-gigabyte TLS files and OOM the CLI. The meta.json branch right above already wraps its reader in limitedReader, this just mirrors it for the tls branch. Also fast-fails on the advertised UncompressedSize64 before calling zf.Open, so a well-formed zip bomb is rejected without any decompression at all. limitedReader still guards the stream in case the header lies. Fixes #6917 Signed-off-by: texasich --- cli/context/store/store.go | 9 ++++++++- cli/context/store/store_test.go | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/cli/context/store/store.go b/cli/context/store/store.go index 2b8b5c311478..c0274b90f9d9 100644 --- a/cli/context/store/store.go +++ b/cli/context/store/store.go @@ -474,11 +474,18 @@ func importZip(name string, s Writer, reader io.Reader) error { } importedMetaFile = true } else if strings.HasPrefix(zf.Name, "tls/") { + // Reject entries whose advertised uncompressed size exceeds + // the per-file cap without decompressing, to avoid allocating + // gigabytes for a zip bomb (see #6917). + if zf.UncompressedSize64 > uint64(maxAllowedFileSizeToImport) { + return invalidParameter(fmt.Errorf("%s: tls file exceeds maximum allowed size", zf.Name)) + } f, err := zf.Open() if err != nil { return err } - data, err := io.ReadAll(f) + // Defense in depth in case the zip header is spoofed. + data, err := io.ReadAll(&limitedReader{R: f, N: maxAllowedFileSizeToImport}) defer f.Close() if err != nil { return err diff --git a/cli/context/store/store_test.go b/cli/context/store/store_test.go index 20d0ef6463f6..b508c47cbe2b 100644 --- a/cli/context/store/store_test.go +++ b/cli/context/store/store_test.go @@ -211,6 +211,41 @@ func TestImportZip(t *testing.T) { assert.NilError(t, err) } +// TestImportZipTLSTooLarge verifies that a TLS entry whose uncompressed +// size exceeds the per-file limit is rejected instead of being read into +// memory unbounded (zip-bomb protection, see issue #6917). +func TestImportZipTLSTooLarge(t *testing.T) { + meta, err := json.Marshal(Metadata{ + Endpoints: map[string]any{ + "ep1": endpoint{Foo: "bar"}, + }, + Metadata: context{Bar: "baz"}, + Name: "source", + }) + assert.NilError(t, err) + + buf := new(bytes.Buffer) + w := zip.NewWriter(buf) + + mf, err := w.Create("meta.json") + assert.NilError(t, err) + _, err = mf.Write(meta) + assert.NilError(t, err) + + tf, err := w.Create(path.Join("tls", "docker", "ca.pem")) + assert.NilError(t, err) + // Write well over the per-file cap; zeros compress to a tiny archive + // so the outer archive-size cap is not hit first. + oversized := make([]byte, 2*maxAllowedFileSizeToImport) + _, err = tf.Write(oversized) + assert.NilError(t, err) + assert.NilError(t, w.Close()) + + s := New(t.TempDir(), testCfg) + err = Import("zipBomb", s, bytes.NewReader(buf.Bytes())) + assert.ErrorContains(t, err, "tls file exceeds maximum allowed size") +} + func TestImportZipInvalid(t *testing.T) { testDir := t.TempDir() zf := path.Join(testDir, "test.zip") From 580b3e50cfd2541cce6fb206d7962f0543822bd7 Mon Sep 17 00:00:00 2001 From: texasich Date: Mon, 20 Apr 2026 10:20:53 -0500 Subject: [PATCH 2/2] cli/context/store: extract importTLSEntry helper Pulls the per-entry TLS decoding out of importZip so the outer loop stays under gocyclo's complexity limit. Pure refactor, no behavior change. Signed-off-by: texasich --- cli/context/store/store.go | 39 ++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/cli/context/store/store.go b/cli/context/store/store.go index c0274b90f9d9..0832d4ecf795 100644 --- a/cli/context/store/store.go +++ b/cli/context/store/store.go @@ -474,24 +474,7 @@ func importZip(name string, s Writer, reader io.Reader) error { } importedMetaFile = true } else if strings.HasPrefix(zf.Name, "tls/") { - // Reject entries whose advertised uncompressed size exceeds - // the per-file cap without decompressing, to avoid allocating - // gigabytes for a zip bomb (see #6917). - if zf.UncompressedSize64 > uint64(maxAllowedFileSizeToImport) { - return invalidParameter(fmt.Errorf("%s: tls file exceeds maximum allowed size", zf.Name)) - } - f, err := zf.Open() - if err != nil { - return err - } - // Defense in depth in case the zip header is spoofed. - data, err := io.ReadAll(&limitedReader{R: f, N: maxAllowedFileSizeToImport}) - defer f.Close() - if err != nil { - return err - } - err = importEndpointTLS(&tlsData, zf.Name, data) - if err != nil { + if err := importTLSEntry(zf, &tlsData); err != nil { return err } } @@ -502,6 +485,26 @@ func importZip(name string, s Writer, reader io.Reader) error { return s.ResetTLSMaterial(name, &tlsData) } +func importTLSEntry(zf *zip.File, tlsData *ContextTLSData) error { + // Reject entries whose advertised uncompressed size exceeds + // the per-file cap without decompressing, to avoid allocating + // gigabytes for a zip bomb (see #6917). + if zf.UncompressedSize64 > uint64(maxAllowedFileSizeToImport) { + return invalidParameter(fmt.Errorf("%s: tls file exceeds maximum allowed size", zf.Name)) + } + f, err := zf.Open() + if err != nil { + return err + } + defer f.Close() + // Defense in depth in case the zip header is spoofed. + data, err := io.ReadAll(&limitedReader{R: f, N: maxAllowedFileSizeToImport}) + if err != nil { + return err + } + return importEndpointTLS(tlsData, zf.Name, data) +} + func parseMetadata(data []byte, name string) (Metadata, error) { var meta Metadata if err := json.Unmarshal(data, &meta); err != nil {