diff --git a/core/services/addons/packages/file.go b/core/services/addons/packages/file.go new file mode 100644 index 0000000000..2288051871 --- /dev/null +++ b/core/services/addons/packages/file.go @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: MIT +package packages + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "github.com/mudler/LocalAI/pkg/addons/zipformat" +) + +// Codes of a FileError. They tell the administrator which rule a file broke. +const ( + FileBadZip = "bad_zip" + FileUnsafePath = "unsafe_path" + FileUnsupportedType = "unsupported_entry" + FileTooLarge = "too_large" + FileTooManyFiles = "too_many_files" + FileRatio = "compression_ratio" + FileBadManifest = "bad_manifest" + FileDigestMismatch = "digest_mismatch" + FileWrongPlatform = "wrong_platform" + FileBadMode = "bad_mode" +) + +// FileError is a refusal of a local file. Its text is fixed and safe to show. +// It matches ErrPackage, so every caller that treats an unverified package as +// a refusal treats this one the same way. +type FileError struct{ Code, Msg string } + +func (e *FileError) Error() string { return e.Msg } +func (e *FileError) Unwrap() error { return ErrPackage } + +func fileErr(code, format string, a ...any) *FileError { + return &FileError{Code: code, Msg: fmt.Sprintf(format, a...)} +} + +// maxRatio is the largest expanded to compressed ratio of a file above +// ratioFloor bytes. A real executable compresses far less than this. +const ( + maxRatio = 200 + ratioFloor = 1 << 20 + // maxZipManifestBytes bounds the digest manifest itself. + maxZipManifestBytes = 4 << 20 +) + +var fileNameRE = regexp.MustCompile(`[^A-Za-z0-9._-]`) + +// FileSummary is what a verified zip says about itself. It is computed without +// writing the package to disk. +type FileSummary struct { + // SHA256 is the hex digest of the whole zip file. + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Files int `json:"files"` + zipformat.Manifest +} + +// zipSink receives each file of the package after the path and type checks. +// It must read r to the end; readZip then compares the digest and the size. +type zipSink func(name string, exec bool, r io.Reader) error + +// readZip applies every check that does not need the host: the shape of the +// zip, the paths, the entry types, the size and file caps, the digest list. +// maxBytes caps both the zip and the sum of the expanded files. +func readZip(ctx context.Context, p string, maxBytes int64, maxFiles int, accept func(zipformat.Manifest) error, sink zipSink) (FileSummary, error) { + var out FileSummary + info, e := os.Lstat(p) + if e != nil || !info.Mode().IsRegular() { + return out, fileErr(FileBadZip, "The file is not a regular file") + } + if info.Size() > maxBytes { + return out, fileErr(FileTooLarge, "The file is larger than the %d MiB limit", maxBytes>>20) + } + f, e := os.Open(p) // #nosec G304 -- the path is in the private staging area or named by the local administrator + if e != nil { + return out, fileErr(FileBadZip, "The file cannot be read") + } + defer func() { _ = f.Close() }() + h := sha256.New() + if _, e = io.Copy(h, io.LimitReader(contextReader{ctx, f}, maxBytes+1)); e != nil { + return out, e + } + out.SHA256, out.Size = hex.EncodeToString(h.Sum(nil)), info.Size() + zr, e := zip.NewReader(f, info.Size()) + if e != nil { + return out, fileErr(FileBadZip, "The file is not a zip archive") + } + if len(zr.File) > maxFiles+1 { + return out, fileErr(FileTooManyFiles, "The zip holds too many files") + } + var manifestEntry *zip.File + byName := map[string]*zip.File{} + folded := map[string]bool{} + for _, zf := range zr.File { + n := strings.TrimSuffix(zf.Name, "/") + if n == "" || n == "." || path.Clean(n) != n || strings.HasPrefix(n, "/") || n == ".." || strings.HasPrefix(n, "../") || strings.ContainsAny(n, "\\:\x00") || zf.NonUTF8 && !utf8Valid(zf.Name) { + return out, fileErr(FileUnsafePath, "The zip holds an unsafe file path") + } + key := strings.ToLower(n) + if folded[key] { + return out, fileErr(FileUnsafePath, "The zip holds a path twice") + } + folded[key] = true + mode := zf.Mode() + switch { + case mode.IsDir(): + continue + case !mode.IsRegular() || strings.HasSuffix(zf.Name, "/"): + return out, fileErr(FileUnsupportedType, "The zip holds a link or a special file") + } + if zf.Method != zip.Store && zf.Method != zip.Deflate { + return out, fileErr(FileBadZip, "The zip uses an unsupported compression method") + } + if n == zipformat.ManifestName { + manifestEntry = zf + continue + } + byName[n] = zf + } + if manifestEntry == nil || manifestEntry.UncompressedSize64 > maxZipManifestBytes { + return out, fileErr(FileBadManifest, "The zip has no usable %s", zipformat.ManifestName) + } + rc, e := manifestEntry.Open() + if e != nil { + return out, fileErr(FileBadZip, "The zip cannot be read") + } + raw, e := io.ReadAll(io.LimitReader(rc, maxZipManifestBytes+1)) + _ = rc.Close() + if e != nil || len(raw) > maxZipManifestBytes { + return out, fileErr(FileBadManifest, "The zip has no usable %s", zipformat.ManifestName) + } + zm, e := zipformat.ParseManifest(raw) + if e != nil { + return out, fileErr(FileBadManifest, "The digest manifest of the zip is not valid") + } + out.Manifest = zm + if accept != nil { + if e = accept(zm); e != nil { + return out, e + } + } + if len(zm.Files) != len(byName) || len(zm.Files) > maxFiles { + return out, fileErr(FileDigestMismatch, "The files of the zip are not the files its digest manifest lists") + } + var total int64 + for _, want := range zm.Files { + if e = ctx.Err(); e != nil { + return out, e + } + zf, ok := byName[want.Path] + if !ok || int64(zf.UncompressedSize64) != want.Size || want.Size < 0 { + return out, fileErr(FileDigestMismatch, "The files of the zip are not the files its digest manifest lists") + } + if zf.Mode().Perm()&0o111 != 0 && want.Path != zm.Executable { + return out, fileErr(FileBadMode, "Only the executable of the add-on may be executable") + } + if zf.UncompressedSize64 > uint64(maxBytes-total) { + return out, fileErr(FileTooLarge, "The files of the zip are larger than the %d MiB limit", maxBytes>>20) + } + if zf.UncompressedSize64 > ratioFloor && zf.UncompressedSize64 > maxRatio*zf.CompressedSize64 { + return out, fileErr(FileRatio, "A file of the zip compresses far more than a real add-on file does") + } + rc, e := zf.Open() + if e != nil { + return out, fileErr(FileBadZip, "The zip cannot be read") + } + fh := sha256.New() + counted := &countingReader{r: io.LimitReader(contextReader{ctx, rc}, want.Size+1)} + e = sink(want.Path, want.Path == zm.Executable, io.TeeReader(counted, fh)) + if e == nil { + // A sink that stopped early leaves bytes behind; the digest and + // the count must cover the whole file. + _, e = io.Copy(io.Discard, io.TeeReader(counted, fh)) + } + _ = rc.Close() + if e != nil { + var fe *FileError + if errors.As(e, &fe) || errors.Is(e, context.Canceled) || errors.Is(e, context.DeadlineExceeded) { + return out, e + } + // The reader of archive/zip reports a size or CRC that does not + // match the header as an error. + if errors.Is(e, zip.ErrChecksum) || errors.Is(e, zip.ErrFormat) || errors.Is(e, io.ErrUnexpectedEOF) { + return out, fileErr(FileBadZip, "The zip is damaged") + } + return out, e + } + if counted.n != want.Size || hex.EncodeToString(fh.Sum(nil)) != want.SHA256 { + return out, fileErr(FileDigestMismatch, "A file of the zip does not match its digest") + } + total += counted.n + out.Files++ + } + return out, nil +} + +func utf8Valid(s string) bool { return strings.ToValidUTF8(s, "") == s } + +type countingReader struct { + r io.Reader + n int64 +} + +func (c *countingReader) Read(b []byte) (int, error) { + n, e := c.r.Read(b) + c.n += int64(n) + return n, e +} + +// InspectFile verifies a zip without writing it anywhere and says what it +// holds. It does not know the host, so it cannot say that the platform fits; +// the owner's StageFile does. maxBytes and maxFiles are the caps of the +// installer; zero takes the defaults. +func InspectFile(ctx context.Context, p string, maxBytes int64, maxFiles int) (FileSummary, error) { + i := &Installer{MaxBytes: maxBytes, MaxFiles: maxFiles} + return readZip(ctx, p, i.byteLimit(), i.fileLimit(), nil, func(_ string, _ bool, r io.Reader) error { + _, e := io.Copy(io.Discard, r) + return e + }) +} + +// SafeFileName reduces the name a client gave a file to characters that are +// safe in a receipt. It is a label only; nothing opens a path by it. +func SafeFileName(name string) string { + name = filepath.Base(strings.ReplaceAll(name, "\\", "/")) + name = fileNameRE.ReplaceAllString(name, "_") + if len(name) > 100 { + name = name[len(name)-100:] + } + if name == "" || name == "." || name == ".." { + return "package.zip" + } + return name +} + +// StageFile verifies the local zip at p and stages its package exactly as +// Stage stages a library package: same stage directory, same checks of the +// manifest, executable, UI, icon and skills, same receipt and same Commit. No +// signature exists for a file, so the receipt says so. name is the file name +// the administrator chose, kept in the receipt as a label. +func (i *Installer) StageFile(ctx context.Context, p, name string, progress func(Progress)) (out StagedPackage, err error) { + if e := ctx.Err(); e != nil { + return out, e + } + root, e := filepath.Abs(i.Root) + if e != nil || i.Root == "" { + return out, ErrPackage + } + if e = os.MkdirAll(root, 0700); e != nil { + return out, e + } + info, e := os.Lstat(root) + if e != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0077 != 0 { + return out, ErrPackage + } + dir, e := os.MkdirTemp(root, "stage-") + if e != nil { + return out, e + } + defer func() { + if err != nil { + _ = os.RemoveAll(dir) + } + }() + if progress != nil { + progress(Progress{Phase: "download"}) + } + var written int64 + accept := func(zm zipformat.Manifest) error { + // Refuse another platform before the first byte is written. + if zm.OS != i.Host.OS || zm.Arch != i.Host.Arch { + return fileErr(FileWrongPlatform, "This file is for %s/%s; this host is %s/%s", zm.OS, zm.Arch, i.Host.OS, i.Host.Arch) + } + return nil + } + sum, e := readZip(ctx, p, i.byteLimit(), i.fileLimit(), accept, func(n string, exec bool, r io.Reader) error { + target := filepath.Join(dir, filepath.FromSlash(n)) + if e := os.MkdirAll(filepath.Dir(target), 0700); e != nil { + return e + } + mode := os.FileMode(0600) + if exec { + mode = 0700 + } + f, e := i.create(target, mode) + if e != nil { + return e + } + w, copyErr := io.Copy(f, r) + written += w + if copyErr == nil { + copyErr = f.Sync() + } + ce := f.Close() + if copyErr != nil { + return copyErr + } + return ce + }) + if e != nil { + return out, e + } + m, icon, skillFiles, e := i.inspectStage(dir) + if e != nil { + return out, e + } + if m.ID != sum.ID || m.Version != sum.Version || m.OS != sum.OS || m.Arch != sum.Arch || m.Executable != sum.Executable { + return out, fileErr(FileBadManifest, "The manifest of the add-on does not match the digest manifest of the zip") + } + if _, e = os.Lstat(filepath.Join(dir, "receipt.json")); !os.IsNotExist(e) { + return out, ErrPackage + } + if e = ctx.Err(); e != nil { + return out, e + } + if progress != nil { + progress(Progress{Phase: "verified", Bytes: written}) + } + return StagedPackage{&stagedState{owner: i, dir: dir, receipt: PackageReceipt{ + Digest: "sha256:" + sum.SHA256, SourceID: zipformat.SourceID, Selection: m.Version, Reference: "file:" + SafeFileName(name), + Verification: zipformat.Verification, Origin: zipformat.OriginLabel, Manifest: m, Icon: icon, Skills: skillFiles}}}, nil +} diff --git a/core/services/addons/packages/file_test.go b/core/services/addons/packages/file_test.go new file mode 100644 index 0000000000..ee76c940e3 --- /dev/null +++ b/core/services/addons/packages/file_test.go @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: MIT +package packages + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/mudler/LocalAI/pkg/addons/contract" + "github.com/mudler/LocalAI/pkg/addons/zipformat" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// rawEntry is one entry of a hand-built zip. +type rawEntry struct { + name string + data []byte + mode fs.FileMode +} + +// rawZip writes a zip whose digest manifest lists the files as given, with no +// check, so a test can make every kind of hostile or damaged file. +func rawZip(zm zipformat.Manifest, entries ...rawEntry) []byte { + var b bytes.Buffer + zw := zip.NewWriter(&b) + raw, err := json.Marshal(zm) + Expect(err).NotTo(HaveOccurred()) + all := append([]rawEntry{{name: zipformat.ManifestName, data: raw, mode: 0o644}}, entries...) + for _, e := range all { + h := &zip.FileHeader{Name: e.name, Method: zip.Deflate} + h.SetMode(e.mode) + w, err := zw.CreateHeader(h) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(e.data) + Expect(err).NotTo(HaveOccurred()) + } + Expect(zw.Close()).To(Succeed()) + return b.Bytes() +} + +func listed(entries ...rawEntry) []zipformat.File { + var out []zipformat.File + for _, e := range entries { + if e.mode.IsRegular() { + s := sha256.Sum256(e.data) + out = append(out, zipformat.File{Path: e.name, SHA256: hex.EncodeToString(s[:]), Size: int64(len(e.data))}) + } + } + return out +} + +func writeZip(b []byte) string { + p := filepath.Join(GinkgoT().TempDir(), "x.zip") + Expect(os.WriteFile(p, b, 0o600)).To(Succeed()) + return p +} + +var _ = Describe("local file packages", func() { + var manifest []byte + var good []rawEntry + var inst *Installer + meta := func(files ...zipformat.File) zipformat.Manifest { + return zipformat.Manifest{Schema: zipformat.Schema, ID: "sample", Version: "1.2.3", OS: "linux", Arch: "amd64", Executable: "bin/sample", Files: files} + } + // goodZip is a zip of the sample fixture, extra entries added to both the + // zip and the digest manifest. + goodZip := func(extra ...rawEntry) []byte { + entries := append(append([]rawEntry{}, good...), extra...) + return rawZip(meta(listed(entries...)...), entries...) + } + BeforeEach(func() { + var e error + manifest, e = os.ReadFile("../../../../tests/addons/fixtures/packages/manifest.json") + Expect(e).NotTo(HaveOccurred()) + var m contract.Manifest + Expect(json.Unmarshal(manifest, &m)).To(Succeed()) + good = []rawEntry{{"manifest.json", manifest, 0o644}, {"bin/sample", []byte("executable"), 0o755}} + root := GinkgoT().TempDir() + DeferCleanup(func() { + _ = filepath.Walk(root, func(p string, info os.FileInfo, e error) error { + if e == nil && info.IsDir() { + _ = os.Chmod(p, 0o700) + } + return nil + }) + }) + inst = &Installer{Root: root, Host: contract.HostCompatibility{Host: 1, API: contract.HostAPIVersion, Protocol: 1, SDK: 1, DataFormat: 1, OS: "linux", Arch: "amd64"}} + }) + refused := func(b []byte, code string) { + GinkgoHelper() + _, e := inst.StageFile(context.Background(), writeZip(b), "x.zip", nil) + Expect(e).To(HaveOccurred()) + Expect(errors.Is(e, ErrPackage)).To(BeTrue()) + var fe *FileError + Expect(errors.As(e, &fe)).To(BeTrue(), "error %v", e) + Expect(fe.Code).To(Equal(code)) + // A refused file leaves no stage behind. + ents, _ := os.ReadDir(inst.Root) + Expect(ents).To(BeEmpty()) + } + + It("stages a zip like a library package and records that it is unsigned", func() { + p := writeZip(goodZip()) + s, e := inst.StageFile(context.Background(), p, "../my pkg.zip", nil) + Expect(e).NotTo(HaveOccurred()) + rc := s.Receipt() + raw, _ := os.ReadFile(p) + sum := sha256.Sum256(raw) + Expect(rc.Digest).To(Equal("sha256:" + hex.EncodeToString(sum[:]))) + Expect(rc.SourceID).To(Equal("local-file")) + Expect(rc.Verification).To(Equal("sha256")) + Expect(rc.Origin).To(Equal("local file, unsigned")) + Expect(rc.Reference).To(Equal("file:my_pkg.zip")) + Expect(rc.Manifest.ID).To(Equal("sample")) + out, e := inst.Commit(context.Background(), s) + Expect(e).NotTo(HaveOccurred()) + info, e := os.Stat(filepath.Join(out.Directory, "bin/sample")) + Expect(e).NotTo(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o500))) + Expect(filepath.Join(out.Directory, zipformat.ManifestName)).NotTo(BeAnExistingFile()) + Expect(filepath.Join(out.Directory, "receipt.json")).To(BeAnExistingFile()) + }) + + It("describes a zip without writing it", func() { + p := writeZip(goodZip()) + sum, e := InspectFile(context.Background(), p, 0, 0) + Expect(e).NotTo(HaveOccurred()) + Expect(sum.ID).To(Equal("sample")) + Expect(sum.Version).To(Equal("1.2.3")) + Expect(sum.Files).To(Equal(2)) + Expect(sum.SHA256).To(HaveLen(64)) + }) + + DescribeTable("refuses an unsafe path", + func(name string) { + refused(goodZip(rawEntry{name, []byte("x"), 0o644}), FileUnsafePath) + }, + Entry("parent directory", "../evil"), + Entry("nested parent directory", "a/../../evil"), + Entry("absolute path", "/etc/evil"), + Entry("backslash", "a\\evil"), + Entry("drive letter", "C:evil"), + Entry("current directory prefix", "./evil"), + Entry("double slash", "a//evil"), + Entry("NUL byte", "evil\x00.txt"), + ) + + It("refuses a path that repeats, also when the case differs", func() { + refused(goodZip(rawEntry{"ui/A.js", []byte("a"), 0o644}, rawEntry{"ui/a.js", []byte("b"), 0o644}), FileUnsafePath) + }) + + It("refuses a symbolic link", func() { + refused(goodZip(rawEntry{"ui/link", []byte("/etc/passwd"), fs.ModeSymlink | 0o777}), FileUnsupportedType) + }) + + It("refuses an executable bit on a file that is not the executable", func() { + refused(goodZip(rawEntry{"tool", []byte("x"), 0o755}), FileBadMode) + }) + + It("refuses a zip whose digest list does not match", func() { + entries := append([]rawEntry{}, good...) + z := meta(listed(entries...)...) + entries[1].data = []byte("tampered!!") + refused(rawZip(z, entries...), FileDigestMismatch) + }) + + It("refuses a missing and an extra file", func() { + listedAll := listed(append(append([]rawEntry{}, good...), rawEntry{"ui/x.js", []byte("x"), 0o644})...) + refused(rawZip(meta(listedAll...), good...), FileDigestMismatch) + refused(rawZip(meta(listed(good...)...), append(append([]rawEntry{}, good...), rawEntry{"ui/x.js", []byte("x"), 0o644})...), FileDigestMismatch) + }) + + It("refuses a size in the list that the file does not have", func() { + files := listed(good...) + files[0].Size++ + refused(rawZip(meta(files...), good...), FileDigestMismatch) + }) + + It("refuses a zip for another platform before it writes anything", func() { + z := meta(listed(good...)...) + z.Arch = "arm64" + _, e := inst.StageFile(context.Background(), writeZip(rawZip(z, good...)), "x.zip", nil) + var fe *FileError + Expect(errors.As(e, &fe)).To(BeTrue()) + Expect(fe.Code).To(Equal(FileWrongPlatform)) + Expect(fe.Msg).To(ContainSubstring("linux/arm64")) + }) + + It("refuses a manifest.json that disagrees with the digest manifest", func() { + z := meta(listed(good...)...) + z.Version = "9.9.9" + refused(rawZip(z, good...), FileBadManifest) + }) + + It("refuses a file that is not a zip, and a zip without a digest manifest", func() { + refused([]byte("not a zip"), FileBadZip) + var b bytes.Buffer + zw := zip.NewWriter(&b) + w, _ := zw.Create("manifest.json") + _, _ = w.Write(manifest) + Expect(zw.Close()).To(Succeed()) + refused(b.Bytes(), FileBadManifest) + }) + + It("refuses a zip above the size limit", func() { + inst.MaxBytes = 600 + raw := goodZip(rawEntry{"ui/noise.bin", randomBytes(2048), 0o644}) + refused(raw, FileTooLarge) + }) + + It("refuses files that together expand past the limit", func() { + inst.MaxBytes = 4096 + a := rawEntry{"ui/a.bin", bytes.Repeat([]byte("a"), 3000), 0o644} + b := rawEntry{"ui/b.bin", bytes.Repeat([]byte("b"), 3000), 0o644} + refused(goodZip(a, b), FileTooLarge) + }) + + It("refuses a file that compresses like a bomb", func() { + zeros := rawEntry{"ui/zeros.bin", make([]byte, 8<<20), 0o644} + refused(goodZip(zeros), FileRatio) + }) + + It("refuses more files than the limit", func() { + inst.MaxFiles = 3 + var extra []rawEntry + for _, n := range []string{"ui/a", "ui/b", "ui/c"} { + extra = append(extra, rawEntry{n, []byte(n), 0o644}) + } + refused(goodZip(extra...), FileTooManyFiles) + }) + + It("refuses a path that is a file in one entry and a directory in another", func() { + _, e := inst.StageFile(context.Background(), writeZip(goodZip(rawEntry{"ui", []byte("x"), 0o644}, rawEntry{"ui/a.js", []byte("x"), 0o644})), "x.zip", nil) + Expect(e).To(HaveOccurred()) + ents, _ := os.ReadDir(inst.Root) + Expect(ents).To(BeEmpty()) + }) + + It("does not read a path through a symbolic link planted in the staging area", func() { + p := filepath.Join(GinkgoT().TempDir(), "link.zip") + target := writeZip(goodZip()) + Expect(os.Symlink(target, p)).To(Succeed()) + _, e := inst.StageFile(context.Background(), p, "x.zip", nil) + Expect(e).To(HaveOccurred()) + Expect(strings.Contains(e.Error(), "regular")).To(BeTrue()) + }) +}) + +func randomBytes(n int) []byte { + b := make([]byte, n) + var x uint32 = 2463534242 + for i := range b { + x ^= x << 13 + x ^= x >> 17 + x ^= x << 5 + b[i] = byte(x) + } + return b +} diff --git a/core/services/addons/packages/install.go b/core/services/addons/packages/install.go index ca644c37e9..ad93e15313 100644 --- a/core/services/addons/packages/install.go +++ b/core/services/addons/packages/install.go @@ -221,48 +221,15 @@ func (i *Installer) Stage(ctx context.Context, r catalog.Release, progress func( return out, e } } - manifestPath := filepath.Join(dir, "manifest.json") - manifestInfo, e := os.Lstat(manifestPath) - if e != nil || !manifestInfo.Mode().IsRegular() || manifestInfo.Size() > contract.DefaultLimits().ManifestBytes { - return out, ErrPackage - } - b, e := os.ReadFile(manifestPath) // #nosec G304 -- path is under the add-on state root and is built from validated identifiers, never from a request + m, icon, skillFiles, e := i.inspectStage(dir) if e != nil { return out, e } - m, e := contract.ParseManifest(b) - if e != nil { - return out, e - } - if e = contract.ValidateManifest(m, i.Host); e != nil { - return out, e - } // A mutable entry names a moving tag, so the version the library shows is a // label; the package says which build it is, and the digest pins it. if m.ID != r.ID || (!r.Mutable && m.Version != r.Version) { return out, ErrPackage } - assets := []string{m.Executable} - if u := m.Capabilities.UI; u != nil { - assets = append(assets, u.Entry) - assets = append(assets, u.CSS...) - } - for _, asset := range assets { - info, e := os.Lstat(filepath.Join(dir, filepath.FromSlash(asset))) - if e != nil || !info.Mode().IsRegular() || (asset == m.Executable && info.Mode()&0100 == 0) { - return out, ErrPackage - } - } - var icon *PackageIcon - if u := m.Capabilities.UI; u != nil && u.Icon != "" { - if icon, e = verifyIcon(dir, u.Icon); e != nil { - return out, e - } - } - skillFiles, e := verifySkills(dir, m) - if e != nil { - return out, e - } if _, e = os.Lstat(filepath.Join(dir, "receipt.json")); !os.IsNotExist(e) { return out, ErrPackage } @@ -275,6 +242,50 @@ func (i *Installer) Stage(ctx context.Context, r catalog.Release, progress func( return StagedPackage{&stagedState{owner: i, dir: dir, receipt: PackageReceipt{Digest: r.Digest, SourceID: r.SourceID, SourceURL: r.SourceURL, Channel: r.Channel, Selection: selection, Reference: r.Artifact, Verification: verification, Manifest: m, Icon: icon, Skills: skillFiles}}}, nil } +// inspectStage reads and checks the staged tree: the manifest against the +// host, the declared executable, UI assets, icon and skills. Both the library +// path and the local file path end here, so one set of rules applies. +func (i *Installer) inspectStage(dir string) (contract.Manifest, *PackageIcon, []PackageSkill, error) { + manifestPath := filepath.Join(dir, "manifest.json") + manifestInfo, e := os.Lstat(manifestPath) + if e != nil || !manifestInfo.Mode().IsRegular() || manifestInfo.Size() > contract.DefaultLimits().ManifestBytes { + return contract.Manifest{}, nil, nil, ErrPackage + } + b, e := os.ReadFile(manifestPath) // #nosec G304 -- path is under the add-on state root and is built from validated identifiers, never from a request + if e != nil { + return contract.Manifest{}, nil, nil, e + } + m, e := contract.ParseManifest(b) + if e != nil { + return contract.Manifest{}, nil, nil, e + } + if e = contract.ValidateManifest(m, i.Host); e != nil { + return contract.Manifest{}, nil, nil, e + } + assets := []string{m.Executable} + if u := m.Capabilities.UI; u != nil { + assets = append(assets, u.Entry) + assets = append(assets, u.CSS...) + } + for _, asset := range assets { + info, e := os.Lstat(filepath.Join(dir, filepath.FromSlash(asset))) + if e != nil || !info.Mode().IsRegular() || (asset == m.Executable && info.Mode()&0100 == 0) { + return contract.Manifest{}, nil, nil, ErrPackage + } + } + var icon *PackageIcon + if u := m.Capabilities.UI; u != nil && u.Icon != "" { + if icon, e = verifyIcon(dir, u.Icon); e != nil { + return contract.Manifest{}, nil, nil, e + } + } + skillFiles, e := verifySkills(dir, m) + if e != nil { + return contract.Manifest{}, nil, nil, e + } + return m, icon, skillFiles, nil +} + // verifyIcon opens the declared icon inside the staged package without // following links, bounds its size and checks that its bytes have the // signature of the declared type. An icon that cannot be served as an diff --git a/core/services/addons/packages/receipt.go b/core/services/addons/packages/receipt.go index 8a159927f5..43ec1b7203 100644 --- a/core/services/addons/packages/receipt.go +++ b/core/services/addons/packages/receipt.go @@ -21,15 +21,19 @@ type PackageIcon struct { } type PackageReceipt struct { - Digest string `json:"digest"` - SourceID string `json:"sourceId"` - SourceURL string `json:"sourceUrl"` - Channel string `json:"channel,omitempty"` - Selection string `json:"selection"` - Reference string `json:"reference"` - Verification string `json:"verification"` - Manifest contract.Manifest `json:"manifest"` - Directory string `json:"directory"` + Digest string `json:"digest"` + SourceID string `json:"sourceId"` + SourceURL string `json:"sourceUrl"` + Channel string `json:"channel,omitempty"` + Selection string `json:"selection"` + Reference string `json:"reference"` + Verification string `json:"verification"` + // Origin says in words where an unlisted package came from. It is set + // for a package installed from a local file ("local file, unsigned") and + // empty for a library package, whose source and verification say it all. + Origin string `json:"origin,omitempty"` + Manifest contract.Manifest `json:"manifest"` + Directory string `json:"directory"` // Icon is set only when the manifest declares one and the package holds a // valid immutable file of the declared type. Icon *PackageIcon `json:"icon,omitempty"`