feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
924f111e48
commit
f8e0520817
197 files changed
+18655
-1416
No files matched your search
+77
-11
@@ -3,34 +3,100 @@ package bundle
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WriteExtractedFiles writes payload files from a verified bundle under root (directories created as needed).
|
||||
func WriteExtractedFiles(root string, v *VerifiedContents) error {
|
||||
return WriteExtractedFilesWithLimits(root, v, Limits{})
|
||||
}
|
||||
|
||||
// WriteExtractedFilesWithLimits confines all filesystem operations to an open
|
||||
// directory handle. Replacing existing files uses unlink + exclusive creation,
|
||||
// so existing hard links and symlinks cannot redirect writes outside the root.
|
||||
func WriteExtractedFilesWithLimits(root string, v *VerifiedContents, limits Limits) error {
|
||||
l, err := limits.Normalized()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v == nil {
|
||||
return fmt.Errorf("bundle: nil verified contents")
|
||||
}
|
||||
if len(v.Manifest.Files) > l.MaxEntries-2 {
|
||||
return ErrLimit
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var total int64
|
||||
for _, fe := range v.Manifest.Files {
|
||||
if err := validatePath(fe.Path, l.MaxPathBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if seen[strings.ToLower(fe.Path)] {
|
||||
return fmt.Errorf("bundle: duplicate path %q", fe.Path)
|
||||
}
|
||||
seen[strings.ToLower(fe.Path)] = true
|
||||
data, ok := v.Files[fe.Path]
|
||||
if !ok {
|
||||
return fmt.Errorf("bundle: missing file %q", fe.Path)
|
||||
}
|
||||
if int64(len(data)) > l.MaxFileBytes || int64(len(data)) > l.MaxExpandedBytes-total {
|
||||
return ErrLimit
|
||||
}
|
||||
total += int64(len(data))
|
||||
}
|
||||
root = filepath.Clean(root)
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
confined, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = confined.Close() }()
|
||||
for _, fe := range v.Manifest.Files {
|
||||
data, ok := v.Files[fe.Path]
|
||||
if !ok {
|
||||
return fmt.Errorf("bundle: missing file %q", fe.Path)
|
||||
}
|
||||
rel := pathpkg.Clean(strings.TrimPrefix(fe.Path, "/"))
|
||||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return fmt.Errorf("bundle: unsafe path %q", fe.Path)
|
||||
parts := strings.Split(fe.Path, "/")
|
||||
for i := 1; i < len(parts); i++ {
|
||||
dir := filepath.FromSlash(strings.Join(parts[:i], "/"))
|
||||
if err := confined.Mkdir(dir, 0o755); err != nil && !os.IsExist(err) {
|
||||
return err
|
||||
}
|
||||
info, err := confined.Lstat(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("bundle: unsafe directory %q", dir)
|
||||
}
|
||||
}
|
||||
dest := filepath.Join(root, filepath.FromSlash(rel))
|
||||
relToRoot, err := filepath.Rel(root, dest)
|
||||
if err != nil || strings.HasPrefix(relToRoot, "..") {
|
||||
return fmt.Errorf("bundle: path escapes root: %q", fe.Path)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
dest := filepath.FromSlash(fe.Path)
|
||||
info, err := confined.Lstat(dest)
|
||||
if err == nil {
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("bundle: unsafe destination %q", dest)
|
||||
}
|
||||
if err = confined.Remove(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||
f, err := confined.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, writeErr := f.Write(data)
|
||||
closeErr := f.Close()
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// legacyManifestV1 mirrors the decoder fields present in the locally available
|
||||
// v1.0.0 source. It deliberately has no future capability or deployment fields.
|
||||
type legacyManifestV1 struct {
|
||||
RevisionID string `json:"revision_id"`
|
||||
SpeakerID string `json:"speaker_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Files []struct {
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
} `json:"files"`
|
||||
Algorithm string `json:"signature_algorithm"`
|
||||
PublicKeyBase64 string `json:"public_key_base64"`
|
||||
}
|
||||
|
||||
func TestLegacyCompatibilityFrozenSignedCurrentBaseline(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/current_baseline_signed_bundle.tar.gz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x42}, ed25519.SeedSize)).Public().(ed25519.PublicKey)
|
||||
verified, err := VerifyGzippedTar(raw, pub)
|
||||
if err != nil {
|
||||
t.Fatalf("frozen signed baseline rejected: %v", err)
|
||||
}
|
||||
if verified.Manifest.RevisionID != "revision-legacy" || verified.Manifest.SpeakerID != "speaker-legacy" || len(verified.Files) != 2 {
|
||||
t.Fatalf("frozen baseline contents: %+v", verified.Manifest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyCompatibilityBundleWire(t *testing.T) {
|
||||
seed := bytes.Repeat([]byte{0x42}, ed25519.SeedSize)
|
||||
priv := ed25519.NewKeyFromSeed(seed)
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
fragments := map[string]string{
|
||||
"bird.conf": "router id 192.0.2.1;\n",
|
||||
"bird.d/evobgp_prefixes_v4.conf": "# revision\n",
|
||||
"_preview": "must not ship",
|
||||
}
|
||||
raw, err := BuildGzippedTar("revision-legacy", "speaker-legacy", fragments, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = gr.Close() }()
|
||||
tr := tar.NewReader(gr)
|
||||
entries := map[string][]byte{}
|
||||
var order []string
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
order = append(order, h.Name)
|
||||
entries[h.Name] = body
|
||||
}
|
||||
wantOrder := []string{"manifest.json", "bundle.sig", "bird.conf", "bird.d/evobgp_prefixes_v4.conf"}
|
||||
if !reflect.DeepEqual(order, wantOrder) {
|
||||
t.Fatalf("legacy tar layout: got %v, want %v", order, wantOrder)
|
||||
}
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(entries["manifest.json"], &top); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantFields := []string{"created_at", "files", "public_key_base64", "revision_id", "signature_algorithm", "speaker_id"}
|
||||
fields := make([]string, 0, len(top))
|
||||
for k := range top {
|
||||
fields = append(fields, k)
|
||||
}
|
||||
sort.Strings(fields)
|
||||
if !reflect.DeepEqual(fields, wantFields) {
|
||||
t.Fatalf("manifest fields: got %v, want %v", fields, wantFields)
|
||||
}
|
||||
var m legacyManifestV1
|
||||
if err := json.Unmarshal(entries["manifest.json"], &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.RevisionID != "revision-legacy" || m.SpeakerID != "speaker-legacy" || m.CreatedAt == "" || m.Algorithm != "ed25519" || m.PublicKeyBase64 != base64.StdEncoding.EncodeToString(pub) {
|
||||
t.Fatalf("legacy manifest fields: %+v", m)
|
||||
}
|
||||
if !ed25519.Verify(pub, entries["manifest.json"], entries["bundle.sig"]) {
|
||||
t.Fatal("legacy Ed25519 verifier rejected current signed manifest")
|
||||
}
|
||||
if len(m.Files) != 2 {
|
||||
t.Fatalf("legacy manifest file count: %d", len(m.Files))
|
||||
}
|
||||
for _, f := range m.Files {
|
||||
sum := sha256.Sum256(entries[f.Path])
|
||||
if hex.EncodeToString(sum[:]) != f.SHA256 {
|
||||
t.Fatalf("legacy SHA-256 mismatch for %q", f.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrLimit reports that a transport, archive or extraction budget was exceeded.
|
||||
var ErrLimit = errors.New("bundle: resource limit exceeded")
|
||||
|
||||
// Limits bounds both archive transport and decoded tar data, including metadata.
|
||||
// Zero fields select defaults. These ceilings permit large (1M-prefix) configs;
|
||||
// they are safety ceilings, not a promise of suitability for a small-RAM node.
|
||||
type Limits struct {
|
||||
MaxCompressedBytes int64
|
||||
MaxExpandedBytes int64
|
||||
MaxFileBytes int64
|
||||
MaxManifestBytes int64
|
||||
MaxEntries int
|
||||
MaxPathBytes int
|
||||
}
|
||||
|
||||
// DefaultLimits permits large production configs while bounding untrusted input.
|
||||
func DefaultLimits() Limits {
|
||||
return Limits{MaxCompressedBytes: 128 << 20, MaxExpandedBytes: 512 << 20, MaxFileBytes: 256 << 20, MaxManifestBytes: 2 << 20, MaxEntries: 4096, MaxPathBytes: 1024}
|
||||
}
|
||||
|
||||
// Normalized fills zero fields with defaults and rejects invalid budgets.
|
||||
func (l Limits) Normalized() (Limits, error) {
|
||||
d := DefaultLimits()
|
||||
values := []struct {
|
||||
v *int64
|
||||
fallback int64
|
||||
}{{&l.MaxCompressedBytes, d.MaxCompressedBytes}, {&l.MaxExpandedBytes, d.MaxExpandedBytes}, {&l.MaxFileBytes, d.MaxFileBytes}, {&l.MaxManifestBytes, d.MaxManifestBytes}}
|
||||
for _, x := range values {
|
||||
if *x.v < 0 || *x.v == int64(^uint64(0)>>1) {
|
||||
return l, fmt.Errorf("bundle: invalid byte limit")
|
||||
}
|
||||
if *x.v == 0 {
|
||||
*x.v = x.fallback
|
||||
}
|
||||
}
|
||||
if l.MaxEntries < 0 || l.MaxPathBytes < 0 {
|
||||
return l, fmt.Errorf("bundle: invalid entry/path limit")
|
||||
}
|
||||
if l.MaxEntries == 0 {
|
||||
l.MaxEntries = d.MaxEntries
|
||||
}
|
||||
if l.MaxPathBytes == 0 {
|
||||
l.MaxPathBytes = d.MaxPathBytes
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func validatePath(name string, limit int) error {
|
||||
if name == "" || len(name) > limit || path.IsAbs(name) || path.Clean(name) != name || name == "." || strings.ContainsAny(name, "\\:\x00") {
|
||||
return fmt.Errorf("bundle: unsafe path %q", name)
|
||||
}
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
if part == ".." || strings.TrimRight(part, " .") != part {
|
||||
return fmt.Errorf("bundle: unsafe path %q", name)
|
||||
}
|
||||
base := strings.ToUpper(strings.SplitN(part, ".", 2)[0])
|
||||
if base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') {
|
||||
return fmt.Errorf("bundle: reserved path %q", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type limitedWriter struct {
|
||||
w io.Writer
|
||||
remaining int64
|
||||
}
|
||||
|
||||
func (w *limitedWriter) Write(p []byte) (int, error) {
|
||||
if int64(len(p)) > w.remaining {
|
||||
return 0, ErrLimit
|
||||
}
|
||||
n, err := w.w.Write(p)
|
||||
w.remaining -= int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ReadCompressed rejects oversized input while reading, including chunked HTTP
|
||||
// bodies and local files whose size changes after stat.
|
||||
func ReadCompressed(r io.Reader, limits Limits) ([]byte, error) {
|
||||
l, err := limits.Normalized()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(r, l.MaxCompressedBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(b)) > l.MaxCompressedBytes {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testEntry struct {
|
||||
header tar.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
func archiveEntries(t *testing.T, entries []testEntry) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
gz := gzip.NewWriter(&b)
|
||||
tw := tar.NewWriter(gz)
|
||||
for _, entry := range entries {
|
||||
h := entry.header
|
||||
if h.Typeflag == 0 {
|
||||
h.Typeflag = tar.TypeReg
|
||||
}
|
||||
h.Size = int64(len(entry.body))
|
||||
if err := tw.WriteHeader(&h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(entry.body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
func testKey() ed25519.PrivateKey {
|
||||
return ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, ed25519.SeedSize))
|
||||
}
|
||||
|
||||
func TestBundleResourceLimits(t *testing.T) {
|
||||
pub := testKey().Public().(ed25519.PublicKey)
|
||||
cases := []struct {
|
||||
name string
|
||||
entries []testEntry
|
||||
limits Limits
|
||||
}{
|
||||
{"file", []testEntry{{header: tar.Header{Name: "bird.conf"}, body: bytes.Repeat([]byte{'a'}, 1025)}}, Limits{MaxFileBytes: 1024}},
|
||||
{"expanded", []testEntry{{header: tar.Header{Name: "bird.conf"}, body: bytes.Repeat([]byte{'a'}, 8192)}}, Limits{MaxExpandedBytes: 4096}},
|
||||
{"manifest", []testEntry{{header: tar.Header{Name: manifestName}, body: bytes.Repeat([]byte{'a'}, 1025)}}, Limits{MaxManifestBytes: 1024}},
|
||||
{"entries", []testEntry{{header: tar.Header{Name: "a"}}, {header: tar.Header{Name: "b"}}, {header: tar.Header{Name: "c"}}}, Limits{MaxEntries: 2}},
|
||||
{"pax-metadata", []testEntry{{header: tar.Header{Name: "bird.conf", Format: tar.FormatPAX, PAXRecords: map[string]string{"comment": strings.Repeat("a", 8192)}}}}, Limits{MaxExpandedBytes: 4096}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := archiveEntries(t, tc.entries)
|
||||
if _, err := VerifyGzippedTarWithLimits(raw, pub, tc.limits); !errors.Is(err, ErrLimit) {
|
||||
t.Fatalf("want limit, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := VerifyGzippedTarWithLimits(make([]byte, 65), pub, Limits{MaxCompressedBytes: 64}); !errors.Is(err, ErrLimit) {
|
||||
t.Fatalf("compressed cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleRejectsUnsafeEntries(t *testing.T) {
|
||||
pub := testKey().Public().(ed25519.PublicKey)
|
||||
for _, name := range []string{"../escape", "/etc/bird.conf", `bird.d\escape`, "C:/escape", "bird.d/../escape", "a//b", "a/./b", "NUL", "bird.conf:stream", "a. /b"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
raw := archiveEntries(t, []testEntry{{header: tar.Header{Name: name}, body: []byte("x")}})
|
||||
if _, err := VerifyGzippedTar(raw, pub); err == nil {
|
||||
t.Fatal("unsafe path accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, kind := range []byte{tar.TypeSymlink, tar.TypeLink, tar.TypeDir, tar.TypeFifo} {
|
||||
raw := archiveEntries(t, []testEntry{{header: tar.Header{Name: "bird.conf", Typeflag: kind, Linkname: "outside"}}})
|
||||
if _, err := VerifyGzippedTar(raw, pub); err == nil {
|
||||
t.Fatalf("type %d accepted", kind)
|
||||
}
|
||||
}
|
||||
raw := archiveEntries(t, []testEntry{{header: tar.Header{Name: "bird.conf"}}, {header: tar.Header{Name: "bird.conf"}}})
|
||||
if _, err := VerifyGzippedTar(raw, pub); err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("duplicate accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleChecksTrailerAndTrailingExpansion(t *testing.T) {
|
||||
priv := testKey()
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
raw, err := BuildGzippedTar("r", "s", map[string]string{"bird.conf": "router id 192.0.2.1;"}, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
corrupt := append([]byte{}, raw...)
|
||||
corrupt[len(corrupt)-8] ^= 0xff
|
||||
if _, err = VerifyGzippedTar(corrupt, pub); err == nil {
|
||||
t.Fatal("gzip checksum ignored after tar EOF")
|
||||
}
|
||||
var extra bytes.Buffer
|
||||
gz := gzip.NewWriter(&extra)
|
||||
if _, err = gz.Write(bytes.Repeat([]byte{'x'}, 32<<10)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := append(append([]byte{}, raw...), extra.Bytes()...)
|
||||
if _, err = VerifyGzippedTarWithLimits(joined, pub, Limits{MaxExpandedBytes: 8 << 10}); !errors.Is(err, ErrLimit) {
|
||||
t.Fatalf("trailing gzip member bypassed budget: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleRejectsPAXSparseRegularFile(t *testing.T) {
|
||||
// archive/tar expands PAX sparse holes even though the returned type is a
|
||||
// regular file. Build a physical one-byte entry with a 4096-byte logical size.
|
||||
record := func(key, value string) string {
|
||||
body := key + "=" + value + "\n"
|
||||
n := len(body) + 2
|
||||
for {
|
||||
next := len(fmt.Sprint(n)) + 1 + len(body)
|
||||
if next == n {
|
||||
return fmt.Sprintf("%d %s", n, body)
|
||||
}
|
||||
n = next
|
||||
}
|
||||
}
|
||||
pax := record("GNU.sparse.size", "4096") + record("GNU.sparse.numblocks", "1") + record("GNU.sparse.map", "0,1")
|
||||
var physical bytes.Buffer
|
||||
tw := tar.NewWriter(&physical)
|
||||
for _, entry := range []testEntry{{header: tar.Header{Name: "PaxHeader", Size: int64(len(pax))}, body: []byte(pax)}, {header: tar.Header{Name: "bird.conf", Size: 1}, body: []byte("x")}} {
|
||||
if err := tw.WriteHeader(&entry.header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(entry.body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := physical.Bytes()
|
||||
data[156] = tar.TypeXHeader
|
||||
for i := 148; i < 156; i++ {
|
||||
data[i] = ' '
|
||||
}
|
||||
sum := 0
|
||||
for _, b := range data[:512] {
|
||||
sum += int(b)
|
||||
}
|
||||
copy(data[148:156], fmt.Sprintf("%06o\x00 ", sum))
|
||||
h, err := tar.NewReader(bytes.NewReader(data)).Next()
|
||||
if err != nil || h.Typeflag != tar.TypeReg || h.Size != 4096 {
|
||||
t.Fatalf("invalid sparse regression fixture %+v %v", h, err)
|
||||
}
|
||||
var compressed bytes.Buffer
|
||||
gz := gzip.NewWriter(&compressed)
|
||||
if _, err = gz.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = VerifyGzippedTar(compressed.Bytes(), testKey().Public().(ed25519.PublicKey)); err == nil || !strings.Contains(err.Error(), "sparse") {
|
||||
t.Fatalf("PAX sparse accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleRejectsUnmanifestedPayload(t *testing.T) {
|
||||
priv := testKey()
|
||||
raw, err := BuildGzippedTar("r", "s", map[string]string{"bird.conf": "x"}, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = gr.Close() }()
|
||||
tr := tar.NewReader(gr)
|
||||
var entries []testEntry
|
||||
for {
|
||||
h, e := tr.Next()
|
||||
if e == io.EOF {
|
||||
break
|
||||
}
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
b, e := io.ReadAll(tr)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
entries = append(entries, testEntry{header: *h, body: b})
|
||||
}
|
||||
entries = append(entries, testEntry{header: tar.Header{Name: "unsigned.conf"}, body: []byte("unsigned")})
|
||||
if _, err = VerifyGzippedTar(archiveEntries(t, entries), priv.Public().(ed25519.PublicKey)); err == nil {
|
||||
t.Fatal("unmanifested payload accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionDoesNotFollowLinks(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "outside")
|
||||
if err := os.WriteFile(outside, []byte("preserve"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dest := filepath.Join(root, "bird.conf")
|
||||
if err := os.Link(outside, dest); err != nil {
|
||||
if runtime.GOOS == "linux" {
|
||||
t.Fatalf("Linux link regression requires hard links: %v", err)
|
||||
}
|
||||
t.Skipf("hard links unavailable: %v", err)
|
||||
}
|
||||
v := &VerifiedContents{Manifest: Manifest{Files: []FileEntry{{Path: "bird.conf"}}}, Files: map[string][]byte{"bird.conf": []byte("replacement")}}
|
||||
if err := WriteExtractedFiles(root, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(outside)
|
||||
if err != nil || string(got) != "preserve" {
|
||||
t.Fatalf("hard link modified outside: %s %v", got, err)
|
||||
}
|
||||
if err = os.Remove(dest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = os.Symlink(outside, dest); err != nil {
|
||||
if runtime.GOOS == "linux" {
|
||||
t.Fatalf("Linux link regression requires symlinks: %v", err)
|
||||
}
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
if err = WriteExtractedFiles(root, v); err == nil {
|
||||
t.Fatal("symlink destination accepted")
|
||||
}
|
||||
got, err = os.ReadFile(outside)
|
||||
if err != nil || string(got) != "preserve" {
|
||||
t.Fatal("symlink modified outside")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleBuilderBoundsAndPortablePaths(t *testing.T) {
|
||||
for _, name := range []string{"/bird.conf", "a/../bird.conf", manifestName, sigFileName, `bird.d\bad`} {
|
||||
if _, err := BuildGzippedTar("r", "s", map[string]string{name: "x"}, testKey()); err == nil {
|
||||
t.Fatalf("invalid builder path accepted: %q", name)
|
||||
}
|
||||
}
|
||||
if _, err := BuildGzippedTarWithLimits("r", "s", map[string]string{"bird.conf": strings.Repeat("x", 1025)}, testKey(), Limits{MaxFileBytes: 1024}); !errors.Is(err, ErrLimit) {
|
||||
t.Fatalf("builder file cap %v", err)
|
||||
}
|
||||
if _, err := BuildGzippedTarWithLimits("r", "s", map[string]string{"bird.conf": "x"}, testKey(), Limits{MaxExpandedBytes: 1024}); !errors.Is(err, ErrLimit) {
|
||||
t.Fatalf("builder tar overhead cap %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// This is a decoded-size regression using repeated representative route lines,
|
||||
// not a unique-prefix cardinality test or a BIRD validity/performance benchmark.
|
||||
func TestDefaultLimitsAllowMillionRepresentativeIPv6RouteLines(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("large representative config")
|
||||
}
|
||||
config := strings.Repeat("route 2001:db8:1234:5678::/64 blackhole;\n", 1_000_000)
|
||||
raw, err := BuildGzippedTar("large", "speaker", map[string]string{"bird.conf": config}, testKey())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := VerifyGzippedTar(raw, testKey().Public().(ed25519.PublicKey))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(v.Files["bird.conf"]) != len(config) {
|
||||
t.Fatal("large config truncated")
|
||||
}
|
||||
}
|
||||
+35
-8
@@ -9,7 +9,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -36,22 +35,47 @@ const manifestName = "manifest.json"
|
||||
|
||||
// BuildGzippedTar builds a .tar.gz with manifest.json, bundle.sig (Ed25519 over manifest JSON), and payload files.
|
||||
func BuildGzippedTar(revisionID, speakerID string, fragments map[string]string, priv ed25519.PrivateKey) ([]byte, error) {
|
||||
return BuildGzippedTarWithLimits(revisionID, speakerID, fragments, priv, Limits{})
|
||||
}
|
||||
|
||||
// BuildGzippedTarWithLimits preserves the manifest/signature wire format while
|
||||
// refusing payloads that exceed the receiving node's configured budgets.
|
||||
func BuildGzippedTarWithLimits(revisionID, speakerID string, fragments map[string]string, priv ed25519.PrivateKey, limits Limits) ([]byte, error) {
|
||||
l, err := limits.Normalized()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(priv) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("bundle: invalid ed25519 private key size")
|
||||
}
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
|
||||
norm := make(map[string]string, len(fragments))
|
||||
norm := make(map[string]string)
|
||||
seen := map[string]bool{}
|
||||
var total int64
|
||||
for p, content := range fragments {
|
||||
p = path.Clean(strings.TrimPrefix(p, "/"))
|
||||
if p == "." || strings.HasPrefix(p, "..") {
|
||||
return nil, fmt.Errorf("bundle: invalid path %q", p)
|
||||
}
|
||||
if strings.HasPrefix(p, "_") {
|
||||
continue // UI-only preview aggregate, not shipped to nodes
|
||||
}
|
||||
if len(norm) >= l.MaxEntries-2 {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
if err := validatePath(p, l.MaxPathBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p == manifestName || p == sigFileName || seen[strings.ToLower(p)] {
|
||||
return nil, fmt.Errorf("bundle: duplicate or reserved path %q", p)
|
||||
}
|
||||
seen[strings.ToLower(p)] = true
|
||||
if int64(len(content)) > l.MaxFileBytes || int64(len(content)) > l.MaxExpandedBytes-total {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
total += int64(len(content))
|
||||
norm[p] = content
|
||||
}
|
||||
if len(norm) > l.MaxEntries-2 {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
|
||||
var files []FileEntry
|
||||
var payload [][]byte
|
||||
@@ -75,11 +99,14 @@ func BuildGzippedTar(revisionID, speakerID string, fragments map[string]string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(manifestJSON)) > l.MaxManifestBytes {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
sig := ed25519.Sign(priv, manifestJSON)
|
||||
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
gw := gzip.NewWriter(&limitedWriter{w: &buf, remaining: l.MaxCompressedBytes})
|
||||
tw := tar.NewWriter(&limitedWriter{w: gw, remaining: l.MaxExpandedBytes})
|
||||
|
||||
add := func(name string, body []byte) error {
|
||||
hdr := &tar.Header{
|
||||
|
||||
Binary file not shown.
+92
-18
@@ -11,44 +11,95 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VerifiedContents is the result of verifying a downloaded bundle on evobgp-node.
|
||||
// VerifiedContents contains only payloads covered by a verified signed manifest.
|
||||
type VerifiedContents struct {
|
||||
Manifest Manifest
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
// VerifyGzippedTar checks Ed25519 signature over manifest.json and SHA-256 of each file.
|
||||
func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents, error) {
|
||||
gr, err := gzip.NewReader(bytes.NewReader(bundle))
|
||||
// VerifyGzippedTar verifies the unchanged signed manifest format with default limits.
|
||||
func VerifyGzippedTar(raw []byte, pub ed25519.PublicKey) (*VerifiedContents, error) {
|
||||
return VerifyGzippedTarWithLimits(raw, pub, Limits{})
|
||||
}
|
||||
|
||||
// VerifyGzippedTarWithLimits verifies the signature and hashes under explicit
|
||||
// compressed, expanded, per-entry and metadata budgets.
|
||||
func VerifyGzippedTarWithLimits(raw []byte, pub ed25519.PublicKey, limits Limits) (*VerifiedContents, error) {
|
||||
l, err := limits.Normalized()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(raw)) > l.MaxCompressedBytes {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
if len(pub) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("bundle: invalid public key size")
|
||||
}
|
||||
gr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = gr.Close() }()
|
||||
|
||||
var manifestRaw []byte
|
||||
var sig []byte
|
||||
files := make(map[string][]byte)
|
||||
tr := tar.NewReader(gr)
|
||||
decoded := &io.LimitedReader{R: gr, N: l.MaxExpandedBytes + 1}
|
||||
tr := tar.NewReader(decoded)
|
||||
var manifestRaw, sig []byte
|
||||
files := map[string][]byte{}
|
||||
seen := map[string]bool{}
|
||||
count := 0
|
||||
var logicalBytes int64
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if decoded.N == 0 {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := path.Clean(hdr.Name)
|
||||
if strings.HasPrefix(name, "..") {
|
||||
return nil, fmt.Errorf("bundle: illegal tar entry %q", hdr.Name)
|
||||
count++
|
||||
if count > l.MaxEntries {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
data, err := io.ReadAll(tr)
|
||||
if err = validatePath(hdr.Name, l.MaxPathBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := hdr.Name
|
||||
folded := strings.ToLower(name)
|
||||
if seen[folded] {
|
||||
return nil, fmt.Errorf("bundle: duplicate tar entry %q", name)
|
||||
}
|
||||
seen[folded] = true
|
||||
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
||||
return nil, fmt.Errorf("bundle: nonregular tar entry %q", name)
|
||||
}
|
||||
for key := range hdr.PAXRecords {
|
||||
if strings.HasPrefix(key, "GNU.sparse.") {
|
||||
return nil, fmt.Errorf("bundle: sparse entry %q is unsupported", name)
|
||||
}
|
||||
}
|
||||
max := l.MaxFileBytes
|
||||
if name == manifestName {
|
||||
max = l.MaxManifestBytes
|
||||
}
|
||||
if name == sigFileName {
|
||||
max = ed25519.SignatureSize
|
||||
}
|
||||
if hdr.Size < 0 || hdr.Size > max || hdr.Size >= decoded.N || hdr.Size > l.MaxExpandedBytes-logicalBytes {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
logicalBytes += hdr.Size
|
||||
data, err := io.ReadAll(io.LimitReader(tr, max+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > max || decoded.N == 0 {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
switch name {
|
||||
case manifestName:
|
||||
manifestRaw = data
|
||||
@@ -58,17 +109,36 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
|
||||
files[name] = data
|
||||
}
|
||||
}
|
||||
if len(manifestRaw) == 0 || len(sig) == 0 {
|
||||
// tar EOF precedes the gzip trailer. Drain through the same byte budget so
|
||||
// checksums, extra gzip members and trailing decompression are also bounded.
|
||||
if _, err = io.Copy(io.Discard, decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if decoded.N == 0 {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
if len(manifestRaw) == 0 || len(sig) != ed25519.SignatureSize {
|
||||
return nil, fmt.Errorf("bundle: missing manifest or signature")
|
||||
}
|
||||
if !ed25519.Verify(pub, manifestRaw, sig) {
|
||||
return nil, fmt.Errorf("bundle: ed25519 signature mismatch")
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(manifestRaw, &m); err != nil {
|
||||
if err = json.Unmarshal(manifestRaw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(m.Files) > l.MaxEntries-2 {
|
||||
return nil, ErrLimit
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, fe := range m.Files {
|
||||
if err = validatePath(fe.Path, l.MaxPathBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fe.Path == manifestName || fe.Path == sigFileName || listed[strings.ToLower(fe.Path)] {
|
||||
return nil, fmt.Errorf("bundle: duplicate or reserved manifest path %q", fe.Path)
|
||||
}
|
||||
listed[strings.ToLower(fe.Path)] = true
|
||||
body, ok := files[fe.Path]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("bundle: missing file %q", fe.Path)
|
||||
@@ -78,10 +148,13 @@ func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents,
|
||||
return nil, fmt.Errorf("bundle: checksum mismatch for %q", fe.Path)
|
||||
}
|
||||
}
|
||||
if len(listed) != len(files) {
|
||||
return nil, fmt.Errorf("bundle: payload is not covered by manifest")
|
||||
}
|
||||
return &VerifiedContents{Manifest: m, Files: files}, nil
|
||||
}
|
||||
|
||||
// ParsePublicKeyHex decodes a 64-char hex Ed25519 public key (32 bytes).
|
||||
// ParsePublicKeyHex decodes a 32-byte Ed25519 public key from hexadecimal.
|
||||
func ParsePublicKeyHex(s string) (ed25519.PublicKey, error) {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X"))
|
||||
raw, err := hex.DecodeString(s)
|
||||
@@ -94,7 +167,8 @@ func ParsePublicKeyHex(s string) (ed25519.PublicKey, error) {
|
||||
return ed25519.PublicKey(raw), nil
|
||||
}
|
||||
|
||||
// PublicKeyFromManifestBase64 uses the key embedded in the signed manifest (cross-check with expected pubkey optional).
|
||||
// PublicKeyFromManifestBase64 decodes the manifest key. Callers must still trust
|
||||
// an independently provisioned key when authenticating the bundle.
|
||||
func PublicKeyFromManifestBase64(m *Manifest) (ed25519.PublicKey, error) {
|
||||
if m.PublicKeyB64 == "" {
|
||||
return nil, fmt.Errorf("bundle: manifest missing public_key_base64")
|
||||
|
||||
Reference in new issue
Block a user