e145d48f22
* feat: add plugin system with grpc runtime and native ui plugins - go plugin backends ship as release tarballs (per-platform binaries, checksums) that kandev installs from url/upload or filesystem sideload+sync, then spawns and supervises via hashicorp/go-plugin (unix socket / loopback tcp+automtls) - strict typed grpc contract (kandev.plugin.v1): DeliverEvent/InvokeTool/HandleWebhook plus a capability-gated host service (state, secrets, emit); author sdk in pkg/pluginsdk; packaging cli in cmd/plugin-pack - native frontend bundles: window.registerKandevPlugin + registry/host loader with dynamic routes, nav items, slots and ws handlers; plugins settings page with install dialog, sync button and enable/disable/uninstall - gated behind the plugins feature flag (prod off, dev/e2e on); covered by unit and integration tests plus playwright e2e against a real packaged fixture plugin Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): hydrate base app context on plugin routes Plugin-owned SPA routes classify as RouteUnknown, so the boot payload shipped only feature flags — no workspaces, workflows, or kanban context. Native plugin UI (e.g. host.ui.TaskCreateDialog) then had an empty store. Hydrate the same base context as the Home route for unknown SPA routes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plugins): expose TaskCreateDialog on host.ui Lets a native plugin open kandev's real create-task modal (prefilled via initialValues) instead of POSTing directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): harden filesystem/URL sinks against CodeQL findings Add sink-level guards on the plugin trust boundary: securejoin containment on archive extraction (zip-slip), plugin-id validation before every fs_store path, http/https scheme validation on install-from-URL, and non-interpolated ws-handler error logging. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): address review findings (races, resource caps, lifecycle) - serialize per-plugin startup to close a TOCTOU double-spawn race, and let a plugin restart after exhausting attempts - bound plugin startup with an explicit 30s timeout - reject backslash paths and cap decompressed bytes by actual size in pkgtar; only mark a package Signed when a verifier actually ran and passed - delete plugin_state on uninstall; validate webhook keys against the manifest and cap webhook body size; restart an active plugin when a new version installs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): sanitize plugin-id path sinks for CodeQL Pin the plugin id to a single path segment with filepath.Base at recordPath/ configPath and pkgtar.Remove — a barrier the taint analysis recognizes. Behaviour-identical for valid ids (already validated by safePluginID); clears the go/path-injection alerts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): inline plugin-id validation at path sinks for CodeQL filepath.Base does not strip '..', so it is not a path-injection barrier. Call the recognized safePluginID / id-pattern guard inline in Delete/GetConfig/ SetConfig and pkgtar.Remove — the same barrier that already cleared the Get sink. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): address code-review findings (backend) - add capability-gating tests for the Host RPC service (was untested) - close a stop-during-start orphan race so Disable/Uninstall can't leave a supervised subprocess behind - on failed upgrade, remove only the new version dir (not all versions + data); restart the prior version - validate manifest version and reject ids ending in .config (record-file alias) - make Deliverer.Refresh non-blocking and cancel in-flight delivery on worker stop - clamp out-of-range webhook status, skip-and-warn corrupt records in List, write records atomically, semver-aware sideload version pick Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): address code-review findings (frontend) - wrap plugin routes + settings routes in a shared PluginErrorBoundary so a throwing plugin page shows a fallback instead of white-screening the SPA - fix disable/re-enable in one session: keep the cached registration and re-run initialize instead of relying on a cached ES-module re-import - prefix root-relative bundle/style URLs with apiBaseUrl (split-origin/Tauri) - surface partial-install warnings via toast.warning instead of a bare success - dedupe/remove injected style links on unload; gate PluginNavItems on the flag Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugins): serialize per-plugin lifecycle + guard config writes - add a per-plugin keyed lock in Service so concurrent Enable/Disable/Install/ Uninstall/UpdateConfig for one id are serialized (no status-machine race) - take s.mu in SetConfig/GetConfig and write config atomically - bound each plugin initialize() with a timeout so one hung bundle can't stall the rest of the boot load Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(plugins): split service_test.go to satisfy file-length lint Move the install/URL test group into service_install_test.go; no logic change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: build and ship the e2e fixture plugin package to E2E shards The plugin E2E specs' global-setup requires apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz. Build it in the E2E build job, upload it as an artifact, and download it in both the regular and container shard jobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
549 lines
20 KiB
Go
549 lines
20 KiB
Go
// Package pkgtar implements the kandev plugin package format described in
|
|
// docs/plans/plugins/GRPC-CONTRACT.md §6: a tar.gz archive containing
|
|
// manifest.yaml, a set of per-platform server/ executables, an optional
|
|
// ui/ bundle, and a checksums.txt covering every other file. Inspect reads
|
|
// only the manifest (no disk writes); Install verifies and atomically
|
|
// extracts a package into a per-plugin, per-version directory; Remove
|
|
// deletes an installed plugin's directory tree.
|
|
package pkgtar
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bufio"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/kandev/kandev/internal/plugins/manifest"
|
|
)
|
|
|
|
// pluginIDPattern mirrors the manifest id rule: a single clean path segment
|
|
// of lowercase alphanumerics, dots, underscores, and hyphens. Used as an
|
|
// inline barrier before any id is joined into a filesystem path.
|
|
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
|
|
|
|
const (
|
|
manifestFileName = "manifest.yaml"
|
|
checksumsFileName = "checksums.txt"
|
|
checksumsSigFileName = "checksums.txt.sig"
|
|
|
|
// maxManifestSize caps how much of manifest.yaml Inspect will read into
|
|
// memory before giving up.
|
|
maxManifestSize = 1 << 20 // 1 MiB
|
|
)
|
|
|
|
// maxPackageFileSize caps any single file's size during Install, and
|
|
// maxPackageTotalSize caps the sum of all files' ACTUAL (decompressed)
|
|
// bytes copied during Install — see readArchive. Declared as vars (not
|
|
// consts), mirroring VerifySignature below, purely so this package's own
|
|
// tests can override them to exercise the cap logic against small fixtures
|
|
// instead of allocating real 200MB payloads; production defaults are
|
|
// unchanged.
|
|
var (
|
|
maxPackageFileSize int64 = 200 << 20 // 200 MiB
|
|
maxPackageTotalSize int64 = 200 << 20 // 200 MiB
|
|
)
|
|
|
|
// Sentinel errors returned (wrapped) by Install. Use errors.Is to check for
|
|
// a specific failure category.
|
|
var (
|
|
// ErrManifestInvalid means manifest.yaml is missing, fails to parse,
|
|
// fails manifest.Validate(), or is not a runtime-managed manifest
|
|
// (manifest.IsManaged() == false).
|
|
ErrManifestInvalid = errors.New("pkgtar: manifest invalid")
|
|
// ErrMissingChecksums means the package has no checksums.txt entry.
|
|
ErrMissingChecksums = errors.New("pkgtar: missing checksums.txt")
|
|
// ErrUnlistedFile means the package contains a file that checksums.txt
|
|
// does not list.
|
|
ErrUnlistedFile = errors.New("pkgtar: file not listed in checksums.txt")
|
|
// ErrChecksumMismatch means a file's sha256 does not match the value
|
|
// recorded in checksums.txt.
|
|
ErrChecksumMismatch = errors.New("pkgtar: checksum mismatch")
|
|
// ErrPathTraversal means an archive entry's path escapes the package
|
|
// root (absolute path, "..", or a symlink/hardlink entry).
|
|
ErrPathTraversal = errors.New("pkgtar: unsafe archive path")
|
|
// ErrPlatformNotSupported means the manifest does not declare an
|
|
// executable for the current host's runtime.GOOS-runtime.GOARCH.
|
|
ErrPlatformNotSupported = errors.New("pkgtar: host platform not supported by package")
|
|
// ErrVersionExists means destRoot/<id>/<version> already exists.
|
|
ErrVersionExists = errors.New("pkgtar: version already installed")
|
|
)
|
|
|
|
// VerifySignature is an injectable hook for verifying checksums.txt.sig
|
|
// against the checksums.txt bytes it signs. It is nil by default, meaning
|
|
// signature verification is not performed: a present checksums.txt.sig
|
|
// entry with no VerifySignature configured leaves InstallResult.Signed
|
|
// false (unverified, not merely "present") — Install still succeeds since
|
|
// signing is optional. Set this (e.g. to an ed25519 verifier) to enable
|
|
// real verification; a non-nil error from it fails Install outright, and a
|
|
// nil error sets InstallResult.Signed=true.
|
|
var VerifySignature func(sig, checksums []byte) error
|
|
|
|
// InstallResult describes the outcome of a successful Install.
|
|
type InstallResult struct {
|
|
// Manifest is the parsed, validated manifest.yaml of the installed
|
|
// package.
|
|
Manifest *manifest.Manifest
|
|
// InstallPath is the absolute path the package was extracted to:
|
|
// destRoot/<id>/<version>.
|
|
InstallPath string
|
|
// Version is Manifest.Version, duplicated here for convenience.
|
|
Version string
|
|
// Signed reports whether the package included a checksums.txt.sig
|
|
// entry AND a configured VerifySignature verifier successfully
|
|
// verified it. A present-but-unverified signature (no verifier wired)
|
|
// or a missing signature both leave this false. See VerifySignature.
|
|
Signed bool
|
|
}
|
|
|
|
// Inspect streams r (a kandev plugin tar.gz package), extracts only
|
|
// manifest.yaml into memory (capped at maxManifestSize), and parses +
|
|
// validates it. It performs no checksum verification and writes nothing to
|
|
// disk; it exists to preview a package's declared capabilities before
|
|
// installing it.
|
|
func Inspect(r io.Reader) (*manifest.Manifest, error) {
|
|
tr, closeReader, err := openTarGz(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer closeReader()
|
|
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
return nil, fmt.Errorf("pkgtar: %s not found in package", manifestFileName)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pkgtar: reading tar entry: %w", err)
|
|
}
|
|
if hdr.Typeflag != tar.TypeReg {
|
|
continue
|
|
}
|
|
name, err := cleanArchivePath(hdr.Name)
|
|
if err != nil || name != manifestFileName {
|
|
continue
|
|
}
|
|
return parseManifestEntry(tr, hdr.Size)
|
|
}
|
|
}
|
|
|
|
// parseManifestEntry reads a manifest.yaml tar entry (capped at
|
|
// maxManifestSize) and parses + validates it.
|
|
func parseManifestEntry(tr *tar.Reader, size int64) (*manifest.Manifest, error) {
|
|
if size > maxManifestSize {
|
|
return nil, fmt.Errorf("pkgtar: %s exceeds max size of %d bytes", manifestFileName, maxManifestSize)
|
|
}
|
|
data, err := readCapped(tr, maxManifestSize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pkgtar: reading %s: %w", manifestFileName, err)
|
|
}
|
|
m, err := manifest.Parse(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pkgtar: parsing %s: %w", manifestFileName, err)
|
|
}
|
|
if err := m.Validate(); err != nil {
|
|
return nil, fmt.Errorf("pkgtar: invalid %s: %w", manifestFileName, err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// Install verifies and atomically extracts a kandev plugin package (tar.gz,
|
|
// read fully from r) into destRoot/<id>/<version>/, per
|
|
// docs/plans/plugins/GRPC-CONTRACT.md §6:
|
|
//
|
|
// 1. Read the whole archive (size-capped, path-traversal-rejected).
|
|
// 2. Require checksums.txt covering every other file; verify every hash
|
|
// and reject unlisted files.
|
|
// 3. Parse + validate manifest.yaml; require it to be runtime-managed.
|
|
// 4. Require an executable declared for the current host platform.
|
|
// 5. Extract to a temp dir under destRoot/<id>/, chmod declared
|
|
// executables to 0755, then atomically rename into place. Fails with
|
|
// ErrVersionExists if destRoot/<id>/<version> already exists.
|
|
func Install(r io.Reader, destRoot string) (*InstallResult, error) {
|
|
files, err := readArchive(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
signed, err := verifyPackageIntegrity(files)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m, execPath, err := validateInstallManifest(files)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
versionDir, err := extractPackage(destRoot, m, files, execPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &InstallResult{
|
|
Manifest: m,
|
|
InstallPath: versionDir,
|
|
Version: m.Version,
|
|
Signed: signed,
|
|
}, nil
|
|
}
|
|
|
|
// verifyPackageIntegrity requires checksums.txt, verifies every listed
|
|
// file's hash, rejects unlisted files, and (when present) runs
|
|
// checksums.txt.sig through VerifySignature. signed is true only when a
|
|
// VerifySignature verifier is configured AND it actually succeeded — a
|
|
// present-but-unverified signature (no verifier wired, the default) is
|
|
// reported as unsigned rather than claiming a guarantee nothing checked. A
|
|
// present signature that fails verification fails Install outright (a
|
|
// tampered/invalid signature is worse than no signature at all); signing
|
|
// otherwise remains optional and never blocks an unsigned install.
|
|
func verifyPackageIntegrity(files map[string][]byte) (signed bool, err error) {
|
|
checksumsData, ok := files[checksumsFileName]
|
|
if !ok {
|
|
return false, ErrMissingChecksums
|
|
}
|
|
checks, err := parseChecksums(checksumsData)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if err := verifyChecksums(files, checks); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
sigData, hasSig := files[checksumsSigFileName]
|
|
if !hasSig || VerifySignature == nil {
|
|
return false, nil
|
|
}
|
|
if err := VerifySignature(sigData, checksumsData); err != nil {
|
|
return false, fmt.Errorf("pkgtar: signature verification failed: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// validateInstallManifest parses manifest.yaml out of files, validates it,
|
|
// requires it to be runtime-managed, and resolves the current host
|
|
// platform's executable path (which must itself be present in files).
|
|
func validateInstallManifest(files map[string][]byte) (*manifest.Manifest, string, error) {
|
|
manifestData, ok := files[manifestFileName]
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("%w: missing %s", ErrManifestInvalid, manifestFileName)
|
|
}
|
|
m, err := manifest.Parse(manifestData)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("%w: %v", ErrManifestInvalid, err)
|
|
}
|
|
if err := m.Validate(); err != nil {
|
|
return nil, "", fmt.Errorf("%w: %v", ErrManifestInvalid, err)
|
|
}
|
|
if !m.IsManaged() {
|
|
return nil, "", fmt.Errorf("%w: manifest is not runtime-managed (runtime.type must be \"binary\")", ErrManifestInvalid)
|
|
}
|
|
|
|
execPath, ok := m.ExecutableFor(runtime.GOOS, runtime.GOARCH)
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("%w: %s-%s", ErrPlatformNotSupported, runtime.GOOS, runtime.GOARCH)
|
|
}
|
|
if _, ok := files[execPath]; !ok {
|
|
return nil, "", fmt.Errorf("%w: declared executable %q not found in package", ErrManifestInvalid, execPath)
|
|
}
|
|
return m, execPath, nil
|
|
}
|
|
|
|
// extractPackage writes files into a temp dir under destRoot/<id>/, chmods
|
|
// every declared runtime executable to 0755, and atomically renames the
|
|
// temp dir to destRoot/<id>/<version>. It fails with ErrVersionExists if
|
|
// that directory already exists, and cleans up the temp dir on any error.
|
|
func extractPackage(destRoot string, m *manifest.Manifest, files map[string][]byte, hostExecPath string) (string, error) {
|
|
pluginDir, err := securejoin(destRoot, m.ID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
versionDir, err := securejoin(pluginDir, m.Version)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if _, err := os.Stat(versionDir); err == nil {
|
|
return "", fmt.Errorf("%w: %s", ErrVersionExists, versionDir)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return "", fmt.Errorf("pkgtar: checking install path: %w", err)
|
|
}
|
|
|
|
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
|
return "", fmt.Errorf("pkgtar: creating plugin dir: %w", err)
|
|
}
|
|
tmpDir, err := os.MkdirTemp(pluginDir, ".tmp-*")
|
|
if err != nil {
|
|
return "", fmt.Errorf("pkgtar: creating temp dir: %w", err)
|
|
}
|
|
succeeded := false
|
|
defer func() {
|
|
if !succeeded {
|
|
_ = os.RemoveAll(tmpDir)
|
|
}
|
|
}()
|
|
|
|
_ = hostExecPath // the whole executables set is chmodded, not just the host one
|
|
execSet := executablePaths(m)
|
|
if err := writePackageFiles(tmpDir, files, execSet); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := os.Rename(tmpDir, versionDir); err != nil {
|
|
return "", fmt.Errorf("pkgtar: finalizing install: %w", err)
|
|
}
|
|
succeeded = true
|
|
return versionDir, nil
|
|
}
|
|
|
|
// writePackageFiles writes every entry in files under root, creating
|
|
// parent directories as needed, and chmods entries listed in execSet to
|
|
// 0755. Every entry name is archive-controlled, so each destination path
|
|
// is re-validated with securejoin at this write sink even though
|
|
// cleanArchivePath already rejected unsafe names when the archive was
|
|
// read (defense in depth: this is the actual filesystem write).
|
|
func writePackageFiles(root string, files map[string][]byte, execSet map[string]bool) error {
|
|
for name, data := range files {
|
|
dest, err := securejoin(root, filepath.FromSlash(name))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
return fmt.Errorf("pkgtar: creating dir for %s: %w", name, err)
|
|
}
|
|
mode := os.FileMode(0o644)
|
|
if execSet[name] {
|
|
mode = 0o755
|
|
}
|
|
if err := os.WriteFile(dest, data, mode); err != nil {
|
|
return fmt.Errorf("pkgtar: writing %s: %w", name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// securejoin joins root and rel (a path derived from archive-controlled
|
|
// input: an archive entry name or a manifest-declared executable path)
|
|
// and verifies the result stays within root. It is the sink-level guard
|
|
// against path traversal / "zip slip": callers upstream (cleanArchivePath,
|
|
// manifest.Validate) already reject unsafe names, but this re-checks
|
|
// containment right before the filesystem operation that uses the path.
|
|
func securejoin(root, rel string) (string, error) {
|
|
if filepath.IsAbs(rel) {
|
|
return "", fmt.Errorf("%w: absolute path %q", ErrPathTraversal, rel)
|
|
}
|
|
dst := filepath.Join(root, rel)
|
|
cleanRoot := filepath.Clean(root)
|
|
relToRoot, err := filepath.Rel(cleanRoot, filepath.Clean(dst))
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %s", ErrPathTraversal, rel)
|
|
}
|
|
if relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(os.PathSeparator)) || filepath.IsAbs(relToRoot) {
|
|
return "", fmt.Errorf("%w: %s", ErrPathTraversal, rel)
|
|
}
|
|
return dst, nil
|
|
}
|
|
|
|
// executablePaths returns the set of package-relative paths declared by
|
|
// any of the manifest's runtime.executables entries (not just the host
|
|
// platform's), so a multi-platform package has every included executable
|
|
// chmodded regardless of which one the host will run.
|
|
func executablePaths(m *manifest.Manifest) map[string]bool {
|
|
set := make(map[string]bool, len(m.Runtime.Executables))
|
|
for _, p := range m.Runtime.Executables {
|
|
set[p] = true
|
|
}
|
|
return set
|
|
}
|
|
|
|
// Remove deletes destRoot/<id>/ entirely: every installed version plus the
|
|
// plugin's data directory.
|
|
func Remove(destRoot, id string) error {
|
|
if !pluginIDPattern.MatchString(id) {
|
|
return fmt.Errorf("pkgtar: invalid plugin id %q", id)
|
|
}
|
|
dir, err := securejoin(destRoot, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
return fmt.Errorf("pkgtar: removing %s: %w", dir, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readArchive fully decompresses and un-tars r into a name->contents map,
|
|
// enforcing per-file and total size caps and rejecting any entry that is
|
|
// not a plain file at a clean, package-relative path (no symlinks,
|
|
// hardlinks, absolute paths, or "..").
|
|
//
|
|
// Both caps are enforced against bytes ACTUALLY copied out of the
|
|
// decompressed stream, not the archive-declared hdr.Size: hdr.Size comes
|
|
// from the (attacker-controlled) tar header, and a gzip stream can be
|
|
// crafted to decompress to far more data than a small compressed payload
|
|
// suggests. Every entry's read is independently bounded by
|
|
// io.LimitReader inside readCapped regardless of what hdr.Size claims, and
|
|
// the running `total` is accumulated from len(data) — the real byte count
|
|
// — after that bounded read, so a mismatched/lied Size cannot smuggle more
|
|
// decompressed data past either cap.
|
|
func readArchive(r io.Reader) (map[string][]byte, error) {
|
|
tr, closeReader, err := openTarGz(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer closeReader()
|
|
|
|
files := make(map[string][]byte)
|
|
var total int64
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pkgtar: reading tar entry: %w", err)
|
|
}
|
|
|
|
switch hdr.Typeflag {
|
|
case tar.TypeDir:
|
|
continue
|
|
case tar.TypeReg:
|
|
// handled below
|
|
default:
|
|
return nil, fmt.Errorf("%w: entry %q has unsupported type (symlinks/hardlinks/devices are not allowed)", ErrPathTraversal, hdr.Name)
|
|
}
|
|
|
|
name, err := cleanArchivePath(hdr.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hdr.Size > maxPackageFileSize {
|
|
// Fast pre-check: reject an implausible declared size before
|
|
// even attempting the (still independently bounded) read.
|
|
return nil, fmt.Errorf("pkgtar: file %s exceeds max size of %d bytes", name, maxPackageFileSize)
|
|
}
|
|
data, err := readCapped(tr, maxPackageFileSize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pkgtar: reading %s: %w", name, err)
|
|
}
|
|
total += int64(len(data))
|
|
if total > maxPackageTotalSize {
|
|
return nil, fmt.Errorf("pkgtar: package exceeds max total size of %d bytes", maxPackageTotalSize)
|
|
}
|
|
files[name] = data
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// openTarGz wraps r in a gzip reader and a tar reader, returning a closer
|
|
// that releases the gzip reader.
|
|
func openTarGz(r io.Reader) (*tar.Reader, func(), error) {
|
|
gz, err := gzip.NewReader(r)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("pkgtar: invalid gzip stream: %w", err)
|
|
}
|
|
return tar.NewReader(gz), func() { _ = gz.Close() }, nil
|
|
}
|
|
|
|
// readCapped reads at most maxSize+1 bytes from r and errors if that many
|
|
// were available, i.e. the entry exceeds maxSize bytes.
|
|
func readCapped(r io.Reader, maxSize int64) ([]byte, error) {
|
|
data, err := io.ReadAll(io.LimitReader(r, maxSize+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(data)) > maxSize {
|
|
return nil, fmt.Errorf("entry exceeds max size of %d bytes", maxSize)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// cleanArchivePath cleans and validates a tar entry name: it must not be
|
|
// empty, absolute, contain a backslash, or escape the package root via
|
|
// "..". Backslashes are rejected outright rather than treated as a path
|
|
// separator: path.Clean/path.IsAbs only understand "/", so an entry like
|
|
// "server/..\\..\\escape.exe" would pass through unchanged (no leading ".."
|
|
// segment under "/"-only splitting) and could then be reinterpreted as an
|
|
// actual traversal by filepath.Join/filepath.Clean on a host where "\\" is
|
|
// a separator (Windows) — securejoin (the write-sink guard) runs
|
|
// filepath.Join too, so the same risk applies there.
|
|
func cleanArchivePath(name string) (string, error) {
|
|
if name == "" {
|
|
return "", fmt.Errorf("%w: empty entry name", ErrPathTraversal)
|
|
}
|
|
if strings.ContainsRune(name, '\\') {
|
|
return "", fmt.Errorf("%w: backslash in entry name %q", ErrPathTraversal, name)
|
|
}
|
|
if path.IsAbs(name) {
|
|
return "", fmt.Errorf("%w: absolute path %q", ErrPathTraversal, name)
|
|
}
|
|
cleaned := path.Clean(name)
|
|
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || path.IsAbs(cleaned) {
|
|
return "", fmt.Errorf("%w: %q", ErrPathTraversal, name)
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
// parseChecksums parses "sha256 path" lines (sha256sum-style: hex digest,
|
|
// whitespace, path) into a map of clean path -> lowercase hex digest.
|
|
func parseChecksums(data []byte) (map[string]string, error) {
|
|
checks := make(map[string]string)
|
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) != 2 {
|
|
return nil, fmt.Errorf("pkgtar: malformed %s line: %q", checksumsFileName, line)
|
|
}
|
|
name, err := cleanArchivePath(fields[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
checks[name] = strings.ToLower(fields[0])
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, fmt.Errorf("pkgtar: reading %s: %w", checksumsFileName, err)
|
|
}
|
|
return checks, nil
|
|
}
|
|
|
|
// verifyChecksums checks that every file in files (other than checksums.txt
|
|
// and checksums.txt.sig) is listed in checks with a matching sha256, that
|
|
// no file is unlisted, and that checksums.txt doesn't reference a file that
|
|
// isn't in the archive.
|
|
func verifyChecksums(files map[string][]byte, checks map[string]string) error {
|
|
for name, data := range files {
|
|
if name == checksumsFileName || name == checksumsSigFileName {
|
|
continue
|
|
}
|
|
want, ok := checks[name]
|
|
if !ok {
|
|
return fmt.Errorf("%w: %s", ErrUnlistedFile, name)
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
got := hex.EncodeToString(sum[:])
|
|
if got != want {
|
|
return fmt.Errorf("%w: %s", ErrChecksumMismatch, name)
|
|
}
|
|
}
|
|
for name := range checks {
|
|
if _, ok := files[name]; !ok {
|
|
return fmt.Errorf("pkgtar: %s references missing file %s", checksumsFileName, name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|