package archive import ( "archive/tar" "fmt" "io" "path" "path/filepath" "strings" "github.com/carlosframework/platform/internal/hostfiles" "github.com/klauspost/compress/zstd" ) // Cap is the archive's content cap (M5): the sum of the regular files' // sizes, .env included. Tar headers and padding are not content and // do not count against it (F25); they are bounded by MaxEntries and // MaxPathLen instead. const Cap int64 = 256 << 20 // MaxEntries bounds the number of tar entries (files, directories and env) // in one archive, and MaxPathLen the length of a path inside the files // directory. With Cap they bound the decoded stream (MaxDecoded), so a zstd // bomb is bounded on the target even after it passed Verify. const ( MaxEntries = 1 << 17 MaxPathLen = 4096 ) // TreePathLen is the files tree's own cap, hostfiles' (MaxPathBytes, 4095 // bytes): the most a path inside the files directory may hold and still be // staged into the prepared files manifest the target builds after the cut // (hostfiles.Prepared.Stage refuses anything longer). It is one byte under // MaxPathLen, the archive format's bound that MV-N81 states, so Build // refuses a path of exactly MaxPathLen with ErrPastTreeCap (a refusal at // S1, before the cut), and every reader refuses one past TreePathLen // (checkName), so an archive an older source built with such a path never // passes the target's verification (#236 fix round 1). const TreePathLen = hostfiles.MaxPathBytes // entryOverhead is the most tar bytes one entry costs beyond its content: // a 512-byte header, a PAX header (512 bytes plus the path record, at most // len("files/")+MaxPathLen+1 bytes and a length prefix, rounded up to 4608) // when the path does not fit the USTAR fields, and under 512 bytes of // padding. That is at most 6143; 8 KiB leaves room. Owners, times and // sizes under Cap always fit USTAR, so no other PAX record is written. // TestTheLongestPathFitsTheEntryOverhead checks the arithmetic. const entryOverhead = 8 << 10 // MaxDecoded is the most bytes a decoded archive (the tar stream) may hold: // content, per-entry overhead and the two-block end marker. const MaxDecoded int64 = Cap + MaxEntries*entryOverhead + 2*512 // MarkerName is the protocol marker inside the files directory, never // archived or digested (§5.1, §6.1 F5), and refused if an archive carries // it or anything under it: an extracted marker would claim an installation // that never happened, and a directory of that name would block the // landing's own marker write. const MarkerName = ".files-gen" // EnvEntry is .env's path inside the archive; FilesPrefix is the // files directory's namespace. The two never overlap. const ( EnvEntry = "env" FilesPrefix = "files/" ) // zstdWindow is the compression window, and the largest window a decoder // accepts: a hostile frame cannot make the target allocate more. const zstdWindow = 8 << 20 // checkName validates one archive path (a directory's without its // trailing slash) and says which namespace it is in. It is the one rule // Verify, Extract and the manifest check share. func checkName(name string, dir bool) (rel string, isEnv bool, err error) { bad := func(why string) error { return fmt.Errorf("%w: %q %s", ErrBadPath, name, why) } switch { case name == "": return "", false, bad("is empty") case len(name) > len(FilesPrefix)+MaxPathLen: return "", false, bad("is too long") case strings.ContainsRune(name, 0): return "", false, bad("holds a NUL") case path.IsAbs(name): return "", false, bad("is absolute") case path.Clean(name) != name: return "", false, bad("is not clean") } for _, c := range strings.Split(name, "/") { if c == ".." || c == "." || c == "" { return "", false, bad("leaves its directory") } } if name == EnvEntry { if dir { return "", false, bad("is the env file but not a regular file") } return "", true, nil } rel, ok := strings.CutPrefix(name, FilesPrefix) if !ok || rel == "" { return "", false, bad("is outside files/ and env") } if len(rel) > TreePathLen { return "", false, bad("is past the files tree's length cap") } if rel == MarkerName || strings.HasPrefix(rel, MarkerName+"/") { return "", false, bad("is the protocol marker or inside it") } // The platform's own rules on top (Windows reserved names and drive // letters; a backslash is a separator there and a file name here). if !filepath.IsLocal(filepath.FromSlash(rel)) || (filepath.Separator != '/' && strings.ContainsRune(rel, filepath.Separator)) { return "", false, bad("is not a local path on this platform") } return rel, false, nil } // headerName validates a tar header's name and type: regular files and // directories only. func headerName(hdr *tar.Header) (name, rel string, isEnv bool, err error) { switch hdr.Typeflag { case tar.TypeReg, tar.TypeDir: default: return "", "", false, fmt.Errorf("%w: %q", ErrSpecialFile, hdr.Name) } if hdr.Size < 0 || (hdr.Typeflag == tar.TypeDir && hdr.Size != 0) { return "", "", false, fmt.Errorf("%w: %q has size %d", ErrBadPath, hdr.Name, hdr.Size) } name = hdr.Name if hdr.Typeflag == tar.TypeDir { name = strings.TrimSuffix(name, "/") } rel, isEnv, err = checkName(name, hdr.Typeflag == tar.TypeDir) return name, rel, isEnv, err } // streamRules are the rules every reader of an archive stream enforces, // in one place so Verify and Extract cannot drift apart (fix round 2): a // stream Extract would refuse must not pass Verify and fail at landing. // Per entry, in order: the entry limit; a valid name and type // (headerName); no destination named twice; every entry's directory // listed before it (Build's sorted order always does, and Extract needs // it so each directory carries its archived mode); and the content cap, // checked on the header before any content is read. The manifest lists // regular files only, so directory order is a property of the stream // alone; Manifest.check applies the same name rule (checkName). type streamRules struct { seen map[string]bool dirs map[string]bool count int content int64 } func newStreamRules() *streamRules { return &streamRules{seen: map[string]bool{}, dirs: map[string]bool{}} } // admit checks one header against the rules and records it. func (s *streamRules) admit(hdr *tar.Header) (name, rel string, isEnv bool, err error) { if s.count >= entryLimit { return "", "", false, ErrTooManyEntries } s.count++ name, rel, isEnv, err = headerName(hdr) if err != nil { return "", "", false, err } if s.seen[name] { return "", "", false, fmt.Errorf("%w: %q named twice", ErrBadPath, hdr.Name) } s.seen[name] = true if !isEnv { if dir := path.Dir(rel); dir != "." && !s.dirs[dir] { return "", "", false, fmt.Errorf("%w: %q comes before its directory", ErrBadPath, hdr.Name) } } if hdr.Typeflag == tar.TypeDir { s.dirs[rel] = true return name, rel, isEnv, nil } if hdr.Size > Cap-s.content { return "", "", false, ErrOverCap } s.content += hdr.Size return name, rel, isEnv, nil } // boundedReader stops a decoded stream at its limit: past it, one more // byte is ErrTooLarge, so a stream exactly at the limit still ends cleanly. type boundedReader struct { r io.Reader left int64 } func (b *boundedReader) Read(p []byte) (int, error) { if b.left <= 0 { var one [1]byte n, err := b.r.Read(one[:]) if n > 0 { return 0, ErrTooLarge } return 0, err } if int64(len(p)) > b.left { p = p[:b.left] } n, err := b.r.Read(p) b.left -= int64(n) return n, err } // newDecoder always streams from r. WithDecodeBuffersBelow(0) turns off // the decoder's shortcut for a reader with Bytes() and Len() (a // *bytes.Buffer) under 128 KiB, which decodes the whole buffer at once and // never drains it: requireEOF would then read the archive again and refuse // it as data after the archive. func newDecoder(r io.Reader) (*zstd.Decoder, error) { return zstd.NewReader(r, zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxWindow(zstdWindow), zstd.WithDecodeBuffersBelow(0)) } // requireEOF reads the raw stream once more after the decoder finished. A // stream fed by Open through a pipe reports Open's terminal verdict here // (the final chunk's authentication); trailing bytes are refused. func requireEOF(r io.Reader) error { var one [1]byte n, err := io.ReadFull(r, one[:]) if n > 0 { return fmt.Errorf("%w: data after the archive", ErrBadPath) } if err == io.EOF { return nil } return err }