Skip to content

Commit e532b98

Browse files
committed
Merge remote-tracking branch 'origin/feat/backup-phase0a-redis-simple' into feat/backup-phase0a-dynamodb
2 parents 9076ef9 + ae3f0d9 commit e532b98

2 files changed

Lines changed: 65 additions & 7 deletions

File tree

internal/backup/open_nofollow_unix.go

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,27 @@ import (
1010
cockroachdberr "github.com/cockroachdb/errors"
1111
)
1212

13-
// openSidecarFile opens path for write with truncate+create semantics
14-
// and atomically refuses symlinks. The unix build leans on
15-
// syscall.O_NOFOLLOW so the kernel returns ELOOP if path is a
16-
// symlink, eliminating the TOCTOU race a separate Lstat-then-Create
17-
// pattern would have. The Windows build (open_nofollow_windows.go)
18-
// implements this with the best alternative available there.
13+
// openSidecarFile opens path for write while refusing both symlink and
14+
// hard-link clobber attacks.
15+
//
16+
// - O_NOFOLLOW makes the kernel return ELOOP atomically if the path
17+
// is a symbolic link — closing the TOCTOU race a separate
18+
// Lstat-then-Create pattern would have.
19+
// - To also refuse hard links to files outside the dump tree, we
20+
// open WITHOUT O_TRUNC, fstat() the descriptor to check the
21+
// link count, and only call Truncate(0) if Nlink == 1. An
22+
// adversary that pre-created strings_ttl.jsonl as a hard link
23+
// to /etc/passwd (or any other writable file outside the dump
24+
// tree) would otherwise see the inode truncated on
25+
// openSidecarFile despite the symlink guard. Codex P2 round 9.
26+
//
27+
// The Windows build (open_nofollow_windows.go) keeps the simpler
28+
// Lstat-then-OpenFile guard because Windows's
29+
// SeCreateSymbolicLinkPrivilege already raises the bar for the
30+
// equivalent attack.
1931
func openSidecarFile(path string) (*os.File, error) {
20-
const flag = os.O_WRONLY | os.O_CREATE | os.O_TRUNC | syscall.O_NOFOLLOW
32+
// Note: NO O_TRUNC here — we truncate after the link-count check.
33+
const flag = os.O_WRONLY | os.O_CREATE | syscall.O_NOFOLLOW
2134
f, err := os.OpenFile(path, flag, 0o600) //nolint:gosec,mnd // path is composed from output-root + fixed file name; 0600 is the standard owner-only mode
2235
if err != nil {
2336
if errors.Is(err, syscall.ELOOP) {
@@ -26,5 +39,19 @@ func openSidecarFile(path string) (*os.File, error) {
2639
}
2740
return nil, cockroachdberr.WithStack(err)
2841
}
42+
info, err := f.Stat()
43+
if err != nil {
44+
_ = f.Close()
45+
return nil, cockroachdberr.WithStack(err)
46+
}
47+
if sysStat, ok := info.Sys().(*syscall.Stat_t); ok && sysStat.Nlink > 1 {
48+
_ = f.Close()
49+
return nil, cockroachdberr.WithStack(cockroachdberr.Newf(
50+
"backup: refusing to overwrite hard-linked file at %s (nlink=%d)", path, sysStat.Nlink))
51+
}
52+
if err := f.Truncate(0); err != nil {
53+
_ = f.Close()
54+
return nil, cockroachdberr.WithStack(err)
55+
}
2956
return f, nil
3057
}

internal/backup/redis_string_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,37 @@ func TestRedisDB_NewFormatStringTTLNotDuplicatedByScanIndex(t *testing.T) {
199199
assertTTLSidecar(t, filepath.Join(root, "redis", "db_0", "strings_ttl.jsonl"), "expiring", fixedExpireMs)
200200
}
201201

202+
// TestRedisDB_OpenJSONLRefusesHardLinkClobber is the regression for
203+
// Codex P2 round 9: O_NOFOLLOW only blocks symlinks; an adversary
204+
// who can write the output directory could pre-create
205+
// strings_ttl.jsonl as a hard link to a file outside the dump tree
206+
// (e.g. /etc/passwd) and the open's O_TRUNC would clobber that
207+
// inode. openSidecarFile now opens WITHOUT O_TRUNC, fstat()s the
208+
// descriptor, refuses if Nlink > 1, and only calls Truncate(0) on
209+
// the verified-single-link case.
210+
func TestRedisDB_OpenJSONLRefusesHardLinkClobber(t *testing.T) {
211+
t.Parallel()
212+
db, root := newRedisDB(t)
213+
dir := filepath.Join(root, "redis", "db_0")
214+
if err := os.MkdirAll(dir, 0o755); err != nil {
215+
t.Fatal(err)
216+
}
217+
bait := filepath.Join(root, "bait-hardlink")
218+
if err := os.WriteFile(bait, []byte("stay-out"), 0o600); err != nil {
219+
t.Fatal(err)
220+
}
221+
if err := os.Link(bait, filepath.Join(dir, redisStringsTTLFile)); err != nil {
222+
t.Fatal(err)
223+
}
224+
err := db.HandleString([]byte("k"), encodeNewStringValue(t, []byte("v"), fixedExpireMs))
225+
if err == nil || !strings.Contains(err.Error(), "refusing to overwrite hard-linked file") {
226+
t.Fatalf("expected hard-link refusal error from openSidecarFile, got %v", err)
227+
}
228+
if got, _ := os.ReadFile(bait); string(got) != "stay-out" { //nolint:gosec // test path
229+
t.Fatalf("bait file written through hard link: %q", got)
230+
}
231+
}
232+
202233
// TestRedisDB_OpenJSONLRefusesSymlinkOverwrite is the regression for Codex
203234
// P2 round 5 + P1 round 6: openJSONL must atomically refuse to follow
204235
// symlinks. The earlier Lstat-then-Create variant left a TOCTOU window

0 commit comments

Comments
 (0)