Skip to content

Commit 90ab65a

Browse files
committed
Zip: Harden fs.Unzip() implementation in pkg/fs photoprism#5330
Signed-off-by: Michael Mayer <[email protected]>
1 parent 4d280e8 commit 90ab65a

File tree

2 files changed

+192
-8
lines changed

2 files changed

+192
-8
lines changed

pkg/fs/zip.go

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package fs
22

33
import (
44
"archive/zip"
5+
"errors"
56
"fmt"
67
"io"
8+
"math"
79
"os"
810
"path/filepath"
911
"strings"
@@ -109,15 +111,24 @@ func Unzip(zipName, dir string, fileSizeLimit, totalSizeLimit int64) (files []st
109111
continue
110112
}
111113

112-
if totalSizeLimit < 1 {
113-
// Do nothing;
114-
} else if totalSizeLimit = totalSizeLimit - int64(zipFile.UncompressedSize64); totalSizeLimit < 1 {
114+
if zipFile.UncompressedSize64 > uint64(math.MaxInt64) {
115115
skipped = append(skipped, zipFile.Name)
116-
totalSizeLimit = 0
117116
continue
118117
}
119118

120-
fileName, unzipErr := UnzipFile(zipFile, dir)
119+
if totalSizeLimit > 0 {
120+
entrySize := int64(zipFile.UncompressedSize64) //nolint:gosec // safe: capped by check above
121+
122+
totalSizeLimit -= entrySize
123+
124+
if totalSizeLimit < 1 {
125+
skipped = append(skipped, zipFile.Name)
126+
totalSizeLimit = 0
127+
continue
128+
}
129+
}
130+
131+
fileName, unzipErr := unzipFileWithLimit(zipFile, dir, fileSizeLimit)
121132
if unzipErr != nil {
122133
return files, skipped, unzipErr
123134
}
@@ -130,6 +141,11 @@ func Unzip(zipName, dir string, fileSizeLimit, totalSizeLimit int64) (files []st
130141

131142
// UnzipFile writes a file from a zip archive to the target destination.
132143
func UnzipFile(f *zip.File, dir string) (fileName string, err error) {
144+
return unzipFileWithLimit(f, dir, 0)
145+
}
146+
147+
// unzipFileWithLimit writes a file from a zip archive to the target destination while applying a size limit.
148+
func unzipFileWithLimit(f *zip.File, dir string, fileSizeLimit int64) (fileName string, err error) {
133149
rc, err := f.Open()
134150
if err != nil {
135151
return fileName, err
@@ -165,9 +181,31 @@ func UnzipFile(f *zip.File, dir string) (fileName string, err error) {
165181

166182
defer fd.Close()
167183

168-
_, err = io.Copy(fd, rc)
169-
if err != nil {
170-
return fileName, err
184+
limit := fileSizeLimit
185+
186+
if limit <= 0 {
187+
switch {
188+
case f.UncompressedSize64 == 0:
189+
limit = math.MaxInt64
190+
case f.UncompressedSize64 > uint64(math.MaxInt64):
191+
return fileName, fmt.Errorf("zip entry too large")
192+
default:
193+
limit = int64(f.UncompressedSize64) //nolint:gosec // safe: capped above
194+
}
195+
}
196+
197+
written, copyErr := io.CopyN(fd, rc, limit)
198+
if copyErr != nil && !errors.Is(copyErr, io.EOF) && !errors.Is(copyErr, io.ErrUnexpectedEOF) {
199+
return fileName, copyErr
200+
}
201+
202+
// Abort if the entry exceeded the configured limit.
203+
if written >= limit && (fileSizeLimit > 0 || f.UncompressedSize64 > 0) {
204+
// Drain a single byte to see if more data remains (indicating truncation).
205+
var b [1]byte
206+
if _, extraErr := rc.Read(b[:]); extraErr == nil {
207+
return fileName, fmt.Errorf("zip entry exceeds limit")
208+
}
171209
}
172210

173211
return fileName, nil

pkg/fs/zip_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package fs
22

33
import (
44
"archive/zip"
5+
"encoding/binary"
6+
"math"
57
"os"
68
"path/filepath"
79
"runtime"
@@ -148,6 +150,150 @@ func TestUnzip_CreatesDirectoriesAndNestedFiles(t *testing.T) {
148150
}
149151
}
150152

153+
func TestUnzip_SkipsVeryLargeEntry(t *testing.T) {
154+
dir := t.TempDir()
155+
zipPath := filepath.Join(dir, "huge.zip")
156+
157+
writeZip64Stub(t, zipPath, "huge.bin", math.MaxUint64)
158+
159+
files, skipped, err := Unzip(zipPath, filepath.Join(dir, "out"), 0, -1)
160+
assert.NoError(t, err)
161+
assert.Empty(t, files)
162+
assert.Contains(t, skipped, "huge.bin")
163+
}
164+
165+
// writeZip64Stub writes a minimal ZIP64 archive with one stored entry and custom size values.
166+
func writeZip64Stub(t *testing.T, path, name string, size uint64) {
167+
t.Helper()
168+
169+
var buf []byte
170+
171+
bw := func(data []byte) {
172+
buf = append(buf, data...)
173+
}
174+
175+
writeLE := func(v any) {
176+
var b [8]byte
177+
switch x := v.(type) {
178+
case uint16:
179+
binary.LittleEndian.PutUint16(b[:2], x)
180+
bw(b[:2])
181+
case uint32:
182+
binary.LittleEndian.PutUint32(b[:4], x)
183+
bw(b[:4])
184+
case uint64:
185+
binary.LittleEndian.PutUint64(b[:8], x)
186+
bw(b[:8])
187+
default:
188+
t.Fatalf("unsupported type %T", v)
189+
}
190+
}
191+
192+
filename := []byte(name)
193+
const (
194+
sigLocal = 0x04034b50
195+
sigCentral = 0x02014b50
196+
sigEnd = 0x06054b50
197+
)
198+
199+
zip64ExtraLen := uint16(4 + 16) // header id + size + two uint64 values
200+
localExtraLen := zip64ExtraLen
201+
centralExtraLen := zip64ExtraLen
202+
203+
// Local file header
204+
writeLE(uint32(sigLocal))
205+
writeLE(uint16(45)) // version needed (zip64)
206+
writeLE(uint16(0)) // flags
207+
writeLE(uint16(0)) // method store
208+
writeLE(uint16(0)) // mod time
209+
writeLE(uint16(0)) // mod date
210+
writeLE(uint32(0)) // crc
211+
writeLE(uint32(0xFFFFFFFF))
212+
writeLE(uint32(0xFFFFFFFF))
213+
if len(filename) > math.MaxUint16 {
214+
t.Fatalf("filename too long")
215+
}
216+
writeLE(uint16(len(filename)))
217+
writeLE(localExtraLen)
218+
bw(filename)
219+
// zip64 extra
220+
writeLE(uint16(0x0001)) // header id
221+
writeLE(uint16(16)) // data size
222+
writeLE(size) // uncompressed size
223+
writeLE(size) // compressed size
224+
// no file data (size 0) to keep archive tiny
225+
226+
localLen := len(buf)
227+
228+
// Central directory header
229+
writeLE(uint32(sigCentral))
230+
writeLE(uint16(45)) // version made by
231+
writeLE(uint16(45)) // version needed
232+
writeLE(uint16(0)) // flags
233+
writeLE(uint16(0)) // method
234+
writeLE(uint16(0)) // time
235+
writeLE(uint16(0)) // date
236+
writeLE(uint32(0)) // crc
237+
writeLE(uint32(0xFFFFFFFF))
238+
writeLE(uint32(0xFFFFFFFF))
239+
if len(filename) > math.MaxUint16 {
240+
t.Fatalf("filename too long")
241+
}
242+
writeLE(uint16(len(filename)))
243+
writeLE(centralExtraLen)
244+
writeLE(uint16(0)) // comment len
245+
writeLE(uint16(0)) // disk start
246+
writeLE(uint16(0)) // int attrs
247+
writeLE(uint32(0)) // ext attrs
248+
writeLE(uint32(0)) // rel offset (zip64 overrides)
249+
bw(filename)
250+
// zip64 extra
251+
writeLE(uint16(0x0001))
252+
writeLE(uint16(16))
253+
writeLE(size) // uncompressed
254+
writeLE(size) // compressed
255+
256+
centralLen := len(buf) - localLen
257+
258+
// End of central directory (not zip64 EOCD; minimal to satisfy reader)
259+
writeLE(uint32(sigEnd))
260+
writeLE(uint16(0)) // disk
261+
writeLE(uint16(0)) // start disk
262+
writeLE(uint16(1)) // entries this disk
263+
writeLE(uint16(1)) // total entries
264+
if centralLen > math.MaxUint32 || localLen > math.MaxUint32 {
265+
t.Fatalf("central or local length exceeds uint32")
266+
}
267+
writeLE(uint32(centralLen))
268+
writeLE(uint32(localLen))
269+
writeLE(uint16(0)) // comment length
270+
271+
if err := os.WriteFile(path, buf, 0o600); err != nil {
272+
t.Fatal(err)
273+
}
274+
}
275+
276+
func TestUnzipFileWithLimit_DetectsOverrun(t *testing.T) {
277+
dir := t.TempDir()
278+
zipPath := filepath.Join(dir, "small.zip")
279+
writeZip(t, zipPath, map[string][]byte{"a.txt": []byte("abc")}) // 3 bytes
280+
281+
r, err := zip.OpenReader(zipPath)
282+
if err != nil {
283+
t.Fatal(err)
284+
}
285+
defer r.Close()
286+
287+
if len(r.File) != 1 {
288+
t.Fatalf("expected one file, got %d", len(r.File))
289+
}
290+
291+
_, err = unzipFileWithLimit(r.File[0], dir, 1) // limit below actual size
292+
if err == nil {
293+
t.Fatalf("expected limit overrun error")
294+
}
295+
}
296+
151297
func TestZip(t *testing.T) {
152298
t.Run("Compressed", func(t *testing.T) {
153299
zipDir := filepath.Join(os.TempDir(), "pkg/fs")

0 commit comments

Comments
 (0)