file_sync: skip nested symlinks during recursive copy

A user who sets `[[file_sync]] host = "~/.aws"` (per the README's
own example) can unintentionally copy files from outside that
directory if .aws contains symlinks. copyHostDir used os.Stat
during recursion, which transparently follows: a symlink to a
credential dir elsewhere would be recursed into, materialising
unrelated secrets inside the guest. For credential trees that's
an avoidable sprawl vector.

Switched copyHostDir's per-entry probe from os.Stat to os.Lstat
and added a default skip-with-warning branch for ModeSymlink.
Files and dirs at the SAME level copy as before; symlinks (both
file and directory flavours) surface a "file_sync skipped
symlink (would escape the requested tree)" warn log and are
otherwise omitted.

Top-level entry paths still follow — the Stat in runFileSync is
unchanged. The user explicitly named that path, so resolving
"~/.aws" through a symlink out of $HOME is on them.

Tests:
- TestRunFileSyncSkipsNestedSymlinks — builds a synced dir with
  both a file symlink and a directory symlink pointing outside
  the tree; asserts real files copy, symlinks do not materialise
  anywhere in the guest mount, and each skipped symlink surfaces
  a warn log entry.

README updated with a one-line note about the skip behaviour so
users know to expect it rather than chasing "why didn't my file
show up."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-23 14:11:58 -03:00
parent caa6a2b996
commit 1850904d9c
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 127 additions and 8 deletions

View file

@ -1177,6 +1177,105 @@ func TestRunFileSyncCopiesDirectoryRecursively(t *testing.T) {
}
}
// TestRunFileSyncSkipsNestedSymlinks pins the anti-sprawl contract:
// a symlink INSIDE a synced directory is not followed, even if the
// target holds real files. Without this, a user syncing ~/.aws with
// a ~/.aws/session -> ~/other-creds symlink would copy the unrelated
// creds into the guest. Top-level entries (the path the user
// literally named) still follow, because they explicitly asked for
// that path.
func TestRunFileSyncSkipsNestedSymlinks(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
// Target the user DID NOT name — lives outside the synced tree.
outsideDir := filepath.Join(homeDir, "other-creds")
if err := os.MkdirAll(outsideDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outsideDir, "leaked.txt"), []byte("must-not-escape"), 0o600); err != nil {
t.Fatal(err)
}
// The synced directory.
srcDir := filepath.Join(homeDir, ".aws")
if err := os.MkdirAll(srcDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(srcDir, "credentials"), []byte("access"), 0o600); err != nil {
t.Fatal(err)
}
// File symlink inside .aws pointing OUT of the tree.
if err := os.Symlink(filepath.Join(outsideDir, "leaked.txt"), filepath.Join(srcDir, "session")); err != nil {
t.Skipf("symlink unsupported on this filesystem: %v", err)
}
// Directory symlink inside .aws pointing OUT of the tree — must
// not be recursed into.
if err := os.Symlink(outsideDir, filepath.Join(srcDir, "linked-dir")); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
logger, _, err := newDaemonLogger(&buf, "info")
if err != nil {
t.Fatal(err)
}
workDisk := t.TempDir()
d := &Daemon{
runner: &filesystemRunner{t: t},
logger: logger,
config: model.DaemonConfig{
FileSync: []model.FileSyncEntry{
{Host: "~/.aws", Guest: "~/.aws"},
},
},
}
wireServices(d)
vm := testVM("sync-symlink", "image", "172.16.0.76")
vm.Runtime.WorkDiskPath = workDisk
if err := d.ws.runFileSync(context.Background(), &vm); err != nil {
t.Fatalf("runFileSync: %v", err)
}
// The real file inside the tree must copy.
creds, err := os.ReadFile(filepath.Join(workDisk, ".aws", "credentials"))
if err != nil {
t.Fatalf("credentials not copied: %v", err)
}
if string(creds) != "access" {
t.Fatalf("credentials = %q, want access", creds)
}
// Neither the file symlink nor anything reached through the
// directory symlink should have been materialised in the guest
// path.
for _, shouldNotExist := range []string{
filepath.Join(workDisk, ".aws", "session"),
filepath.Join(workDisk, ".aws", "linked-dir"),
filepath.Join(workDisk, ".aws", "linked-dir", "leaked.txt"),
} {
if _, err := os.Stat(shouldNotExist); !os.IsNotExist(err) {
t.Fatalf("symlinked path %s was materialised in guest tree (stat err = %v); secret leakage path open", shouldNotExist, err)
}
}
// Each skipped symlink must be warned.
entries := parseLogEntries(t, buf.Bytes())
for _, want := range []string{
filepath.Join(srcDir, "session"),
filepath.Join(srcDir, "linked-dir"),
} {
if !hasLogEntry(entries, map[string]string{
"msg": "file_sync skipped symlink (would escape the requested tree)",
"vm_name": vm.Name,
"host_path": want,
}) {
t.Fatalf("expected warn log for skipped symlink %s; got %v", want, entries)
}
}
}
func TestCreateVMRejectsNonPositiveCPUAndMemory(t *testing.T) {
d := &Daemon{}
wireServices(d)