Skip to content
Open
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
6 changes: 3 additions & 3 deletions cli/context/store/io_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ func (l *limitedReader) Read(p []byte) (n int, err error) {
if l.N < 0 {
return 0, errors.New("read exceeds the defined limit")
}
if l.N == 0 {
return 0, io.EOF
}
Comment on lines -19 to -21

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious; why was this condition removed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — this was intentional to fix a boundary detection gap with chunked reads.

The old early-return at l.N == 0 silently masks data beyond the limit as io.EOF when the underlying reader delivers data in chunks. This is realistic for zip decompression streams that don't deliver all bytes in a single Read() call.

The bug scenario:

// Underlying reader has 5 bytes but delivers them in 2-byte chunks: AB, CD, E
// Limit is 4 bytes
r := &chunkedReader{data: []byte("ABCDE"), chunkSize: 2}
lr := &limitedReader{R: r, N: 4}
data, err := io.ReadAll(lr)

// OLD: reads AB (N=2), reads CD (N=0), next call returns io.EOF
//      → ReadAll returns ("ABCD", nil) — SILENT TRUNCATION!
//      The 5th byte "E" is never detected.

// NEW: reads AB (N=2), reads CD (N=0), next call probes with 1-byte read,
//      gets "E" (N=-1) → returns error
//      → ReadAll returns ("ABCD", "read exceeds the defined limit") — CAUGHT!

The old code's if l.N == 0 { return 0, io.EOF } tells io.ReadAll "stream ended cleanly" when the underlying reader actually has more data beyond the limit. This is a security concern for the zip bomb fix — if a TLS entry decompresses to exactly 10MB in chunks, the old code returns EOF. If it's 10MB+1 byte delivered as (10MB chunk) + (1 byte chunk), the old code also returns EOF after reading 10MB, silently truncating without error.

The new code removes that early return and probes the underlying reader at the zero-remaining boundary: when N reaches 0, the next read is capped to 1 byte (N+1). If the underlying reader returns data, N goes to -1 and we report the violation. If it returns io.EOF, we propagate it cleanly.

The regression test validates this — the "Tes" (3 bytes, N=2) case catches both implementations via the N+1 cap, but the chunked scenario is where the old code fails.

// have to cap N + 1 otherwise we won't hit limit err
if int64(len(p)) > l.N+1 {
p = p[0 : l.N+1]
}
n, err = l.R.Read(p)
l.N -= int64(n)
if l.N < 0 {
return n, errors.New("read exceeds the defined limit")
}
return n, err
}
4 changes: 4 additions & 0 deletions cli/context/store/io_utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@ func TestLimitReaderReadAll(t *testing.T) {
r = strings.NewReader("Test")
_, err = io.ReadAll(&limitedReader{R: r, N: 2})
assert.Error(t, err, "read exceeds the defined limit")

r = strings.NewReader("Tes")
_, err = io.ReadAll(&limitedReader{R: r, N: 2})
assert.Error(t, err, "read exceeds the defined limit")
}
2 changes: 1 addition & 1 deletion cli/context/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ func importZip(name string, s Writer, reader io.Reader) error {
if err != nil {
return err
}
data, err := io.ReadAll(f)
data, err := io.ReadAll(&limitedReader{R: f, N: maxAllowedFileSizeToImport})
defer f.Close()
if err != nil {
return err
Expand Down
40 changes: 40 additions & 0 deletions cli/context/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,46 @@ func TestImportZip(t *testing.T) {
assert.NilError(t, err)
}

func TestImportZipRejectsOversizedTLSFile(t *testing.T) {
testDir := t.TempDir()
zf := path.Join(testDir, "test.zip")

f, err := os.Create(zf)
assert.NilError(t, err)
defer f.Close()
w := zip.NewWriter(f)

meta, err := json.Marshal(Metadata{
Endpoints: map[string]any{
"ep1": endpoint{Foo: "bar"},
},
Metadata: context{Bar: "baz"},
Name: "source",
})
assert.NilError(t, err)

mf, err := w.Create(metaFile)
assert.NilError(t, err)
_, err = mf.Write(meta)
assert.NilError(t, err)

tlsFile, err := w.Create(path.Join("tls", "docker", "ca.pem"))
assert.NilError(t, err)
_, err = tlsFile.Write(bytes.Repeat([]byte{0}, int(maxAllowedFileSizeToImport)+1))
assert.NilError(t, err)

err = w.Close()
assert.NilError(t, err)

source, err := os.Open(zf)
assert.NilError(t, err)
defer source.Close()
var r io.Reader = source
s := New(testDir, testCfg)
err = Import("zipOversizedTLS", s, r)
assert.ErrorContains(t, err, "read exceeds the defined limit")
}

func TestImportZipInvalid(t *testing.T) {
testDir := t.TempDir()
zf := path.Join(testDir, "test.zip")
Expand Down
Loading