Files
tiny-forge/internal/volsnap/extract.go
T
alexei.dolgolyov 1c47030854 feat(volsnap): volume snapshot restore (backlog #6)
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/
2026-06-22 17:23:52 +03:00

163 lines
4.9 KiB
Go

package volsnap
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
)
// safeExtractIndex extracts the files archived under the integer subdirectory
// `index` of a snapshot tar.gz into dest, returning the total bytes written.
//
// On RESTORE the archive is treated as UNTRUSTED (it may have been downloaded,
// hand-edited, or swapped on disk), so this is hardened well beyond what the
// capture writer emits:
//
// - zip-slip: every resolved target must stay within dest (HasPrefix check on
// the cleaned absolute path) — a "../" or absolute member is rejected.
// - type allow-list: ONLY regular files and directories are materialized;
// symlinks, hardlinks, char/block devices, fifos, and sockets are rejected
// outright (never created, never followed) — they could redirect a write
// outside the volume or smuggle in a device node.
// - decompression bomb: a running byte counter is capped at bombCap; the first
// byte past the cap aborts the extraction.
//
// dest must be a fresh staging directory (files are created O_EXCL). The caller
// performs the atomic rename-swap of dest onto the live path separately.
func safeExtractIndex(archivePath string, index int, dest string, bombCap int64) (int64, error) {
f, err := os.Open(archivePath)
if err != nil {
return 0, fmt.Errorf("open archive: %w", err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return 0, fmt.Errorf("gzip reader: %w", err)
}
defer gz.Close()
cleanDest, err := filepath.Abs(dest)
if err != nil {
return 0, fmt.Errorf("resolve dest: %w", err)
}
if err := os.MkdirAll(cleanDest, 0o700); err != nil {
return 0, fmt.Errorf("create dest: %w", err)
}
tr := tar.NewReader(gz)
var written int64
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return written, fmt.Errorf("read tar: %w", err)
}
// Archive paths are always forward-slash. path.Clean collapses any
// "./" / "../" so the prefix and containment checks see a normal form.
name := path.Clean(hdr.Name)
if name == "manifest.json" {
continue
}
rel, ok := stripIndexPrefix(name, index)
if !ok {
continue // belongs to a different volume's subtree
}
switch hdr.Typeflag {
case tar.TypeReg, tar.TypeDir:
// allowed
default:
return written, fmt.Errorf("archive entry %q has disallowed type %q", hdr.Name, string(hdr.Typeflag))
}
target := cleanDest
if rel != "" {
target = filepath.Join(cleanDest, filepath.FromSlash(rel))
}
if !withinDir(cleanDest, target) {
return written, fmt.Errorf("archive entry %q escapes destination", hdr.Name)
}
if hdr.Typeflag == tar.TypeDir {
if err := os.MkdirAll(target, 0o700); err != nil {
return written, fmt.Errorf("mkdir %s: %w", target, err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return written, fmt.Errorf("mkdir parent of %s: %w", target, err)
}
remaining := bombCap - written
if remaining <= 0 {
return written, fmt.Errorf("archive exceeds decompression cap of %d bytes", bombCap)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return written, fmt.Errorf("create %s: %w", target, err)
}
// LimitReader to remaining+1: if the entry is larger than the cap allows,
// the extra byte is copied and written>bombCap trips the guard below.
n, copyErr := io.Copy(out, io.LimitReader(tr, remaining+1))
closeErr := out.Close()
written += n
if copyErr != nil {
return written, fmt.Errorf("write %s: %w", target, copyErr)
}
if closeErr != nil {
return written, fmt.Errorf("close %s: %w", target, closeErr)
}
if written > bombCap {
return written, fmt.Errorf("archive exceeds decompression cap of %d bytes", bombCap)
}
}
return written, nil
}
// stripIndexPrefix returns the path relative to the "<index>/" subtree and
// whether name belongs to it. name=="<index>" (the subtree root) yields ("", true).
// The "/" boundary keeps index 1 from matching "10/...".
func stripIndexPrefix(name string, index int) (string, bool) {
p := strconv.Itoa(index)
if name == p {
return "", true
}
if strings.HasPrefix(name, p+"/") {
return name[len(p)+1:], true
}
return "", false
}
// leadingIndex parses the first path segment of an archive entry name as the
// volume index. Returns false for manifest.json or any non-integer prefix.
func leadingIndex(name string) (int, bool) {
seg := name
if i := strings.IndexByte(name, '/'); i >= 0 {
seg = name[:i]
}
idx, err := strconv.Atoi(seg)
if err != nil {
return 0, false
}
return idx, true
}
// withinDir reports whether target is base itself or lives beneath it. Both
// args must already be cleaned absolute paths.
func withinDir(base, target string) bool {
if target == base {
return true
}
return strings.HasPrefix(target, base+string(filepath.Separator))
}