Skip to content

Commit 2febd42

Browse files
committed
backup: reject leading-slash S3 object keys (PR #718, round 5)
Codex P1 round 5 (commit 2f87b84): `safeJoinUnderRoot` permitted an empty first segment so leading-slash keys like `/a` were accepted and then normalised by `filepath.Join` to the same output path as `a`. S3 treats `/a` and `a` as two distinct keys (the literal byte '/' is part of the key), so a bucket containing both produced last-flush-wins corruption with no KEYMAP record. The "absolute path collapses safely under bucket dir" comfort the previous behaviour leaned on was false comfort: the collapse silently merged distinct user data. Now empty segments are refused everywhere — leading, mid-path, and trailing — with ErrS3MalformedKey. Operators with leading-slash keys must rename them in S3 first. The previous test `TestS3_AbsolutePathObjectKeyConfinedUnderBucket` (which asserted the wrong behaviour) is replaced by `TestS3_LeadingSlashObjectKeyRejected`.
1 parent 2f87b84 commit 2febd42

2 files changed

Lines changed: 24 additions & 30 deletions

File tree

internal/backup/s3.go

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -672,23 +672,22 @@ func safeJoinUnderRoot(root, rel string) (string, error) {
672672
// them onto one filesystem path; without explicit rejection,
673673
// distinct user keys would silently overwrite each other at
674674
// finalize (Codex P1 #614).
675+
//
676+
// Empty segments are rejected wherever they appear — including
677+
// the leading position. A leading "/" produces an initial empty
678+
// segment (segs[0] == "") which filepath.Join would otherwise
679+
// strip, collapsing "/a" onto the same output path as "a".
680+
// Because S3 treats those as two distinct keys, last-flush wins
681+
// and silently overwrites the other (Codex P1 round 5).
675682
segs := strings.Split(rel, "/")
676-
for i, seg := range segs {
683+
for _, seg := range segs {
677684
switch seg {
678685
case ".", "..":
679686
return "", errors.Wrapf(ErrS3MalformedKey,
680687
"object name has dot segment %q (S3 treats it literally; rename in S3 first)", rel)
681688
case "":
682-
// A leading "/" produces an initial empty segment
683-
// (segs[0] == ""); filepath.Join handles that case
684-
// safely by stripping the prefix, matching the
685-
// already-tested "absolute path collapses under
686-
// bucket dir" behaviour. Reject empty segments
687-
// anywhere else (mid-path "//" or trailing "/").
688-
if i != 0 {
689-
return "", errors.Wrapf(ErrS3MalformedKey,
690-
"object name has empty path segment %q", rel)
691-
}
689+
return "", errors.Wrapf(ErrS3MalformedKey,
690+
"object name has empty path segment %q", rel)
692691
}
693692
}
694693
cleanRoot := filepath.Clean(root)

internal/backup/s3_test.go

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -284,26 +284,21 @@ func TestS3_PathTraversalAttemptRejected(t *testing.T) {
284284
}
285285
}
286286

287-
func TestS3_AbsolutePathObjectKeyConfinedUnderBucket(t *testing.T) {
287+
func TestS3_LeadingSlashObjectKeyRejected(t *testing.T) {
288288
t.Parallel()
289-
// filepath.Join normalises a leading "/" on the second arg, so
290-
// "/etc/host" becomes "<bucketDir>/etc/host" — under the bucket
291-
// root, not at filesystem root. This is safe (the user gets a
292-
// surprising-but-confined path) and matches what `aws s3 sync`
293-
// would round-trip back. We assert the safe outcome rather than
294-
// rejecting; rejection would surprise operators with legitimate
295-
// keys whose first byte is '/'.
296-
enc, root := newS3Encoder(t)
297-
emitObject(t, enc, "b", 1, "/etc/host-confined", []byte("ok"), "")
298-
if err := enc.Finalize(); err != nil {
299-
t.Fatal(err)
300-
}
301-
got, err := os.ReadFile(filepath.Join(root, "s3", "b", "etc", "host-confined")) //nolint:gosec
302-
if err != nil {
303-
t.Fatalf("absolute-path key must end up under the bucket dir: %v", err)
304-
}
305-
if string(got) != "ok" {
306-
t.Fatalf("body=%q", got)
289+
// Codex P1 round 5: S3 treats "/a" and "a" as two distinct keys
290+
// (the literal byte '/' is part of the key). filepath.Join would
291+
// silently strip the leading "/" and collapse both onto the same
292+
// output path, so a bucket containing both objects would produce
293+
// last-flush-wins corruption with no KEYMAP record. The encoder
294+
// must refuse any key whose first segment is empty rather than
295+
// "confine and merge" them. Operators with such keys must rename
296+
// in S3 first.
297+
enc, _ := newS3Encoder(t)
298+
emitObject(t, enc, "b", 1, "/etc/host-attack", []byte("ok"), "")
299+
err := enc.Finalize()
300+
if !errors.Is(err, ErrS3MalformedKey) {
301+
t.Fatalf("err=%v want ErrS3MalformedKey for leading-slash key", err)
307302
}
308303
}
309304

0 commit comments

Comments
 (0)