1c47030854
Restore a captured volume snapshot onto an image workload's live host-bind
data volumes, then redeploy — the most destructive workload action, built to
the adversarially-reviewed design (C1–C6) with all data-loss guards.
- Engine.Restore (engine-owned): all-or-nothing pre-flight re-resolution from
the workload's CURRENT config (never the tamperable manifest), per-filesystem
disk pre-check, per-workload lock, container quiesce, extract-to-tmp, durable
pre-restore snapshot, write-ahead journal, atomic rename swap, redeploy, and
crash-recovery sweep (RecoverInterruptedRestores) wired before serving.
- internal/keyedmutex: shared per-key lock; deployer now serializes every
deploy entrypoint per workload via DispatchPlugin (+ LockWorkload/RedeployLocked
for the restore re-dispatch, no deadlock).
- Untrusted-archive extractor: zip-slip containment, type allow-list (reg/dir
only), decompression-bomb cap, manifest-index bounds.
- POST /api/workloads/{id}/snapshots/{sid}/restore: admin, X-Confirm-Restore
header (CSRF), per-workload single-flight (409).
- WebUI: Restore button + danger ConfirmDialog + busy state + i18n (en/ru).
Scope: image-source only; scopes absolute/stage/project (driven off the same
supportedScopes constant capture uses).
Plan-reviewed before coding; per-phase go/security/ts reviews; final review
READY TO MERGE. Security review caught + fixed a CRITICAL manifest-Source path
traversal (re-derive target from current config + base containment).
Plan: plans/volume-snapshot-restore/
167 lines
5.5 KiB
Go
167 lines
5.5 KiB
Go
package volsnap
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type tentry struct {
|
|
name string
|
|
typeflag byte
|
|
body string
|
|
linkname string
|
|
}
|
|
|
|
// buildTarGz writes an arbitrary (possibly hostile) tar.gz so the extractor's
|
|
// untrusted-input hardening can be exercised — writeArchive only emits well-
|
|
// formed reg/dir entries, which is the wrong shape for these tests.
|
|
func buildTarGz(t *testing.T, entries []tentry) string {
|
|
t.Helper()
|
|
dest := filepath.Join(t.TempDir(), "snap.tar.gz")
|
|
f, err := os.Create(dest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gz := gzip.NewWriter(f)
|
|
tw := tar.NewWriter(gz)
|
|
for _, e := range entries {
|
|
hdr := &tar.Header{Name: e.name, Typeflag: e.typeflag, Mode: 0o600, Linkname: e.linkname}
|
|
switch e.typeflag {
|
|
case tar.TypeReg:
|
|
hdr.Size = int64(len(e.body))
|
|
case tar.TypeChar, tar.TypeBlock:
|
|
hdr.Devmajor, hdr.Devminor = 1, 1
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e.typeflag == tar.TypeReg {
|
|
if _, err := tw.Write([]byte(e.body)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return dest
|
|
}
|
|
|
|
func TestSafeExtractIndex_RoundTrip(t *testing.T) {
|
|
arc := buildTarGz(t, []tentry{
|
|
{name: "0/", typeflag: tar.TypeDir},
|
|
{name: "0/a.txt", typeflag: tar.TypeReg, body: "hello"},
|
|
{name: "0/sub/", typeflag: tar.TypeDir},
|
|
{name: "0/sub/b.txt", typeflag: tar.TypeReg, body: "world"},
|
|
{name: "manifest.json", typeflag: tar.TypeReg, body: "[]"},
|
|
})
|
|
dest := filepath.Join(t.TempDir(), "out")
|
|
n, err := safeExtractIndex(arc, 0, dest, maxRestoreUncompressedBytes)
|
|
if err != nil {
|
|
t.Fatalf("extract: %v", err)
|
|
}
|
|
if n != int64(len("hello")+len("world")) {
|
|
t.Errorf("written = %d, want %d", n, len("hello")+len("world"))
|
|
}
|
|
if got, _ := os.ReadFile(filepath.Join(dest, "a.txt")); string(got) != "hello" {
|
|
t.Errorf("a.txt = %q", got)
|
|
}
|
|
if got, _ := os.ReadFile(filepath.Join(dest, "sub", "b.txt")); string(got) != "world" {
|
|
t.Errorf("sub/b.txt = %q", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "manifest.json")); !os.IsNotExist(err) {
|
|
t.Error("manifest.json must not be extracted into the volume")
|
|
}
|
|
}
|
|
|
|
func TestSafeExtractIndex_IsolatesIndex(t *testing.T) {
|
|
arc := buildTarGz(t, []tentry{
|
|
{name: "1/keep.txt", typeflag: tar.TypeReg, body: "one"},
|
|
{name: "10/other.txt", typeflag: tar.TypeReg, body: "ten"},
|
|
{name: "2/nope.txt", typeflag: tar.TypeReg, body: "two"},
|
|
})
|
|
dest := filepath.Join(t.TempDir(), "out")
|
|
if _, err := safeExtractIndex(arc, 1, dest, maxRestoreUncompressedBytes); err != nil {
|
|
t.Fatalf("extract: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "keep.txt")); err != nil {
|
|
t.Errorf("index 1 file missing: %v", err)
|
|
}
|
|
// "10/" must not bleed into index 1 (prefix boundary), nor "2/".
|
|
for _, leaked := range []string{"other.txt", "nope.txt"} {
|
|
if _, err := os.Stat(filepath.Join(dest, leaked)); !os.IsNotExist(err) {
|
|
t.Errorf("index 1 extraction leaked %q from another index", leaked)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSafeExtractIndex_RejectsDisallowedTypes(t *testing.T) {
|
|
cases := map[string]tentry{
|
|
"symlink": {name: "0/link", typeflag: tar.TypeSymlink, linkname: "/etc/passwd"},
|
|
"hardlink": {name: "0/hard", typeflag: tar.TypeLink, linkname: "0/real"},
|
|
"chardev": {name: "0/cdev", typeflag: tar.TypeChar},
|
|
"blockdev": {name: "0/bdev", typeflag: tar.TypeBlock},
|
|
"fifo": {name: "0/fifo", typeflag: tar.TypeFifo},
|
|
"sparse": {name: "0/sparse", typeflag: tar.TypeCont}, // GNU sparse / contiguous
|
|
}
|
|
for name, ent := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
arc := buildTarGz(t, []tentry{ent})
|
|
dest := filepath.Join(t.TempDir(), "out")
|
|
if _, err := safeExtractIndex(arc, 0, dest, maxRestoreUncompressedBytes); err == nil {
|
|
t.Fatalf("expected %s entry to be rejected", name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSafeExtractIndex_RejectsBomb(t *testing.T) {
|
|
arc := buildTarGz(t, []tentry{
|
|
{name: "0/big.bin", typeflag: tar.TypeReg, body: strings.Repeat("x", 4096)},
|
|
})
|
|
dest := filepath.Join(t.TempDir(), "out")
|
|
if _, err := safeExtractIndex(arc, 0, dest, 1024); err == nil {
|
|
t.Fatal("expected extraction to abort past the decompression cap")
|
|
}
|
|
}
|
|
|
|
func TestSafeExtractIndex_NoEscapeOutsideDest(t *testing.T) {
|
|
// A "../" climb and an absolute member must never materialize a file
|
|
// outside dest, regardless of which guard catches it. Note the backslash
|
|
// case is platform-split: on Windows `..\winslip.txt` is a real climb that
|
|
// withinDir rejects; on Linux it is a literal one-segment filename that
|
|
// stays harmlessly inside dest (no guard fires). Both satisfy containment.
|
|
arc := buildTarGz(t, []tentry{
|
|
{name: "0/../../escape.txt", typeflag: tar.TypeReg, body: "pwned"},
|
|
{name: "/abs-escape.txt", typeflag: tar.TypeReg, body: "pwned"},
|
|
{name: `0/..\winslip.txt`, typeflag: tar.TypeReg, body: "pwned"},
|
|
{name: "0/ok.txt", typeflag: tar.TypeReg, body: "fine"},
|
|
})
|
|
outParent := t.TempDir()
|
|
dest := filepath.Join(outParent, "out")
|
|
// May or may not error depending on platform/guard; the invariant is that
|
|
// nothing escapes dest.
|
|
_, _ = safeExtractIndex(arc, 0, dest, maxRestoreUncompressedBytes)
|
|
|
|
for _, escaped := range []string{
|
|
filepath.Join(outParent, "escape.txt"),
|
|
filepath.Join(filepath.Dir(outParent), "escape.txt"),
|
|
filepath.Join(outParent, "abs-escape.txt"),
|
|
filepath.Join(outParent, "winslip.txt"),
|
|
} {
|
|
if _, err := os.Stat(escaped); err == nil {
|
|
t.Errorf("zip-slip escaped to %s", escaped)
|
|
}
|
|
}
|
|
}
|