|
9 | 9 | "math" |
10 | 10 | "os" |
11 | 11 | "path/filepath" |
12 | | - "syscall" |
| 12 | + "strings" |
13 | 13 |
|
14 | 14 | cockroachdberr "github.com/cockroachdb/errors" |
15 | 15 | ) |
@@ -300,18 +300,92 @@ func intToDecimal(v int) string { |
300 | 300 |
|
301 | 301 | // ensureDir runs MkdirAll once per directory and remembers the result |
302 | 302 | // in r.dirsCreated, so repeated calls on the hot path (one per blob |
303 | | -// record) collapse to a map lookup. |
| 303 | +// record) collapse to a map lookup. After MkdirAll succeeds, every |
| 304 | +// path component under outRoot is Lstat-checked: a pre-existing |
| 305 | +// directory symlink at e.g. `<outRoot>/redis/db_0/strings` would |
| 306 | +// otherwise let `os.MkdirAll` succeed without creating anything, |
| 307 | +// then steer subsequent writes outside outRoot. Codex P1 round 9. |
| 308 | +// |
| 309 | +// This guard is best-effort against TOCTOU (an adversary that can |
| 310 | +// swap a directory for a symlink between this check and the open |
| 311 | +// races us either way); it closes the much more common case of a |
| 312 | +// stale symlink left in the output tree from a prior run or |
| 313 | +// configuration mistake. Hardening to fully race-free traversal |
| 314 | +// would require os.Root / openat-style traversal, which is a |
| 315 | +// larger refactor for marginal benefit at this layer. |
304 | 316 | func (r *RedisDB) ensureDir(dir string) error { |
305 | 317 | if _, ok := r.dirsCreated[dir]; ok { |
306 | 318 | return nil |
307 | 319 | } |
308 | 320 | if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:mnd // 0755 == standard dir mode |
309 | 321 | return cockroachdberr.WithStack(err) |
310 | 322 | } |
| 323 | + if err := assertNoSymlinkAncestors(r.outRoot, dir); err != nil { |
| 324 | + return err |
| 325 | + } |
311 | 326 | r.dirsCreated[dir] = struct{}{} |
312 | 327 | return nil |
313 | 328 | } |
314 | 329 |
|
| 330 | +// assertNoSymlinkAncestors walks every path component from rootDir up |
| 331 | +// to (and including) target, Lstat'ing each. Returns ErrSymlinkInPath |
| 332 | +// if any component is a symbolic link. rootDir itself is also |
| 333 | +// Lstat'd: if the dump root is a symlink to somewhere else, all bets |
| 334 | +// are off. |
| 335 | +func assertNoSymlinkAncestors(rootDir, target string) error { |
| 336 | + cleanRoot := filepath.Clean(rootDir) |
| 337 | + cleanTarget := filepath.Clean(target) |
| 338 | + rel, err := filepath.Rel(cleanRoot, cleanTarget) |
| 339 | + if err != nil { |
| 340 | + return cockroachdberr.WithStack(err) |
| 341 | + } |
| 342 | + // Defensive: if target escapes rootDir (which the callers' path |
| 343 | + // construction already prevents), refuse rather than silently |
| 344 | + // validate an unrelated path. |
| 345 | + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { |
| 346 | + return cockroachdberr.WithStack(cockroachdberr.Newf( |
| 347 | + "backup: target %s escapes root %s", target, rootDir)) |
| 348 | + } |
| 349 | + if err := lstatRefuseSymlink(cleanRoot); err != nil { |
| 350 | + return err |
| 351 | + } |
| 352 | + cur := cleanRoot |
| 353 | + if rel == "." { |
| 354 | + return nil |
| 355 | + } |
| 356 | + for _, seg := range strings.Split(rel, string(filepath.Separator)) { |
| 357 | + if seg == "" { |
| 358 | + continue |
| 359 | + } |
| 360 | + cur = filepath.Join(cur, seg) |
| 361 | + if err := lstatRefuseSymlink(cur); err != nil { |
| 362 | + return err |
| 363 | + } |
| 364 | + } |
| 365 | + return nil |
| 366 | +} |
| 367 | + |
| 368 | +// lstatRefuseSymlink returns an error wrapped over the underlying |
| 369 | +// stat call when path is a symbolic link. A non-existent path is |
| 370 | +// treated as fine: the caller has just MkdirAll'd it, so a missing |
| 371 | +// component is impossible — but if it were, the symlink-check |
| 372 | +// contract is "if it exists, it must not be a symlink", and we |
| 373 | +// return nil rather than synthesize a false positive. |
| 374 | +func lstatRefuseSymlink(path string) error { |
| 375 | + info, err := os.Lstat(path) |
| 376 | + if err != nil { |
| 377 | + if os.IsNotExist(err) { |
| 378 | + return nil |
| 379 | + } |
| 380 | + return cockroachdberr.WithStack(err) |
| 381 | + } |
| 382 | + if info.Mode()&os.ModeSymlink != 0 { |
| 383 | + return cockroachdberr.WithStack(cockroachdberr.Newf( |
| 384 | + "backup: refusing to traverse symlinked ancestor at %s", path)) |
| 385 | + } |
| 386 | + return nil |
| 387 | +} |
| 388 | + |
315 | 389 | func (r *RedisDB) writeBlob(subdir string, userKey, value []byte) error { |
316 | 390 | encoded := EncodeSegment(userKey) |
317 | 391 | if err := r.recordIfFallback(encoded, userKey); err != nil { |
@@ -546,11 +620,14 @@ func IsBlobAtomicWriteRetriable(err error) bool { |
546 | 620 |
|
547 | 621 | // IsBlobAtomicWriteOutOfSpace reports whether err from writeFileAtomic |
548 | 622 | // (or any os.File write the master pipeline issues) was driven by a |
549 | | -// full disk. Tested via syscall.ENOSPC + os.PathError unwrap, which |
550 | | -// matches what os.File.Write returns on POSIX and Windows. |
| 623 | +// full disk. The platform-specific error codes (POSIX ENOSPC vs. |
| 624 | +// Windows ERROR_DISK_FULL / ERROR_HANDLE_DISK_FULL) live in |
| 625 | +// disk_full_{unix,windows}.go so retry/alarm logic in callers |
| 626 | +// classifies disk-full uniformly across operating systems |
| 627 | +// (Codex P2 round 9). |
551 | 628 | func IsBlobAtomicWriteOutOfSpace(err error) bool { |
552 | 629 | if err == nil { |
553 | 630 | return false |
554 | 631 | } |
555 | | - return errors.Is(err, syscall.ENOSPC) |
| 632 | + return isDiskFullError(err) |
556 | 633 | } |
0 commit comments