313 lines
9.6 KiB
Go
313 lines
9.6 KiB
Go
// Package appbundle authenticates a bounded local bundle as bytes.
|
|
package appbundle
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"server-deploy/internal/wire"
|
|
)
|
|
|
|
const (
|
|
manifestLimit int64 = 1 << 20
|
|
fileLimit int64 = 4 << 20
|
|
payloadLimit int64 = 16 << 20
|
|
)
|
|
|
|
var (
|
|
idPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,47}$`)
|
|
versionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
|
|
digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
|
repositoryPart = regexp.MustCompile(`^[a-z0-9]+(?:[._-]+[a-z0-9]+)*$`)
|
|
pathPart = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
|
|
)
|
|
|
|
type Manifest struct {
|
|
ProtocolVersion int `json:"protocolVersion"`
|
|
AppID string `json:"appId"`
|
|
Version string `json:"version"`
|
|
Runtime string `json:"runtime"`
|
|
Entrypoint string `json:"entrypoint"`
|
|
Platforms []string `json:"platforms"`
|
|
Components []Component `json:"components"`
|
|
Files []File `json:"files"`
|
|
}
|
|
|
|
type Component struct {
|
|
Name string `json:"name"`
|
|
Image string `json:"image"`
|
|
}
|
|
|
|
type File struct {
|
|
Path string `json:"path"`
|
|
Digest string `json:"digest"`
|
|
}
|
|
|
|
type Verified struct {
|
|
Manifest Manifest
|
|
Files map[string][]byte
|
|
Digest string
|
|
}
|
|
|
|
// Verify authenticates the exact manifest and listed payload bytes. The expected
|
|
// digest is a caller trust anchor, not publisher authentication. Compose syntax
|
|
// and execution safety are not evaluated. The caller must use a trusted staging
|
|
// directory with trusted ancestors and prevent concurrent mutation; os.Root and
|
|
// identity checks do not provide a transaction against hostile concurrent writers.
|
|
func Verify(directory, expectedDigest string) (Verified, error) {
|
|
if !filepath.IsAbs(directory) {
|
|
return Verified{}, errors.New("bundle directory must be absolute")
|
|
}
|
|
// A trailing separator can make Lstat follow a directory symlink on Unix.
|
|
// Normalize it before checking the caller-selected root itself.
|
|
directory = filepath.Clean(directory)
|
|
if !digestPattern.MatchString(expectedDigest) {
|
|
return Verified{}, errors.New("invalid expected manifest digest")
|
|
}
|
|
info, err := os.Lstat(directory)
|
|
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return Verified{}, errors.New("invalid bundle directory")
|
|
}
|
|
root, err := os.OpenRoot(directory)
|
|
if err != nil {
|
|
return Verified{}, errors.New("cannot open bundle directory")
|
|
}
|
|
defer root.Close()
|
|
openedInfo, err := root.Stat(".")
|
|
if err != nil || !os.SameFile(info, openedInfo) {
|
|
return Verified{}, errors.New("bundle directory changed")
|
|
}
|
|
raw, err := readRegular(root, "manifest.json", manifestLimit)
|
|
if err != nil {
|
|
return Verified{}, errors.New("cannot read bounded regular manifest")
|
|
}
|
|
if hash(raw) != expectedDigest {
|
|
return Verified{}, errors.New("manifest digest mismatch")
|
|
}
|
|
var manifest Manifest
|
|
if err := wire.Decode(bytes.NewReader(raw), &manifest, manifestLimit); err != nil {
|
|
// Never propagate decoder errors: some include offending input values.
|
|
return Verified{}, errors.New("invalid manifest JSON")
|
|
}
|
|
tree, err := validate(manifest)
|
|
if err != nil {
|
|
return Verified{}, err
|
|
}
|
|
files := make(map[string][]byte, len(manifest.Files))
|
|
remaining := payloadLimit
|
|
if err := readInventory(root, tree, "", files, &remaining); err != nil {
|
|
return Verified{}, err
|
|
}
|
|
return Verified{Manifest: manifest, Files: files, Digest: expectedDigest}, nil
|
|
}
|
|
|
|
func hash(data []byte) string {
|
|
sum := sha256.Sum256(data)
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// readRegular caps both the pre-open size and actual bytes read. The one extra
|
|
// byte detects growth or oversize without an unbounded read, even after Stat.
|
|
// Names are single validated path segments relative to an already-open Root.
|
|
func readRegular(root *os.Root, name string, limit int64) ([]byte, error) {
|
|
info, err := root.Lstat(name)
|
|
if err != nil || !info.Mode().IsRegular() || info.Size() < 0 || info.Size() > limit {
|
|
return nil, errors.New("invalid file type or size")
|
|
}
|
|
f, err := root.Open(name)
|
|
if err != nil {
|
|
return nil, errors.New("cannot open file")
|
|
}
|
|
defer f.Close()
|
|
openedInfo, err := f.Stat()
|
|
if err != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) || openedInfo.Size() != info.Size() {
|
|
return nil, errors.New("file changed before read")
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(f, limit+1))
|
|
if err != nil || int64(len(data)) > limit || int64(len(data)) != openedInfo.Size() {
|
|
return nil, errors.New("file read exceeds bounds or changed")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
type inventory struct {
|
|
children map[string]*inventory
|
|
digest string
|
|
}
|
|
|
|
func validate(m Manifest) (*inventory, error) {
|
|
if m.ProtocolVersion != 1 || m.Runtime != "compose" || !idPattern.MatchString(m.AppID) || !versionPattern.MatchString(m.Version) {
|
|
return nil, errors.New("unsupported or invalid manifest identity")
|
|
}
|
|
if len(m.Platforms) < 1 || len(m.Platforms) > 2 {
|
|
return nil, errors.New("invalid platforms")
|
|
}
|
|
platforms := make(map[string]bool)
|
|
for _, platform := range m.Platforms {
|
|
if (platform != "linux/amd64" && platform != "linux/arm64") || platforms[platform] {
|
|
return nil, errors.New("invalid platforms")
|
|
}
|
|
platforms[platform] = true
|
|
}
|
|
if len(m.Components) < 1 || len(m.Components) > 32 {
|
|
return nil, errors.New("invalid component count")
|
|
}
|
|
names := make(map[string]bool)
|
|
for _, c := range m.Components {
|
|
if !idPattern.MatchString(c.Name) || names[c.Name] || !validImage(c.Image) {
|
|
return nil, errors.New("invalid component")
|
|
}
|
|
names[c.Name] = true
|
|
}
|
|
if len(m.Files) < 1 || len(m.Files) > 128 {
|
|
return nil, errors.New("invalid file count")
|
|
}
|
|
tree := &inventory{children: map[string]*inventory{"manifest.json": {}}}
|
|
paths := make(map[string]bool)
|
|
for _, file := range m.Files {
|
|
if !validPath(file.Path) || file.Path == "manifest.json" || !digestPattern.MatchString(file.Digest) || paths[file.Path] {
|
|
return nil, errors.New("invalid file declaration")
|
|
}
|
|
paths[file.Path] = true
|
|
node := tree
|
|
parts := strings.Split(file.Path, "/")
|
|
for i, part := range parts {
|
|
next, exists := node.children[part]
|
|
if i == len(parts)-1 {
|
|
if exists {
|
|
return nil, errors.New("conflicting file paths")
|
|
}
|
|
node.children[part] = &inventory{digest: file.Digest}
|
|
} else {
|
|
if exists && next.children == nil {
|
|
return nil, errors.New("conflicting file paths")
|
|
}
|
|
if !exists {
|
|
next = &inventory{children: make(map[string]*inventory)}
|
|
node.children[part] = next
|
|
}
|
|
node = next
|
|
}
|
|
}
|
|
}
|
|
if !paths[m.Entrypoint] {
|
|
return nil, errors.New("entrypoint must be a listed file")
|
|
}
|
|
return tree, nil
|
|
}
|
|
|
|
func validImage(image string) bool {
|
|
repo, pin, found := strings.Cut(image, "@")
|
|
if !found || !digestPattern.MatchString(pin) {
|
|
return false
|
|
}
|
|
for _, part := range strings.Split(repo, "/") {
|
|
if !repositoryPart.MatchString(part) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validPath(path string) bool {
|
|
if len(path) == 0 || len(path) > 240 {
|
|
return false
|
|
}
|
|
for _, part := range strings.Split(path, "/") {
|
|
if len(part) > 100 || !pathPart.MatchString(part) || strings.HasSuffix(part, ".") {
|
|
return false
|
|
}
|
|
base, _, _ := strings.Cut(part, ".")
|
|
switch base {
|
|
case "con", "prn", "aux", "nul", "conin$", "conout$":
|
|
return false
|
|
}
|
|
if len(base) == 4 && (strings.HasPrefix(base, "com") || strings.HasPrefix(base, "lpt")) && base[3] >= '1' && base[3] <= '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// readInventory enumerates only declared directories, one entry at a time. Each
|
|
// directory consumes at most its declared child count plus one entry; extras
|
|
// fail immediately, with no unbounded ReadDir or recursive filesystem walk.
|
|
func readInventory(root *os.Root, node *inventory, prefix string, files map[string][]byte, remaining *int64) error {
|
|
dir, err := root.Open(".")
|
|
if err != nil {
|
|
return errors.New("cannot enumerate bundle")
|
|
}
|
|
defer dir.Close()
|
|
seen := make(map[string]bool, len(node.children))
|
|
for {
|
|
entries, err := dir.ReadDir(1)
|
|
if err != nil && err != io.EOF {
|
|
return errors.New("cannot enumerate bundle")
|
|
}
|
|
if len(entries) == 0 {
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
return errors.New("invalid directory enumeration")
|
|
}
|
|
entry := entries[0]
|
|
name := entry.Name()
|
|
child, exists := node.children[name]
|
|
if !exists || seen[name] {
|
|
return errors.New("unexpected bundle entry")
|
|
}
|
|
seen[name] = true
|
|
info, statErr := root.Lstat(name)
|
|
if statErr != nil || info.Mode()&os.ModeSymlink != 0 {
|
|
return errors.New("invalid bundle entry")
|
|
}
|
|
if child.children != nil {
|
|
if !info.IsDir() {
|
|
return errors.New("expected bundle directory")
|
|
}
|
|
sub, openErr := root.OpenRoot(name)
|
|
if openErr != nil {
|
|
return errors.New("cannot open bundle subdirectory")
|
|
}
|
|
openedInfo, statErr := sub.Stat(".")
|
|
if statErr != nil || !os.SameFile(info, openedInfo) {
|
|
sub.Close()
|
|
return errors.New("bundle subdirectory changed")
|
|
}
|
|
err := readInventory(sub, child, prefix+name+"/", files, remaining)
|
|
sub.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return errors.New("expected regular bundle file")
|
|
}
|
|
if prefix == "" && name == "manifest.json" {
|
|
continue
|
|
}
|
|
limit := min(fileLimit, *remaining)
|
|
data, readErr := readRegular(root, name, limit)
|
|
if readErr != nil {
|
|
return errors.New("cannot read bounded regular payload")
|
|
}
|
|
if hash(data) != child.digest {
|
|
return errors.New("payload digest mismatch")
|
|
}
|
|
*remaining -= int64(len(data))
|
|
files[prefix+name] = data
|
|
}
|
|
if len(seen) != len(node.children) {
|
|
return errors.New("missing bundle entry")
|
|
}
|
|
return nil
|
|
}
|