Skip to content

Commit d016ea7

Browse files
committed
backup: refuse dot-segment scratch paths in HandleBlob (PR #718, round 9)
Codex P1 round 11 (commit 9a63e32): `HandleBlob` composed scratch paths with `filepath.Join(s.scratchRoot, EncodeSegment([]byte(bucket)), EncodeSegment([]byte(object)))`. EncodeSegment uses the RFC3986 unreserved set (ALPHA/DIGIT/-/./_) — `/` is percent-encoded but `.` is preserved, so the literal segment `..` survives unchanged. A crafted `bucket=".."` and/or `object=".."` would resolve to `<scratchRoot>/../...`, letting `writeFileAtomic` land outside the decoder's controlled directory before `safeJoinUnderRoot` runs at output time. Add `scratchDirForBlob` which rejects `.` / `..` / "" bucket and object literals at the encoder boundary so the spill-to-disk step inherits the same containment invariant the final output path enforces. Apply the same guard to `flushOrphanObject` which shared the failure mode under `--include-orphans`. (Multi-segment dot keys like `a/../b` continue to be caught at Finalize via `safeJoinUnderRoot` because EncodeSegment keeps the whole key in one filename segment that splits cleanly there.) Tests: - TestS3_HandleBlobRejectsScratchEscape: 5 sub-cases covering bucket/object/both variants of `.`/`..` literals. - TestS3_DotSegmentObjectKeyRejected updated to allow either HandleBlob or Finalize to surface ErrS3MalformedKey, since sole-dot keys are now caught earlier.
1 parent 9a63e32 commit d016ea7

2 files changed

Lines changed: 107 additions & 4 deletions

File tree

internal/backup/s3.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,14 +411,25 @@ func (s *S3Encoder) HandleObjectManifest(key, value []byte) error {
411411

412412
// HandleBlob spills a !s3|blob| record to a per-chunk scratch file
413413
// and registers it under the (bucket, object, gen, uploadID, partNo,
414-
// chunkNo, partVersion) routing key.
414+
// chunkNo, partVersion) routing key. EncodeSegment percent-encodes
415+
// `/` so a multi-segment object key like `../../tmp/pwn` collapses
416+
// into one filename, but a literal `..` (or `.`) survives unchanged
417+
// because both `.` chars are RFC3986-unreserved. Without explicit
418+
// validation, a crafted bucket+object pair like `bucket="..",
419+
// object=".."` would resolve to filepath.Join(scratchRoot, "..",
420+
// "..") = the parent of scratchRoot, letting writeFileAtomic
421+
// land outside the decoder's controlled directory before
422+
// safeJoinUnderRoot ever runs at output time. Codex P1 round 11.
415423
func (s *S3Encoder) HandleBlob(key, value []byte) error {
416424
bucket, gen, object, uploadID, partNo, chunkNo, partVersion, ok := s3keys.ParseBlobKey(key)
417425
if !ok {
418426
return errors.Wrapf(ErrS3MalformedKey, "blob key: %q", key)
419427
}
420428
st := s.objectState(bucket, gen, object)
421-
dir := filepath.Join(s.scratchRoot, EncodeSegment([]byte(bucket)), EncodeSegment([]byte(object)))
429+
dir, err := scratchDirForBlob(s.scratchRoot, bucket, object)
430+
if err != nil {
431+
return err
432+
}
422433
if !st.scratchDirCreated {
423434
if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:mnd // 0755 == standard dir mode
424435
return errors.WithStack(err)
@@ -434,6 +445,27 @@ func (s *S3Encoder) HandleBlob(key, value []byte) error {
434445
return nil
435446
}
436447

448+
// scratchDirForBlob builds the per-(bucket,object) scratch path and
449+
// validates it stays under scratchRoot. A bucket or object name of
450+
// `.` / `..` would let `filepath.Join` resolve out of scratchRoot
451+
// before anything else gets a chance to refuse the key. Reject the
452+
// dot-component case at the encoder boundary so the spill-to-disk
453+
// step inherits the same containment invariant the final output
454+
// path enforces via safeJoinUnderRoot.
455+
func scratchDirForBlob(scratchRoot, bucket, object string) (string, error) {
456+
for _, seg := range [...]string{bucket, object} {
457+
switch seg {
458+
case ".", "..":
459+
return "", errors.Wrapf(ErrS3MalformedKey,
460+
"bucket or object key %q is a dot segment (would escape scratch root)", seg)
461+
case "":
462+
return "", errors.Wrapf(ErrS3MalformedKey,
463+
"bucket or object key is empty (cannot construct scratch path)")
464+
}
465+
}
466+
return filepath.Join(scratchRoot, EncodeSegment([]byte(bucket)), EncodeSegment([]byte(object))), nil
467+
}
468+
437469
// HandleIncompleteUpload routes !s3|upload|meta|/!s3|upload|part|
438470
// records to <bucket>/_incomplete_uploads/records.jsonl when the
439471
// include flag is on; otherwise drops them.
@@ -682,6 +714,10 @@ func (s *S3Encoder) flushOrphanObject(b *s3BucketState, bucketDir string, obj *s
682714
if len(obj.chunkPaths) == 0 {
683715
return nil
684716
}
717+
if obj.object == "." || obj.object == ".." || obj.object == "" {
718+
return errors.Wrapf(ErrS3MalformedKey,
719+
"orphan object key %q is a dot segment (would escape orphan dir)", obj.object)
720+
}
685721
dir := filepath.Join(bucketDir, "_orphans", EncodeSegment([]byte(obj.object)))
686722
if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:mnd // 0755 == standard dir mode
687723
return errors.WithStack(err)

internal/backup/s3_test.go

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -584,14 +584,81 @@ func TestS3_StalePartVersionExcludedFromAssembledBody(t *testing.T) {
584584
}
585585
}
586586

587+
// TestS3_HandleBlobRejectsScratchEscape is the regression for Codex
588+
// P1 round 11: HandleBlob composed scratch paths with EncodeSegment,
589+
// which preserves `.` and `..` (RFC3986 unreserved). A bucket or
590+
// object literal of `..` would resolve to `<scratchRoot>/../...`,
591+
// letting writeFileAtomic land outside the decoder's scratch tree
592+
// before safeJoinUnderRoot ever ran at Finalize. The encoder now
593+
// refuses dot-component bucket/object names at HandleBlob.
594+
func TestS3_HandleBlobRejectsScratchEscape(t *testing.T) {
595+
t.Parallel()
596+
cases := []struct {
597+
name string
598+
bucket, object string
599+
}{
600+
{"object_dotdot", "b", ".."},
601+
{"object_dot", "b", "."},
602+
{"bucket_dotdot", "..", "x"},
603+
{"bucket_dot", ".", "x"},
604+
{"both_dotdot", "..", ".."},
605+
}
606+
for _, tc := range cases {
607+
t.Run(tc.name, func(t *testing.T) {
608+
t.Parallel()
609+
enc, _ := newS3Encoder(t)
610+
err := enc.HandleBlob(
611+
s3keys.BlobKey(tc.bucket, 1, tc.object, "u-1", 1, 0),
612+
[]byte("payload"),
613+
)
614+
if !errors.Is(err, ErrS3MalformedKey) {
615+
t.Fatalf("err=%v want ErrS3MalformedKey for bucket=%q object=%q", err, tc.bucket, tc.object)
616+
}
617+
})
618+
}
619+
}
620+
587621
func TestS3_DotSegmentObjectKeyRejected(t *testing.T) {
588622
t.Parallel()
589623
cases := []string{"a/../b", "a/./b", "..", "."}
590624
for _, key := range cases {
591625
t.Run(key, func(t *testing.T) {
626+
t.Parallel()
592627
enc, _ := newS3Encoder(t)
593-
emitObject(t, enc, "b", 1, key, []byte("x"), "")
594-
err := enc.Finalize()
628+
// Refusal must happen at OR BEFORE Finalize. The
629+
// scratch-path guard (Codex P1 round 11) catches sole-
630+
// dot keys at HandleBlob time; multi-segment dot keys
631+
// like "a/../b" pass through to Finalize where
632+
// safeJoinUnderRoot rejects them. Either point is
633+
// acceptable as long as ErrS3MalformedKey surfaces.
634+
err := enc.HandleBucketMeta(
635+
s3keys.BucketMetaKey("b"),
636+
encodeS3BucketMetaValue(t, map[string]any{"bucket_name": "b", "generation": 1}),
637+
)
638+
if err != nil {
639+
t.Fatalf("HandleBucketMeta: %v", err)
640+
}
641+
err = enc.HandleObjectManifest(
642+
s3keys.ObjectManifestKey("b", 1, key),
643+
encodeS3ManifestValue(t, map[string]any{
644+
"upload_id": "u-1", "size_bytes": int64(1),
645+
"parts": []map[string]any{{"part_no": 1, "size_bytes": int64(1), "chunk_count": 1}},
646+
}),
647+
)
648+
if err != nil {
649+
if errors.Is(err, ErrS3MalformedKey) {
650+
return
651+
}
652+
t.Fatalf("HandleObjectManifest: %v", err)
653+
}
654+
err = enc.HandleBlob(s3keys.BlobKey("b", 1, key, "u-1", 1, 0), []byte("x"))
655+
if err != nil {
656+
if errors.Is(err, ErrS3MalformedKey) {
657+
return
658+
}
659+
t.Fatalf("HandleBlob: %v", err)
660+
}
661+
err = enc.Finalize()
595662
if !errors.Is(err, ErrS3MalformedKey) {
596663
t.Fatalf("err=%v want ErrS3MalformedKey for key %q", err, key)
597664
}

0 commit comments

Comments
 (0)