fix: reject oversized and invalid zip uploads (#25877)

Enforce aggregate limits when converting uploaded ZIP archives to tar
so compressed inputs cannot expand without bound in memory.

Also treat malformed ZIP entry metadata and content mismatches as
client errors during conversion, returning 400 for invalid archives and
413 when expanded tar output exceeds the upload limit.

Ref: https://linear.app/codercom/issue/PLAT-274/zip-upload-decompressed-without-aggregate-size-limit-sec-103
This commit is contained in:
George K
2026-06-02 10:11:49 -07:00
committed by GitHub
parent 05b8fb69b5
commit 2f011fd2a3
4 changed files with 330 additions and 22 deletions
+18 -5
View File
@@ -80,11 +80,24 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) {
data, err = archive.CreateTarFromZip(zipReader, HTTPFileMaxBytes)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error processing .zip archive.",
Detail: err.Error(),
})
return
switch {
case errors.Is(err, archive.ErrArchiveTooLarge):
httpapi.Write(ctx, rw, http.StatusRequestEntityTooLarge, codersdk.Response{
Message: "Expanded .zip archive exceeds maximum size.",
})
return
case errors.Is(err, archive.ErrInvalidZipContent):
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid .zip archive contents.",
})
return
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error processing .zip archive.",
Detail: err.Error(),
})
return
}
}
contentType = tarMimeType
}
+87
View File
@@ -2,8 +2,11 @@ package coderd_test
import (
"archive/tar"
"archive/zip"
"bytes"
"context"
"encoding/binary"
"io"
"net/http"
"sync"
"testing"
@@ -14,6 +17,7 @@ import (
"github.com/coder/coder/v2/archive"
"github.com/coder/coder/v2/archive/archivetest"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
@@ -22,6 +26,19 @@ import (
func TestPostFiles(t *testing.T) {
t.Parallel()
buildZipWithFile := func(t *testing.T, name string, writeContents func(w io.Writer) error) []byte {
t.Helper()
var zipBytes bytes.Buffer
zw := zip.NewWriter(&zipBytes)
w, err := zw.Create(name)
require.NoError(t, err)
require.NoError(t, writeContents(w))
require.NoError(t, zw.Close())
return zipBytes.Bytes()
}
// Single instance shared across all sub-tests. Each sub-test
// creates independent resources with unique IDs so parallel
// execution is safe.
@@ -65,6 +82,39 @@ func TestPostFiles(t *testing.T) {
_, err = client.Upload(ctx, codersdk.ContentTypeTar, bytes.NewReader(data))
require.NoError(t, err)
})
t.Run("InvalidZipMetadata", func(t *testing.T) {
t.Parallel()
corruptZipUncompressedSize := func(t *testing.T, zipBytes []byte, size uint32) []byte {
t.Helper()
const (
directoryHeaderSignature = "PK\x01\x02"
uncompressedSizeOffset = 24
)
hdrOffset := bytes.Index(zipBytes, []byte(directoryHeaderSignature))
require.NotEqual(t, -1, hdrOffset, "missing ZIP central directory header")
corrupted := bytes.Clone(zipBytes)
sizeBytes := corrupted[hdrOffset+uncompressedSizeOffset : hdrOffset+uncompressedSizeOffset+4]
binary.LittleEndian.PutUint32(sizeBytes, size)
return corrupted
}
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
zipBytes := buildZipWithFile(t, "hello.txt", func(w io.Writer) error {
_, err := w.Write([]byte("hello"))
return err
})
zipBytes = corruptZipUncompressedSize(t, zipBytes, 6)
_, err := client.Upload(ctx, codersdk.ContentTypeZip, bytes.NewReader(zipBytes))
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
})
t.Run("InsertConcurrent", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
@@ -86,6 +136,43 @@ func TestPostFiles(t *testing.T) {
wg.Done()
end.Wait()
})
//nolint:paralleltest // This subtest is intentionally serial to
// avoid extra memory pressure.
t.Run("OversizedZipExpansion", func(t *testing.T) {
buildZipWithSizedFile := func(t *testing.T, name string, size int64) []byte {
return buildZipWithFile(t, name, func(w io.Writer) error {
chunk := bytes.Repeat([]byte("a"), 32*1024)
for written := int64(0); written < size; {
n := len(chunk)
if remaining := size - written; int64(n) > remaining {
n = int(remaining)
}
_, err := w.Write(chunk[:n])
if err != nil {
return err
}
written += int64(n)
}
return nil
})
}
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Leave only enough room for the tar trailer. The single
// entry header then pushes the converted tar output over the
// file size limit.
size := int64(coderd.HTTPFileMaxBytes - 1024)
zipBytes := buildZipWithSizedFile(t, "oversized.txt", size)
_, err := client.Upload(ctx, codersdk.ContentTypeZip, bytes.NewReader(zipBytes))
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusRequestEntityTooLarge, apiErr.StatusCode())
})
}
func TestDownload(t *testing.T) {