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.
159188func 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