From e6ff1ab0f9fef24928dda7cf273380abc43f9b2e Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 22:16:16 +0000 Subject: [PATCH] feat(addons): stage a package from a local zip file Add Installer.StageFile and InspectFile. A zip is checked against the digest manifest it carries, refused for unsafe or duplicate paths, links, wrong executable bits, too many or too large files and bomb ratios, then staged through the same inspection as a library package. The receipt records the source local-file and the origin "local file, unsigned". Stage now shares its manifest, executable, UI, icon and skills checks with StageFile. Assisted-by: Claude:claude-sonnet-5-5 go gofmt --- core/services/addons/packages/file.go | 332 +++++++++++++++++++++ core/services/addons/packages/file_test.go | 268 +++++++++++++++++ core/services/addons/packages/install.go | 79 ++--- core/services/addons/packages/receipt.go | 22 +- 4 files changed, 658 insertions(+), 43 deletions(-) create mode 100644 core/services/addons/packages/file.go create mode 100644 core/services/addons/packages/file_test.go 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"`