Skip to content

Commit 2bb8cd7

Browse files
committed
backup: PR810 r2 — codex r2 P1 (symlink escape on VerifyChecksums)
Codex r2 P1: after the r1 textual `..`-traversal guard, VerifyChecksums still followed symlinks when sha256-ing the joined path. A CHECKSUMS line `<hex> safe/file` could direct the verifier to hash a host file outside the dump root via a planted symlink at `safe/file` (or any intermediate parent). Fix: refuseSymlinkComponents walks the relative path segment by segment from root, os.Lstat()s each component, and returns ErrChecksumsSymlinkEscape the moment any component is a symlink. Catches BOTH terminal symlinks AND intermediate ones (a parent directory along the way being symlinked). The per-component walk is stricter than syscall O_NOFOLLOW (which only covers the terminal case) and avoids the filepath.EvalSymlinks resolution we are trying to refuse. Phase 0a dumps contain only regular files by design, so refusing every symlink found in the path is the right policy for the only honest dump shape. Tests: - TestVerifyChecksums_RejectsTerminalSymlink — plant a symlink at safe/escape pointing at a host file, add a CHECKSUMS line for it; assert ErrChecksumsSymlinkEscape. - TestVerifyChecksums_RejectsIntermediateSymlink — plant a symlink at safe/ (a directory link to an outside dir), reference safe/target.txt in CHECKSUMS; same assertion. - Both tests skip on Windows (os.Symlink requires SeCreateSymbolicLinkPrivilege there; the lstat-walk fix itself runs on every platform). Caller audit (CLAUDE.md "semantic-change → grep all callers"): - refuseSymlinkComponents is only called from verifyOneChecksumLine, which is only called from VerifyChecksums. VerifyChecksums has no production callers (test-only); CLI uses WriteChecksums. Self-review: 1. Data loss — none (read-side hardening). 2. Concurrency — none. 3. Performance — one extra Lstat per path component during verify; verify is offline-only and rare. 4. Data consistency — symlink-escape closed. 5. Test coverage — two regression tests (terminal + intermediate symlink); existing five-shape traversal-path table test still passes.
1 parent b60f468 commit 2bb8cd7

2 files changed

Lines changed: 136 additions & 3 deletions

File tree

internal/backup/checksums.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,9 @@ func VerifyChecksums(root string) error {
187187
// verifyOneChecksumLine handles a single CHECKSUMS line: parse,
188188
// path-validate, recompute, compare. Extracted from VerifyChecksums
189189
// so the parent stays under the project's cyclop ceiling and the
190-
// per-line failure modes (malformed shape, traversal, missing file,
191-
// mismatch) each surface as a distinct typed error.
190+
// per-line failure modes (malformed shape, traversal, symlink
191+
// escape, missing file, mismatch) each surface as a distinct
192+
// typed error.
192193
func verifyOneChecksumLine(root, line string, lineNum int) error {
193194
if line == "" {
194195
return nil
@@ -201,7 +202,11 @@ func verifyOneChecksumLine(root, line string, lineNum int) error {
201202
if err != nil {
202203
return errors.Wrapf(err, "line %d", lineNum)
203204
}
204-
got, err := sha256File(filepath.Join(root, cleaned))
205+
joined := filepath.Join(root, cleaned)
206+
if err := refuseSymlinkComponents(root, cleaned); err != nil {
207+
return errors.Wrapf(err, "line %d", lineNum)
208+
}
209+
got, err := sha256File(joined)
205210
if err != nil {
206211
return errors.Wrapf(err, "verifying %s", cleaned)
207212
}
@@ -211,6 +216,48 @@ func verifyOneChecksumLine(root, line string, lineNum int) error {
211216
return nil
212217
}
213218

219+
// refuseSymlinkComponents walks rel one path segment at a time
220+
// starting at root and rejects the first component that is a
221+
// symlink. This closes the codex r2 P1 symlink-escape attack on
222+
// PR #810: after the textual `..`-traversal guard runs,
223+
// `os.Open(filepath.Join(root, "safe/file"))` would still follow a
224+
// symlink at `safe/file` (or at any of its parent components) to
225+
// a file outside root and hash whatever it found. A Phase 0a dump
226+
// produced by the encoder writes only regular files, so refusing
227+
// every symlink found in the path keeps the verifier honest
228+
// regardless of which component an adversary planted the link in.
229+
//
230+
// The per-component walk catches BOTH terminal symlinks (where
231+
// `cleaned` itself names a symlink) AND intermediate ones (where
232+
// a parent directory along the way is a symlink). syscall
233+
// O_NOFOLLOW would only cover the terminal case, and
234+
// filepath.EvalSymlinks runs the resolution we are trying to
235+
// avoid in the first place.
236+
func refuseSymlinkComponents(root, rel string) error {
237+
cur := root
238+
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
239+
if seg == "" {
240+
continue
241+
}
242+
cur = filepath.Join(cur, seg)
243+
info, err := os.Lstat(cur)
244+
if err != nil {
245+
return errors.WithStack(err)
246+
}
247+
if info.Mode()&os.ModeSymlink != 0 {
248+
return errors.Wrapf(ErrChecksumsSymlinkEscape,
249+
"symlink in path: %s", cur)
250+
}
251+
}
252+
return nil
253+
}
254+
255+
// ErrChecksumsSymlinkEscape is returned by VerifyChecksums when a
256+
// CHECKSUMS line's resolved path traverses a symlink. Typed so a
257+
// caller branching on errors.Is can distinguish symlink-tampering
258+
// from textual `..`-traversal (ErrChecksumsPathTraversal).
259+
var ErrChecksumsSymlinkEscape = errors.New("backup: CHECKSUMS path traverses symlink")
260+
214261
// validateChecksumRelPath rejects CHECKSUMS rows whose path field
215262
// could escape the dump root. The acceptance rules:
216263
//

internal/backup/checksums_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"path/filepath"
7+
"runtime"
78
"strings"
89
"testing"
910

@@ -158,6 +159,91 @@ func TestVerifyChecksums_RejectsTraversalPath(t *testing.T) {
158159
}
159160
}
160161

162+
// TestVerifyChecksums_RejectsTerminalSymlink is the regression for
163+
// codex r2 P1 on PR #810: after the textual `..` guard, a
164+
// CHECKSUMS line `<hex> safe/file` whose `safe/file` is a symlink
165+
// pointing outside the dump root would have been silently
166+
// followed by os.Open, hashing the host-side target. The
167+
// per-component lstat walk in refuseSymlinkComponents catches
168+
// both terminal and intermediate symlinks.
169+
func TestVerifyChecksums_RejectsTerminalSymlink(t *testing.T) {
170+
if runtime.GOOS == "windows" {
171+
// Windows symlink creation requires
172+
// SeCreateSymbolicLinkPrivilege which is rarely available
173+
// to CI users. The lstat-walk fix runs on every platform;
174+
// the test only exercises Linux/macOS where os.Symlink is
175+
// unprivileged.
176+
t.Skip("symlink test requires unprivileged os.Symlink (skipped on Windows)")
177+
}
178+
t.Parallel()
179+
root := t.TempDir()
180+
outsideDir := t.TempDir()
181+
outsideFile := filepath.Join(outsideDir, "secret")
182+
mustWrite(t, outsideFile, []byte("host-only"))
183+
// Build a CHECKSUMS that lists `safe/file` with a digest that
184+
// would match the outside file's contents — proving the
185+
// verifier did NOT follow the symlink.
186+
mustWrite(t, filepath.Join(root, "safe", "honest"), []byte("dump-only"))
187+
if err := WriteChecksums(root); err != nil {
188+
t.Fatalf("WriteChecksums: %v", err)
189+
}
190+
// Now plant a symlink at safe/escape -> outsideFile and add an
191+
// entry for it to CHECKSUMS. WriteChecksums followed the link
192+
// during its own walk too, but VerifyChecksums must reject.
193+
link := filepath.Join(root, "safe", "escape")
194+
if err := os.Symlink(outsideFile, link); err != nil {
195+
t.Fatalf("symlink: %v", err)
196+
}
197+
// Append a CHECKSUMS entry for the symlinked file.
198+
checksumsPath := filepath.Join(root, CHECKSUMSFilename)
199+
body, err := os.ReadFile(checksumsPath) //nolint:gosec // test path
200+
if err != nil {
201+
t.Fatalf("read CHECKSUMS: %v", err)
202+
}
203+
// Use the digest of the outside file's actual content so the
204+
// shape-parse + path-traversal checks succeed; the symlink
205+
// guard is the first thing that must reject.
206+
body = append(body, []byte("e3d1ac1b09a9e88c0c12e9b1d31d6a92e2ec43cf2bda7bcd58e3a3b2c50e2dd2 safe/escape\n")...)
207+
if err := os.WriteFile(checksumsPath, body, 0o600); err != nil {
208+
t.Fatalf("write CHECKSUMS: %v", err)
209+
}
210+
err = VerifyChecksums(root)
211+
if err == nil {
212+
t.Fatalf("expected ErrChecksumsSymlinkEscape, got nil")
213+
}
214+
if !errors.Is(err, ErrChecksumsSymlinkEscape) {
215+
t.Fatalf("err = %v, want ErrChecksumsSymlinkEscape", err)
216+
}
217+
}
218+
219+
// TestVerifyChecksums_RejectsIntermediateSymlink pins that a
220+
// symlinked PARENT component is also caught — without the
221+
// per-component walk, refusing only the terminal symlink would
222+
// still leave the verifier vulnerable to a dump with a top-level
223+
// symlinked directory.
224+
func TestVerifyChecksums_RejectsIntermediateSymlink(t *testing.T) {
225+
if runtime.GOOS == "windows" {
226+
t.Skip("symlink test requires unprivileged os.Symlink")
227+
}
228+
t.Parallel()
229+
root := t.TempDir()
230+
outsideDir := t.TempDir()
231+
mustWrite(t, filepath.Join(outsideDir, "target.txt"), []byte("host-side"))
232+
// safe/ is a symlink to a directory outside the dump root.
233+
if err := os.Symlink(outsideDir, filepath.Join(root, "safe")); err != nil {
234+
t.Fatalf("symlink: %v", err)
235+
}
236+
body := "0000000000000000000000000000000000000000000000000000000000000000 safe/target.txt\n"
237+
mustWrite(t, filepath.Join(root, CHECKSUMSFilename), []byte(body))
238+
err := VerifyChecksums(root)
239+
if err == nil {
240+
t.Fatalf("expected ErrChecksumsSymlinkEscape, got nil")
241+
}
242+
if !errors.Is(err, ErrChecksumsSymlinkEscape) {
243+
t.Fatalf("err = %v, want ErrChecksumsSymlinkEscape", err)
244+
}
245+
}
246+
161247
// TestVerifyChecksums_StreamsLargeFile pins the bufio.Scanner path
162248
// — the previous implementation slurped the entire CHECKSUMS file
163249
// via os.ReadFile, which OOMs on large dump trees (gemini

0 commit comments

Comments
 (0)