Symlink swaps, path traversal, TOCTOU races, zip slip, partial writes, and permission leaks — practical defensive patterns for Go CLIs that read and write user-controlled paths.
Your Go CLI reads a config file, writes a report, creates a directory. Every one of those operations is an attack surface if the path comes from the user.
A user runs stave apply --observations ./data. What if ./data is a symlink to /etc/passwd? What if a snapshot filename contains ../../.ssh/authorized_keys? What if the output directory is swapped to a symlink between your pre-check and your write?
These aren't theoretical. They're the vulnerability classes that CVEs are made of: symlink following (CWE-61), path traversal (CWE-22), TOCTOU races (CWE-367), and zip slip (CWE-23).
Here are 7 filesystem attacks we found and fixed in a Go security CLI, with the exact defensive code for each.
1. Symlink Following — The Classic
The Attack
The user creates a symlink: ln -s /etc/shadow ./observations/snapshot.json. Your tool reads "snapshot.json" and follows the symlink to /etc/shadow. Or worse: the user creates ln -s /tmp ./output, and your tool writes its report into /tmp where any process can read it.
The Defense: CheckSymlinkSafety
// CheckSymlinkSafety verifies that neither the target path nor any of its
// ancestor directories are symlinks. Walks up to 16 parent levels.
func CheckSymlinkSafety(path string) error {
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("resolve absolute path: %w", err)
}
current := absPath
for i := 0; i < 16; i++ {
info, err := os.Lstat(current)
if err != nil {
if os.IsNotExist(err) {
current = filepath.Dir(current)
continue
}
return fmt.Errorf("lstat %s: %w", current, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink detected at %s", current)
}
if current == filepath.Dir(current) {
return nil // reached filesystem root
}
current = filepath.Dir(current)
}
return fmt.Errorf("path too deep: exceeded 16 ancestor checks")
}
Notes:
- Uses
Lstat(notStat) —Statfollows symlinks and would miss them entirely. - Walks ancestors — a symlink at
/home/user/projectis just as dangerous as one at the target path. - Bounded walk (16 levels) — prevents infinite loops on pathological paths.
- Returns error on exhaustion — the original version silently returned
nilafter 16 iterations, treating deep paths as safe. That was a bug.
The Bug We Found
The original CheckSymlinkSafety had a loop that walked up to 16 parent directories. If it exhausted the loop without finding an existing ancestor (on a very deep path), it returned nil — success. This meant a path with 17+ components would bypass the symlink check entirely. Fixed by returning an error after the loop exits.
2. TOCTOU Race — The Subtle One
The Attack
Thread 1 (your tool): Thread 2 (attacker):
1. Lstat(path) → not a symlink
2. rm path; ln -s /etc/shadow path
3. os.Create(path) → writes to /etc/shadow
Between your check (step 1) and your use (step 3), the attacker swaps the path. This is the Time-of-Check-Time-of-Use (TOCTOU) race.
The Defense: Post-Open Handle Verification
// verifyHandle confirms the opened file handle matches the pre-open Lstat.
// Detects symlink swaps between Lstat and Open.
func verifyHandle(f *os.File, path string) error {
// Get the inode of what we actually opened
handleInfo, err := f.Stat()
if err != nil {
return fmt.Errorf("stat opened handle: %w", err)
}
// Get the inode of what the path points to NOW (without following symlinks)
pathInfo, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("lstat path after open: %w", err)
}
// If the path is now a symlink, or points to a different inode,
// something changed between our pre-check and our open.
if pathInfo.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("path became a symlink after open")
}
if !os.SameFile(handleInfo, pathInfo) {
return fmt.Errorf("file handle does not match path (possible TOCTOU)")
}
return nil
}
The pattern: check before open, verify after open, close if mismatch.
func SafeCreateFile(path string, opts WriteOptions) (*os.File, error) {
if !opts.AllowSymlink {
if err := CheckSymlinkSafety(path); err != nil {
return nil, err
}
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, opts.Perm)
if err != nil {
return nil, err
}
// Post-open verification closes the TOCTOU window
if !opts.AllowSymlink {
if err := verifyHandle(f, path); err != nil {
f.Close()
os.Remove(path)
return nil, fmt.Errorf("TOCTOU detected: %w", err)
}
}
return f, nil
}
The os.O_EXCL flag is the first defense — it atomically fails if the file already exists. The verifyHandle is the second defense — it catches the case where the path was swapped after the pre-check but before the kernel completed the open() syscall.
3. Path Traversal — The Dangerous Input
The Attack
A user provides a relative path with .. components: --output ../../.ssh/authorized_keys. Or a JSON file contains a filename like ../../../etc/cron.d/backdoor. After filepath.Join(root, userPath), the result escapes the intended directory.
The Defense: JoinWithinRoot
// JoinWithinRoot joins a root directory and a relative path, then verifies
// the result doesn't escape the root. Rejects absolute paths and
// separator-prefixed relative paths.
func JoinWithinRoot(root, relPath string) (string, error) {
if filepath.IsAbs(relPath) {
return "", fmt.Errorf("absolute path not allowed: %q", relPath)
}
cleaned := filepath.Clean(relPath)
// Reject separator-prefixed paths (Windows: "\dir" is drive-relative)
if strings.HasPrefix(cleaned, string(filepath.Separator)) {
return "", fmt.Errorf("separator-prefixed path not allowed: %q", relPath)
}
joined := filepath.Join(root, cleaned)
absJoined, err := filepath.Abs(joined)
if err != nil {
return "", err
}
absRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
// Verify the joined path is within root
if !strings.HasPrefix(absJoined, absRoot+string(filepath.Separator)) && absJoined != absRoot {
return "", fmt.Errorf("path %q escapes root %q", relPath, root)
}
return absJoined, nil
}
The separator-prefix check is critical for Windows: \dir is a drive-relative path that filepath.IsAbs returns false for, but filepath.Join("C:\root", "\dir") produces C:\dir — escaping the root.
Where to Apply It
Every place your code joins a user-controlled path with a root directory:
// Archive operations: user-controlled filenames from observation snapshots
dst, err := fsutil.JoinWithinRoot(archiveDir, entry.Name)
if err != nil {
return fmt.Errorf("path containment check: %w", err)
}
// Delete operations: user-controlled paths from prune commands
target, err := fsutil.JoinWithinRoot(observationsDir, filename)
if err != nil {
return fmt.Errorf("path containment check: %w", err)
}
4. SafeMkdirAll — The Directory Creation Trap
The Attack
os.MkdirAll("/home/user/project/output/reports", 0o700) creates all intermediate directories. What if /home/user/project/output is a symlink to /etc? MkdirAll follows it and creates /etc/reports — a directory you never intended to create.
The Defense: Component-by-Component Creation
// SafeMkdirAll creates directories one component at a time, checking each
// for symlinks before creating the next. No TOCTOU window between check
// and create because each component is Lstat'd immediately before Mkdir.
func SafeMkdirAll(path string, opts WriteOptions) error {
absPath, err := filepath.Abs(path)
if err != nil {
return err
}
// Split into components and create each one
components := splitPath(absPath)
current := string(filepath.Separator)
for _, comp := range components {
current = filepath.Join(current, comp)
info, err := os.Lstat(current)
if err == nil {
// Path exists — verify it's a directory, not a symlink
if info.Mode()&os.ModeSymlink != 0 && !opts.AllowSymlink {
return fmt.Errorf("symlink at intermediate component: %s", current)
}
if !info.IsDir() {
return fmt.Errorf("not a directory: %s", current)
}
continue
}
if !os.IsNotExist(err) {
return fmt.Errorf("lstat %s: %w", current, err)
}
// Doesn't exist — create it
if mkErr := os.Mkdir(current, opts.Perm); mkErr != nil && !os.IsExist(mkErr) {
return fmt.Errorf("mkdir %s: %w", current, mkErr)
}
}
return nil
}
The difference from os.MkdirAll: each component is Lstat'd right before creation. There's no window between "check if /home/user/project/output is safe" and "create /home/user/project/output/reports" — they happen in the same loop iteration.
5. Atomic Writes — Crash Safety
The Attack
Not an attack — a crash. Your tool writes a report file. The process is killed mid-write. The user now has a partial, corrupt report file that looks valid (the filename exists) but contains truncated JSON.
The Defense: Temp File + Sync + Rename
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".stave-*.tmp")
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
tmpPath := tmp.Name()
// Cleanup on any error
defer func() {
if tmpPath != "" {
os.Remove(tmpPath)
}
}()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write: %w", err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("sync: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("rename: %w", err)
}
tmpPath = "" // prevent deferred cleanup
return nil
}
The temp file is created in the same directory as the target so Rename is atomic (same-filesystem). Sync ensures data is flushed to disk before the rename. If the process crashes at any point:
- During write: temp file exists, target doesn't — no corruption
- During sync: temp file exists, target doesn't — no corruption
- After rename: target is the complete file — success
6. File Permissions — The Silent Leak
The Attack
Your tool writes a report containing S3 bucket policies, IAM configurations, and security findings. The file is created with 0o644 (world-readable). Any user on the system can read it. In a shared server or CI environment, that's a data leak.
The Defense
const (
// Sensitive files: only the owner can read/write
permFile = 0o600
// Directories: only the owner can list/traverse
permDir = 0o700
)
Every WriteFile, CreateTemp, MkdirAll, and OpenFile call uses restrictive permissions by default. The WriteOptions struct makes this explicit:
type WriteOptions struct {
Perm fs.FileMode // default: 0o600
Overwrite bool
AllowSymlink bool
}
The gosec linter (G306) catches 0o644 in WriteFile calls. We fixed every instance across the codebase — generated docs, state files, log files, marker files.
7. Archive Operations — Where Everything Combines
The Attack
Archive operations (moving snapshot files to an archive directory) combine all the previous attacks. The attacker controls filenames in the observation directory. A filename like ../../.bashrc escapes the archive directory. A symlink in the archive path redirects writes. A concurrent modification swaps the source between validation and copy.
The Defense: Validate-All-Then-Execute
func ApplyArchive(input ArchiveInput) ([]ArchivedFile, error) {
absArchive, _ := filepath.Abs(input.ArchiveDir)
archivePrefix := absArchive + string(filepath.Separator)
// Phase 1: Validate ALL destination paths before ANY moves
for _, entry := range input.Entries {
absDst, _ := filepath.Abs(filepath.Join(input.ArchiveDir, entry.Name))
if absDst != absArchive && !strings.HasPrefix(absDst, archivePrefix) {
return nil, fmt.Errorf("path escapes archive: %s", entry.Name)
}
}
// Phase 2: Execute moves with TOCTOU-safe source verification
for _, entry := range input.Entries {
if err := safeMove(entry.Source, entry.Dest, input.Overwrite); err != nil {
return nil, err
}
}
return results, nil
}
The two-phase approach prevents a subtle attack: if validation and execution are interleaved, a path that passes validation could be swapped before execution. By validating all paths first, we ensure the entire operation is consistent.
The safeMove function opens the source with openVerifiedSource (TOCTOU-safe Lstat+Open+Verify), then uses os.Link for atomic no-overwrite placement or crossDeviceMove (temp+rename) for cross-filesystem moves.
The Defensive Utility Belt
All seven patterns are collected in a single package (internal/platform/fsutil/io.go) with a consistent API:
| Function | Protects Against |
|---|---|
CheckSymlinkSafety(path) |
Symlink following |
verifyHandle(f, path) |
TOCTOU race |
JoinWithinRoot(root, rel) |
Path traversal |
SafeMkdirAll(path, opts) |
Intermediate symlinks |
SafeCreateFile(path, opts) |
Overwrite + symlink + TOCTOU |
WriteFileAtomic(path, data, perm) |
Partial writes |
ReadFileLimited(path) |
File size bombs |
Each function is independently testable with dedicated negative tests: symlinks in middle components, deep paths exceeding walk limits, separator-prefixed paths on Windows, handle verification after swap, and cross-device move with permission preservation.
When NOT to Defend
Not every file operation needs the full defensive stack:
// Reading a file you control (embedded in the binary): no defense needed
data, _ := embeddedFS.ReadFile("schema.json")
// Writing to a path the user explicitly chose: defend
err := fsutil.SafeWriteFile(userPath, data, fsutil.WriteOptions{Perm: 0o600})
Apply defenses when:
- The path comes from the user (CLI flag, config file, environment variable)
- The filename comes from data (JSON fields, YAML keys, observation snapshots)
- The operation creates directories (intermediate components can be symlinks)
- The output contains sensitive data (security findings, credentials, policies)
Skip defenses when:
- The path is hardcoded or embedded
- The file is read-only and public (help text, version info)
- Performance matters and the threat model doesn't include local attackers
These 7 filesystem security patterns were implemented in Stave, an offline configuration safety evaluator. The defensive utilities in internal/platform/fsutil/io.go handle symlink protection, path containment, TOCTOU closure, atomic writes, and permission enforcement for all user-controlled file operations.
Top comments (0)