Skip to content

Commit 8c3240e

Browse files
authored
fix(security): harden package-fetch archive extraction (#676)
Apply four hardenings to the npx/uvx package-source fetch extractor (internal/security/scanner/package_fetch.go), for both the tar and zip paths, while keeping behavior identical for well-formed small archives: 1. Decompression bomb across skipped members: read the gzip tar stream through a cappedReader so EVERY decompressed byte is bounded — including the bodies of members the extractor skips (oversized, symlink, traversal), which tar.Reader still decompresses while advancing to the next header. 2. safeJoin REJECTS path-traversal and absolute entries with an error instead of cleaning '../' into an in-dest path (the rewrite bug). 3. Oversized and partially-written files are charged to the file-count and byte caps (a truncated-but-large file still counts). 4. Directory creation is capped (charged to the same combined entry limit) before the file-count limit can be bypassed by an all-directories archive — for both tar and zip. extractTarballGz/extractZip take an explicit maxFileBytes so the per-file cap is testable. Adds TDD fixtures: gzip bomb of skipped members, traversal reject (not rewrite), oversized-charged, dir cap. Related #670
1 parent 9f3e1ab commit 8c3240e

2 files changed

Lines changed: 274 additions & 42 deletions

File tree

internal/security/scanner/package_fetch.go

Lines changed: 107 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"archive/zip"
66
"compress/gzip"
77
"context"
8+
"errors"
89
"fmt"
910
"io"
1011
"os"
@@ -52,6 +53,30 @@ const (
5253
archiveZip
5354
)
5455

56+
// errArchiveTooLarge is returned by cappedReader once the total number of bytes
57+
// read past it exceeds the cap. It is the decompression-bomb backstop.
58+
var errArchiveTooLarge = errors.New("decompressed archive exceeded size cap")
59+
60+
// cappedReader counts every byte read through it and fails the read once the
61+
// running total exceeds limit. Wrapping the gzip stream with it bounds the TOTAL
62+
// decompressed output — including the bodies of tar members we skip (oversized,
63+
// symlink, traversal), which the tar reader still decompresses while advancing
64+
// to the next header. This closes the gzip-bomb-via-skipped-members hole.
65+
type cappedReader struct {
66+
r io.Reader
67+
n int64
68+
limit int64
69+
}
70+
71+
func (c *cappedReader) Read(p []byte) (int, error) {
72+
n, err := c.r.Read(p)
73+
c.n += int64(n)
74+
if c.n > c.limit {
75+
return n, errArchiveTooLarge
76+
}
77+
return n, err
78+
}
79+
5580
// parsePackageSpec splits a package spec into its name and exact version.
5681
// Only exact pins are honored ("pkg@1.2.3", "pkg==1.2.3"); range/min specifiers
5782
// (">=", "~", "^") yield an empty version so the package manager resolves the
@@ -154,11 +179,26 @@ func findDownloadedArchive(dir string) (path string, kind archiveKind, err error
154179
return "", 0, fmt.Errorf("no package archive found in %s", dir)
155180
}
156181

157-
// safeJoin joins destDir with a (possibly hostile) archive entry name and
158-
// returns an error if the result would escape destDir (path traversal).
182+
// safeJoin joins destDir with a (possibly hostile) archive entry name. It
183+
// REJECTS — never sanitizes/rewrites — absolute paths and any path-traversal
184+
// entry: an entry like "../../etc/passwd" must error out, not be silently
185+
// cleaned into an in-dest path (the MCP-2444 bug). Archive entry names use '/'
186+
// separators by convention; we treat both '/' and '\' as separators so a
187+
// Windows-style traversal can't slip through.
159188
func safeJoin(destDir, name string) (string, error) {
160-
cleaned := filepath.Clean("/" + name) // make absolute then strip leading /
161-
target := filepath.Join(destDir, cleaned)
189+
if name == "" {
190+
return "", fmt.Errorf("empty archive entry name")
191+
}
192+
if filepath.IsAbs(name) || strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {
193+
return "", fmt.Errorf("entry %q is an absolute path", name)
194+
}
195+
for _, part := range strings.FieldsFunc(name, func(r rune) bool { return r == '/' || r == '\\' }) {
196+
if part == ".." {
197+
return "", fmt.Errorf("entry %q contains a path-traversal component", name)
198+
}
199+
}
200+
target := filepath.Join(destDir, filepath.Clean(name))
201+
// Defense in depth: confirm the result stays within destDir.
162202
rel, err := filepath.Rel(destDir, target)
163203
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
164204
return "", fmt.Errorf("entry %q escapes destination", name)
@@ -168,51 +208,73 @@ func safeJoin(destDir, name string) (string, error) {
168208

169209
// extractTarballGz extracts a gzip-compressed tar stream into destDir. It is
170210
// hardened for UNTRUSTED input: regular files and directories only (symlinks,
171-
// hardlinks, devices skipped), path-traversal entries skipped, and bounded by
172-
// maxFiles and maxTotalBytes.
173-
func extractTarballGz(r io.Reader, destDir string, maxFiles int, maxTotalBytes int64) error {
211+
// hardlinks, devices skipped), path-traversal entries rejected (not rewritten),
212+
// and bounded by maxFiles (files + directories combined), maxTotalBytes (TOTAL
213+
// decompressed bytes, including the bodies of skipped members — gzip-bomb guard)
214+
// and maxFileBytes (any single file).
215+
func extractTarballGz(r io.Reader, destDir string, maxFiles int, maxTotalBytes, maxFileBytes int64) error {
174216
gz, err := gzip.NewReader(r)
175217
if err != nil {
176218
return fmt.Errorf("gzip: %w", err)
177219
}
178220
defer gz.Close()
179221

180-
tr := tar.NewReader(gz)
181-
var fileCount int
222+
// Read the tar through a cappedReader so EVERY decompressed byte counts —
223+
// including bodies of entries we skip, which tar.Reader decompresses while
224+
// seeking the next header. This bounds a bomb even when all members are
225+
// skipped (oversized/symlink/traversal).
226+
capped := &cappedReader{r: gz, limit: maxTotalBytes}
227+
tr := tar.NewReader(capped)
228+
229+
var entryCount int // files + directories, both charged to maxFiles
182230
var totalBytes int64
183231
for {
184232
hdr, err := tr.Next()
185233
if err == io.EOF {
186234
break
187235
}
236+
if errors.Is(err, errArchiveTooLarge) {
237+
return fmt.Errorf("package exceeds max total size (%d bytes)", maxTotalBytes)
238+
}
188239
if err != nil {
189240
return fmt.Errorf("tar: %w", err)
190241
}
242+
191243
// Only regular files and directories. Symlinks/hardlinks are escape
192244
// vectors and are never needed for source scanning.
193245
switch hdr.Typeflag {
194246
case tar.TypeDir:
247+
// Cap directory creation alongside files: charge it to the same
248+
// entry limit BEFORE creating, so an all-directories archive can't
249+
// bypass the file-count cap.
250+
if entryCount >= maxFiles {
251+
return fmt.Errorf("package exceeds max entry count (%d)", maxFiles)
252+
}
195253
target, jerr := safeJoin(destDir, hdr.Name)
196254
if jerr != nil {
197-
continue
255+
continue // reject traversal/absolute dir entry
198256
}
257+
entryCount++
199258
_ = os.MkdirAll(target, 0o755)
200259
continue
201260
case tar.TypeReg, tar.TypeRegA: //nolint:staticcheck // TypeRegA for legacy tars
202261
// handled below
203262
default:
204-
continue // symlink, hardlink, char/block device, fifo — skip
263+
continue // symlink, hardlink, char/block device, fifo — skip (body still counted by capped)
205264
}
206265

207266
target, jerr := safeJoin(destDir, hdr.Name)
208267
if jerr != nil {
209-
continue
268+
continue // reject traversal/absolute file entry
210269
}
211-
if fileCount >= maxFiles {
212-
return fmt.Errorf("package exceeds max file count (%d)", maxFiles)
270+
if entryCount >= maxFiles {
271+
return fmt.Errorf("package exceeds max entry count (%d)", maxFiles)
213272
}
214-
if hdr.Size > fetchMaxFileBytes {
215-
continue // skip oversized single file rather than abort
273+
// Charge the entry now so an oversized or partially-written file still
274+
// counts toward the caps (it consumed resources either way).
275+
entryCount++
276+
if hdr.Size > maxFileBytes {
277+
continue // skip oversized single file; its body is still counted by capped
216278
}
217279
if totalBytes+hdr.Size > maxTotalBytes {
218280
return fmt.Errorf("package exceeds max total size (%d bytes)", maxTotalBytes)
@@ -221,31 +283,40 @@ func extractTarballGz(r io.Reader, destDir string, maxFiles int, maxTotalBytes i
221283
continue
222284
}
223285
written, werr := writeArchiveFile(target, tr, hdr.Size)
224-
if werr != nil {
225-
continue
286+
totalBytes += written // charge bytes even on a partial/failed write
287+
if errors.Is(werr, errArchiveTooLarge) {
288+
return fmt.Errorf("package exceeds max total size (%d bytes)", maxTotalBytes)
226289
}
227-
totalBytes += written
228-
fileCount++
229290
}
230291
return nil
231292
}
232293

233294
// extractZip extracts a zip archive (e.g. a Python wheel) into destDir with the
234-
// same untrusted-input hardening as extractTarballGz.
235-
func extractZip(zipPath, destDir string, maxFiles int, maxTotalBytes int64) error {
295+
// same untrusted-input hardening as extractTarballGz: directories and files are
296+
// charged to a single entry cap (maxFiles), traversal/absolute entries are
297+
// rejected, and a single entry that lies about its decompressed size is bounded
298+
// by writeArchiveFile (deflate-bomb guard) with its written bytes charged.
299+
func extractZip(zipPath, destDir string, maxFiles int, maxTotalBytes, maxFileBytes int64) error {
236300
zr, err := zip.OpenReader(zipPath)
237301
if err != nil {
238302
return fmt.Errorf("zip: %w", err)
239303
}
240304
defer zr.Close()
241305

242-
var fileCount int
306+
var entryCount int // files + directories, both charged to maxFiles
243307
var totalBytes int64
244308
for _, f := range zr.File {
245309
if f.FileInfo().IsDir() {
246-
if target, jerr := safeJoin(destDir, f.Name); jerr == nil {
247-
_ = os.MkdirAll(target, 0o755)
310+
// Cap directory creation before the file-count limit can be bypassed.
311+
if entryCount >= maxFiles {
312+
return fmt.Errorf("package exceeds max entry count (%d)", maxFiles)
248313
}
314+
target, jerr := safeJoin(destDir, f.Name)
315+
if jerr != nil {
316+
continue // reject traversal/absolute dir entry
317+
}
318+
entryCount++
319+
_ = os.MkdirAll(target, 0o755)
249320
continue
250321
}
251322
// Skip symlinks (mode bit) — escape vector.
@@ -254,13 +325,16 @@ func extractZip(zipPath, destDir string, maxFiles int, maxTotalBytes int64) erro
254325
}
255326
target, jerr := safeJoin(destDir, f.Name)
256327
if jerr != nil {
257-
continue
328+
continue // reject traversal/absolute file entry
258329
}
259-
if fileCount >= maxFiles {
260-
return fmt.Errorf("package exceeds max file count (%d)", maxFiles)
330+
if entryCount >= maxFiles {
331+
return fmt.Errorf("package exceeds max entry count (%d)", maxFiles)
261332
}
333+
// Charge the entry now so an oversized or partially-written file still
334+
// counts toward the caps.
335+
entryCount++
262336
size := int64(f.UncompressedSize64)
263-
if size > fetchMaxFileBytes {
337+
if size > maxFileBytes {
264338
continue
265339
}
266340
if totalBytes+size > maxTotalBytes {
@@ -276,11 +350,8 @@ func extractZip(zipPath, destDir string, maxFiles int, maxTotalBytes int64) erro
276350
}
277351
written, werr := writeArchiveFile(target, rc, size)
278352
rc.Close()
279-
if werr != nil {
280-
continue
281-
}
282-
totalBytes += written
283-
fileCount++
353+
totalBytes += written // charge bytes even on a partial/failed write
354+
_ = werr
284355
}
285356
return nil
286357
}
@@ -365,7 +436,7 @@ func (r *SourceResolver) fetchNpmPackage(ctx context.Context, info ServerInfo, s
365436
return nil, err
366437
}
367438
defer f.Close()
368-
if err := extractTarballGz(f, srcDir, fetchMaxFiles, fetchMaxTotalBytes); err != nil {
439+
if err := extractTarballGz(f, srcDir, fetchMaxFiles, fetchMaxTotalBytes, fetchMaxFileBytes); err != nil {
369440
cleanup()
370441
return nil, fmt.Errorf("extract npm tarball: %w", err)
371442
}
@@ -412,7 +483,7 @@ func (r *SourceResolver) fetchPythonPackage(ctx context.Context, info ServerInfo
412483
cleanup()
413484
return nil, fmt.Errorf("python download produced a non-wheel archive; refusing to build/extract an sdist (would execute untrusted code)")
414485
}
415-
if err := extractZip(archive, srcDir, fetchMaxFiles, fetchMaxTotalBytes); err != nil {
486+
if err := extractZip(archive, srcDir, fetchMaxFiles, fetchMaxTotalBytes, fetchMaxFileBytes); err != nil {
416487
cleanup()
417488
return nil, fmt.Errorf("extract wheel: %w", err)
418489
}

0 commit comments

Comments
 (0)