Keep restore writes inside the target directory (closes #154)
check / check (pull_request) Successful in 1m21s
check / check (push) Successful in 2m51s

restoreFile and verifyRestoredFiles joined the stored path onto the target with no containment check, so a ".." segment or an absolute path escaped the target, and a restored symlink could redirect a later child write anywhere on disk. Since age decryption proves a snapshot is readable but not honest, and restore usually runs as root, a forged snapshot became an arbitrary file write.

Both call sites now go through containedRestorePath: it rejects a stored path unless filepath.IsLocal accepts it with the leading separator removed (barring "..", absolute, and empty paths), then Lstats each existing ancestor below the target and refuses to descend through a symlink. The target directory itself may be a symlink, and honest symlinks pointing outside the tree are still written verbatim.

Model: opus-4-8
This commit was merged in pull request #176.
This commit is contained in:
2026-09-22 11:01:01 +02:00
parent 38ebfd843a
commit 76a6917a35
3 changed files with 279 additions and 11 deletions
+91 -11
View File
@@ -11,6 +11,7 @@ import (
"math"
"os"
"path/filepath"
"strings"
"time"
"filippo.io/age"
@@ -30,10 +31,12 @@ var (
"Set the VAULTIK_AGE_SECRET_KEY environment variable to your " +
"age private key:\n" +
" export VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...'")
errBlobMissingFromIndex = errors.New("blob hash missing from blob index")
errChunkNotInAnyBlob = errors.New("chunk not found in any blob")
errBlobIDNotInHashIndex = errors.New("blob id missing from hash index")
errShortChunkRead = errors.New("short read")
errBlobMissingFromIndex = errors.New("blob hash missing from blob index")
errChunkNotInAnyBlob = errors.New("chunk not found in any blob")
errBlobIDNotInHashIndex = errors.New("blob id missing from hash index")
errShortChunkRead = errors.New("short read")
errRestorePathEscapesTarget = errors.New(
"refusing to restore path outside the target directory")
)
// restoreDirMode is the permission mode for directories created while
@@ -760,13 +763,85 @@ type restoreSession struct {
runningAsRoot bool
}
// containedRestorePath resolves rel — a path read from the snapshot
// database — to its location under targetDir and confirms the write will
// stay inside the target.
//
// age decryption proves a snapshot is readable, not that it is honest, so
// every stored path is treated as hostile. rel is rejected unless
// filepath.IsLocal accepts it once the leading separator is stripped:
// stored paths are absolute and the join to targetDir drops that
// separator, so "/etc/passwd" is judged as the relative "etc/passwd" it
// becomes on disk. This bars "..", absolute, and empty paths.
//
// A stored symlink whose target points outside the tree is still honest
// (and restored verbatim), but a later entry must not be written through
// it. Each existing ancestor directory below the target is therefore
// Lstat'ed and a symlink among them is refused. The leaf itself is not
// traversed: honest snapshots restore symlinks at leaf positions, and the
// unique-path constraint keeps a leaf from being both a symlink and a
// regular file. The target directory itself may be a symlink; only
// components below it are checked.
func containedRestorePath(fs afero.Fs, targetDir, rel string) (string, error) {
local := strings.TrimPrefix(rel, string(filepath.Separator))
if !filepath.IsLocal(local) {
return "", fmt.Errorf("%w: %s", errRestorePathEscapesTarget, rel)
}
local = filepath.Clean(local)
targetPath := filepath.Join(targetDir, local)
relDir := filepath.Dir(local)
if relDir == "." {
return targetPath, nil
}
current := targetDir
for component := range strings.SplitSeq(relDir, string(filepath.Separator)) {
current = filepath.Join(current, component)
info, err := lstatIfPossible(fs, current)
if err != nil {
if os.IsNotExist(err) {
continue
}
return "", fmt.Errorf("checking restore path %s: %w", current, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("%w: %s descends through symlink %s",
errRestorePathEscapesTarget, rel, current)
}
}
return targetPath, nil
}
// lstatIfPossible performs a symlink-aware stat when the filesystem
// supports it. afero.OsFs does; MemMapFs, which has no symlinks, reports
// that Lstat was not used and its result never carries ModeSymlink.
func lstatIfPossible(fs afero.Fs, name string) (os.FileInfo, error) {
if lstater, ok := fs.(afero.Lstater); ok {
info, _, err := lstater.LstatIfPossible(name)
return info, err
}
return fs.Stat(name)
}
// restoreFile dispatches to the right per-kind restorer.
func (s *restoreSession) restoreFile(file *database.File) error {
targetPath := filepath.Join(s.opts.TargetDir, file.Path.String())
targetPath, err := containedRestorePath(
s.v.Fs, s.opts.TargetDir, file.Path.String())
if err != nil {
return err
}
parentDir := filepath.Dir(targetPath)
err := s.v.Fs.MkdirAll(parentDir, restoreDirMode)
err = s.v.Fs.MkdirAll(parentDir, restoreDirMode)
if err != nil {
return fmt.Errorf("creating parent directory: %w", err)
}
@@ -1062,17 +1137,22 @@ func (v *Vaultik) verifyRestoredFiles(
return ctx.Err()
}
targetPath := filepath.Join(targetDir, file.Path.String())
targetPath, err := containedRestorePath(v.Fs, targetDir, file.Path.String())
if err == nil {
var bytesVerified int64
bytesVerified, err = v.verifyFile(ctx, repos, file, targetPath)
if err == nil {
result.FilesVerified++
result.BytesVerified += bytesVerified
}
}
bytesVerified, err := v.verifyFile(ctx, repos, file, targetPath)
if err != nil {
log.Error("File verification failed", "path", file.Path, "error", err)
result.FilesFailed++
result.FailedFiles = append(result.FailedFiles, file.Path.String())
} else {
result.FilesVerified++
result.BytesVerified += bytesVerified
}
bytesProcessed += file.Size