Files
vaultik/internal/vaultik/restore_containment_test.go
T
clawbot 76a6917a35
check / check (pull_request) Successful in 1m21s
check / check (push) Successful in 2m51s
Keep restore writes inside the target directory (closes #154)
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
2026-09-22 11:01:01 +02:00

176 lines
5.3 KiB
Go

package vaultik //nolint:testpackage // drives unexported restore internals
import (
"context"
"io"
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/types"
"sneak.berlin/go/vaultik/internal/ui"
)
// These tests exercise the path-containment guard that keeps restore from
// writing outside its target directory. age decryption proves only that a
// snapshot is readable, not that its recorded paths are honest, so restore
// treats every stored path as hostile: a compromised backed-up host could
// forge a snapshot that decrypts cleanly, and restore usually runs as root.
//
// They drive restoreAllFiles directly (rather than the full Restore, which
// downloads and decrypts the metadata database from storage) so a snapshot
// database with adversarial rows can be handed to the restore loop without
// the surrounding blob/storage machinery. Directory and symlink entries
// carry no chunks, so no blobs are needed.
// containmentDirMode marks a File row as a directory for the restore loop.
const containmentDirMode = uint32(os.ModeDir | 0o755)
// newContainmentVaultik builds the minimal Vaultik needed to run
// restoreAllFiles against fs.
func newContainmentVaultik(ctx context.Context, fs afero.Fs) *Vaultik {
v := &Vaultik{
Config: &config.Config{
BlobSizeLimit: config.Size(10 * 1024 * 1024),
},
Fs: fs,
Stdout: io.Discard,
Stderr: io.Discard,
UI: ui.NewWithColor(io.Discard, false),
}
v.SetContext(ctx)
return v
}
// makeFiles inserts the given rows into a fresh in-memory snapshot database
// and returns them (with IDs assigned) plus the repositories.
func makeFiles(
ctx context.Context, t *testing.T, rows []*database.File,
) ([]*database.File, *database.Repositories) {
t.Helper()
db, err := database.New(ctx, filepath.Join(t.TempDir(), "index.sqlite"))
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db)
for _, f := range rows {
require.NoError(t, repos.Files.Create(ctx, nil, f))
}
return rows, repos
}
func TestRestoreRejectsPathTraversal(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
tests := []struct {
name string
// rows are inserted in order; the escape entry is restored after
// any entry it depends on (the symlink case needs its link first).
rows func(outsideDir string) []*database.File
// escaped is the path, outside the target, that must not appear.
escaped func(tempDir, outsideDir string) string
}{
{
name: "relative dotdot",
rows: func(_ string) []*database.File {
return []*database.File{{
Path: "../escaped-relative",
Mode: containmentDirMode,
}}
},
escaped: func(tempDir, _ string) string {
return filepath.Join(tempDir, "escaped-relative")
},
},
{
name: "absolute with dotdot",
rows: func(_ string) []*database.File {
return []*database.File{{
Path: "/a/../../escaped-absolute",
Mode: containmentDirMode,
}}
},
escaped: func(tempDir, _ string) string {
return filepath.Join(tempDir, "escaped-absolute")
},
},
{
name: "child through symlink",
rows: func(outsideDir string) []*database.File {
return []*database.File{
// Restored first: an in-target symlink pointing out.
{Path: "linkdir", LinkTarget: types.FilePath(outsideDir)},
// Restored second: a child written through that link.
{Path: "linkdir/child", Mode: containmentDirMode},
}
},
escaped: func(_, outsideDir string) string {
return filepath.Join(outsideDir, "child")
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
fs := afero.NewOsFs()
tempDir := t.TempDir()
targetDir := filepath.Join(tempDir, "target")
outsideDir := filepath.Join(tempDir, "outside")
require.NoError(t, fs.MkdirAll(outsideDir, 0o755))
rows, repos := makeFiles(ctx, t, tc.rows(outsideDir))
v := newContainmentVaultik(ctx, fs)
_, err := v.restoreAllFiles(rows, repos,
&RestoreOptions{TargetDir: targetDir}, nil, nil)
require.ErrorIs(t, err, errRestorePathEscapesTarget)
escaped := tc.escaped(tempDir, outsideDir)
_, statErr := os.Lstat(escaped)
require.Truef(t, os.IsNotExist(statErr),
"restore wrote outside the target at %s", escaped)
})
}
}
// TestRestoreAllowsSymlinkPointingOutsideTree confirms the guard does not
// over-block: an honest snapshot may contain a symlink whose target lies
// outside the restored tree, and it must still be restored verbatim.
func TestRestoreAllowsSymlinkPointingOutsideTree(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
fs := afero.NewOsFs()
tempDir := t.TempDir()
targetDir := filepath.Join(tempDir, "target")
linkTarget := filepath.Join(tempDir, "outside", "data")
rows, repos := makeFiles(ctx, t, []*database.File{
{Path: "goodlink", LinkTarget: types.FilePath(linkTarget), MTime: time.Unix(0, 0)},
})
v := newContainmentVaultik(ctx, fs)
_, err := v.restoreAllFiles(rows, repos,
&RestoreOptions{TargetDir: targetDir}, nil, nil)
require.NoError(t, err)
got, err := os.Readlink(filepath.Join(targetDir, "goodlink"))
require.NoError(t, err)
require.Equal(t, linkTarget, got)
}