Skip to content

fix(snapshot s3): reject object keys with ".." segments and never overwrite a downloaded object - #1155

Merged
AlexKantor87 merged 14 commits into
mainfrom
6781-s3-key-containment
Sep 10, 2026
Merged

fix(snapshot s3): reject object keys with ".." segments and never overwrite a downloaded object#1155
AlexKantor87 merged 14 commits into
mainfrom
6781-s3-key-containment

Conversation

@AlexKantor87

@AlexKantor87 AlexKantor87 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

What

kosli snapshot s3 downloads objects into a temp directory before fingerprinting. Object keys containing a .. path segment are now rejected, and each destination file is created exclusively so two keys can never write the same local file. A key that fails either check fails the snapshot and names the key. Keys that snapshot successfully today keep the same local layout and therefore the same fingerprint.

The .. check splits on both / and \, and treats a segment that Windows would normalise to .. (trailing spaces or dots, e.g. .. or ...) as .., so the rule does not depend on which platform runs the snapshot. filepath.IsLocal is the OS-specific safety net on top: on Windows it additionally rejects rooted paths, any colon, and reserved device names.

Why

A key that normalises to a different path than it names could replace another object's downloaded content, and on Windows a key containing backslash-separated .. could resolve outside the temp directory. Reported privately; tracked internally.

Behaviour change

Buckets containing a .. key, or two keys that resolve to one local file, previously produced a fingerprint; they now error and name the key. Two consequences worth knowing:

  • A backslash key containing .. (e.g. a\..\b) was a literal filename on Linux and macOS and is now rejected everywhere, so the rule is the same on every OS.
  • Two keys differing only in filename case snapshot fine on Linux and now error on a case-insensitive filesystem (macOS, Windows) instead of the second silently replacing the first.
  • A segment made only of dots and spaces that begins with .. (e.g. ... or .. ) is rejected everywhere, because Windows trims trailing dots and spaces and would resolve it as ... A legitimate ... directory is a legal name on Linux and macOS and snapshots today; it errors after this change.

Use --exclude-regex for a legitimate key of these shapes. --exclude cannot match a key that starts with / (it slash-trims the pattern, not the key), so the error text and help point at the regex form. When --include/--include-regex is set, exclude filters are ignored (pre-existing precedence), so the advice also says to narrow the include filter.

Errors caused by the key (rule rejections, an O_EXCL collision) carry that advice; other filesystem errors (disk full, permissions, object-and-prefix ENOTDIR, case-folded EISDIR) name the key and wrap the cause with no advice, so a machine fault never suggests dropping a legitimate object.

Doubled slashes, leading slashes, . segments and backslashes without .. are deliberately still accepted and land exactly where filepath.Join put them before. TestGetS3DataFromClientKeepsTodaysLayoutForUnusualKeys pins that, and it was verified green against unmodified main.

Testing

gofmt -l .                                  clean
go build ./... / go vet ./...               ok
make lint                                   0 issues
go test ./internal/aws/ -count=1            ok
go test ./internal/aws/ -race -count=2      ok
go mod tidy                                 no-op

cmd/kosli TestSnapshotS3 needs the local Kosli server on :8001 and AWS credentials for kosli-cli-public; it was not runnable locally and is left to CI. make test_integration was not run locally for the same reason.

Red first. TestGetS3DataFromClientRejectsKeysWithDotDotSegments against unmodified code:

--- FAIL: TestAWSTestSuite/TestGetS3DataFromClientRejectsKeysWithDotDotSegments
    Error: An error is expected but got nil.

and a temporary equality assertion confirmed the poisoned bucket's fingerprint was identical to a bucket holding only the attacker's bytes under protected/release.bin. The three attack tests (RejectsKeysWithDotDotSegments, CollidingKeysAreAnError, DownloadFileFromBucketRefusesToOverwrite) are all red against main.

Mutations, each restored afterwards. Red: delete the whole .. segment check; split segments on / only (backslash rows red); exact .. compare without trimming (.. and ... rows red); strings.Contains(key, "..") instead of segment compare (..hidden row red); replace O_EXCL with O_TRUNC; MkdirAll(dest) instead of the parent; drop the leading-slash trim; naive ""/"." guard instead of Clean (./. row red); helper returns the key unchanged. Survived: dropping filepath.IsLocal. On Linux and macOS it can never reject anything the .. check has not already rejected, so it has no CI coverage; it is kept because on Windows it is what rejects rooted paths, colons and reserved names (reasoned from internal/filepathlite/path_windows.go, not run; CI has no Windows job).

An independent adversarial pass tried to escape the temp directory or collide silently on all three platforms (case folding, Unicode normalisation, trailing dots and spaces, NUL bytes, file/directory confusion, overlong keys) and found no escape and no silent file collision.

Not done here, deliberately

  • Two directories differing only in case still merge on a case-insensitive filesystem, as before this change; O_EXCL covers files only. Pre-existing, noted in the code comment, tracked internally.
  • Bucket-root .kosli_ignore handling (separate ticket).
  • Tightening temp-file permissions. MkdirAll(..., 0770) and OpenFile(..., 0666) are exactly what utils.CreateFile + os.Create produced, so the on-disk tree digest.DirSha256 walks is unchanged in modes as well as layout; that is what makes the same-fingerprint claim hold.
  • Deprecating content mode in favour of metadata fingerprinting (feat(snapshot s3): fingerprint buckets from metadata #1069).

Checklist

  • Docs are autogenerated from CLI help; help text updated.
  • Helm chart not affected.
  • Terraform provider not affected.

🤖 Generated with Claude Code

… --exclude-regex

Windows drops trailing spaces and dots from a name, so a segment such as
".. " can resolve as "..". Trim before comparing so the check does not
depend on the platform running the snapshot.

--exclude matches the raw key with a slash-trimmed pattern, so it cannot
exclude a key that starts with "/"; --exclude-regex always can.

A single filepath.Clean check replaces the two-literal guard for keys that
name no file, and the redundant FromSlash is dropped: IsLocal and Join
accept both separators on every platform.
@github-actions github-actions Bot added go Pull requests that update go code fix labels Sep 9, 2026
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @AlexKantor87's task in 7m 24s —— View job


Review — round 8

  • Static analysis (go build, go vet, go mod tidy, Windows cross-compile)
  • Read the changed files
  • Verify the two fixes from the last round
  • Post findings

No new findings. The two items from round 7 both do what they claim, and I have nothing blocking to add.

Static analysis

Check Result
go build ./... clean
go vet ./... clean (includes the test files)
go mod tidy + git diff --exit-code go.mod go.sum no-op
GOOS=windows go build ./... clean

The Windows cross-compile is the one worth calling out: it's the only mechanical check available for the syscall.ENOTDIR branch at internal/aws/aws.go:598, which has no CI coverage and was reasoned about rather than run. syscall.ENOTDIR is defined on windows/amd64, so the branch compiles everywhere — the portability claim holds.

go test ./internal/aws/ is not in this job's allowlist, so the suite itself was not run here; CI covers it.

Verifying the round-7 changes

The contract row is no longer vacuous (internal/aws/s3_contract_test.go:49). TestS3Contract_Fake has two objects and PageSize: 1, so the paginator collects both keys across two pages, len(keys) >= 2 passes, and slices.IsSorted is meaningful: README.md (R, 0x52) then dummy/dummy_2/template.yml (d, 0x64). Reversing sort.Strings in FakeS3Client.sortedKeys flips the order and turns the row red. Termination against the fake also checks out — page 2 reports IsTruncated: false, so HasMorePages goes false.

The MkdirAll wrap is now covered (internal/aws/aws_test.go, TestDownloadFileFromBucketNamesTheKeyWithoutAdviceOnFilesystemErrors). With tempDir at 0500, key sub/README.md reaches MkdirAll(tempDir/sub)EACCES, which misses the ENOTDIR branch and lands on the neutral wrap. Reverting that wrap to a bare return err leaves errors.Is(err, fs.ErrPermission) satisfied but drops object key [sub/README.md] from the message, so the assertion is load-bearing.

Two things I checked independently

Both path checks are load-bearing, and the PR's framing of them is accurate. I read filepathlite.unixIsLocal (Go 1.25, path.go:145): it Cleans the path when dots are present, so IsLocal("uploads/user-a/../../protected/release.bin") returns true — it cleans to protected/release.bin, which is local. IsLocal alone would not have caught the reported attack; the explicit .. segment scan is what does. IsLocal earns its keep on the Windows side, where isLocal (path_windows.go:23) rejects a leading separator, a colon anywhere, and isReservedName components — which is exactly what the three wantErr: runtime.GOOS == "windows" rows assert, with the right reason string.

The same-fingerprint claim holds on modes as well as layout. utils.CreateFile (internal/utils/utils.go:41) is MkdirAll(dir, 0770) + os.Create, and os.Create is O_RDWR|O_CREATE|O_TRUNC, 0666. The replacement uses the identical mode bits, so the tree digest.DirSha256 walks is byte-for-byte and mode-for-mode what it was — only O_TRUNCO_EXCL differs, which is the fix.

Also confirmed: the new help line at cmd/kosli/snapshotS3.go:18 has balanced ^ delimiters (8), so the generated docs won't garble, and no golden file in cmd/kosli/testdata/ pins the S3 help text.

One note for the human approver

The largest remaining hole is the one the PR scopes out deliberately: two directories differing only in case (or only in Unicode normalisation, on macOS) still merge, because O_EXCL covers files only. Keys A/x and a/y land as A/x and A/y on a case-insensitive filesystem, so a bucket holding {A/x, a/y} fingerprints identically to one holding {A/x, A/y} — a fingerprint collision between distinct bucket contents, the same class of problem this PR fixes for files. It is pre-existing, called out in the code comment at internal/aws/aws.go:605-607 and in the PR description, and tracked internally. Worth a conscious nod at approval time rather than discovery later; the code comment names case-insensitivity but not the macOS normalisation variant of the same hole.

Worth acknowledging

  • The advice/no-advice split on error messages is the right instinct and rarely gets built: refusing to suggest --exclude-regex on a full disk is what stops an operator from turning a failed snapshot into a silently incomplete one. Routing ENOTDIR to the advising side while leaving ENOSPC neutral draws the line where the bucket is at fault versus the machine.
  • TestGetS3DataFromClientKeepsTodaysLayoutForUnusualKeys is the test that makes this mergeable: pinning that accepted odd-shaped keys still fingerprint identically to the plain keys they resolve onto is what turns "we tightened a rule" into "no existing user's fingerprint moves."
  • The per-OS wantErr rows are a better answer than a runtime.GOOS skip — they make the table the only Windows-side coverage IsLocal has, instead of silently asserting nothing there.
  • Adding the listing-order row to the contract suite rather than to a comment puts the assumption where a fake/real divergence would actually surface.
    · branch 6781-s3-key-containment

Comment thread internal/aws/aws.go Outdated
Comment thread internal/aws/aws_test.go
Comment thread internal/aws/aws_test.go
… directory cannot be made

The rule error already named the key and pointed at --exclude-regex; the
O_EXCL and MkdirAll errors surfaced a bare temp-dir path instead. One
helper now builds every unusable-key error, and the collision test pins
the key and the advice.
Comment thread internal/aws/aws.go Outdated
Comment thread internal/aws/aws.go Outdated
Comment thread internal/aws/aws.go
A disk-full or permission error was carrying "exclude it with
--exclude-regex"; following that on a legitimate object would record a
snapshot with the object silently missing. Filesystem errors now name the
key and wrap the cause with no advice. The rule rejections and an O_EXCL
collision, which the key does cause, keep the advice.

Pins the object-and-prefix case ("a" plus "a/b") and an unwritable
download directory, and documents that only leading "/" is trimmed.
Comment thread cmd/kosli/snapshotS3.go Outdated
Comment thread internal/aws/aws.go Outdated
Comment thread internal/aws/aws.go Outdated
Comment thread internal/aws/aws.go Outdated
Comment thread internal/aws/aws.go
…ey on download failure

os.MkdirAll returns ENOTDIR portably when a parent of the destination is
already a file, which only a bucket holding both "a" and "a/b" can cause,
so that error now carries the exclusion advice like an O_EXCL collision.
The advice no longer presumes an include filter is in use.
Comment thread internal/aws/aws.go Outdated
Comment thread internal/aws/aws.go
Comment thread internal/aws/aws_test.go
Comment thread internal/aws/aws_test.go
Comment thread internal/aws/aws_test.go Outdated
Comment thread internal/aws/aws.go
Comment thread internal/aws/aws.go
Comment thread internal/aws/aws_test.go
Comment thread internal/aws/s3_contract_test.go
Comment thread internal/aws/aws_test.go Outdated
Comment thread internal/aws/aws_test.go
…ror wrap

The contract row read one page, and the fake serves one key per page in
the contract suite, so a one-element slice was always sorted. Walking the
paginator checks the order the snapshot actually consumes; reversing the
fake's sort now fails it. A nested key in the permissions test reaches
the MkdirAll wrap, which a bare key never did.
Cut the test doc comments that restated their test names, the history and
the reference to a sibling test, and reduced each remaining comment to the
fact a reader cannot get from the code.

@mbevc1 mbevc1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we could avoid doing downloads or perhaps use temporary names when downloading, instead of relying on target OS compatibility

@AlexKantor87

Copy link
Copy Markdown
Contributor Author

Both good instincts, and the second one is better than I first gave it credit for. Answering properly rather than just deferring to the roadmap.

Avoiding the download is where we're heading: that's your --fingerprint-source metadata in #1069, and it's the agreed direction. It can't be what ships here though, because it needs every object to carry a full-object SHA256 that S3 only stores when the uploader asked for it, so most existing buckets can't use it yet. The deprecation is several releases and the default mode has the hole in it the whole time.

Temporary names is the interesting one. It doesn't work on its own, because DirSha256 hashes each entry's base name as well as its content, so renaming the files on disk changes the fingerprint. But it does work if the fingerprint stops coming from the tree on disk, and you've already built that: download to flat temp names, hash each object as it lands, and hand VirtualDirSha256 the (key, sha256) pairs. No key ever becomes a path, the fingerprint is unchanged because your virtual walk reproduces DirSha256 exactly, and unlike metadata mode it works on buckets with no checksums. That's a genuinely better end state than what I've done here and I think it's worth folding into the #1069 design.

Two things to know before it does, both from reading your branch rather than the description:

It moves the path rule rather than removing it, and the two rules disagree in both directions. validateVirtualPath rejects anything where p != path.Clean(p), which is the strict rule we deliberately didn't take here. Ran your real VirtualDirSha256 against the shapes this PR cares about:

key content mode (this PR) metadata mode (#1069)
a//b accept reject
./c.txt accept reject
/lead.txt accept reject
..., a/.../b reject accept
d\e.txt accept accept
uploads/user-a/../../protected/release.bin reject reject

So a bucket that snapshots fine today breaks when the default flips, on keys that have nothing to do with the vulnerability. Worth settling on one rule across both modes before that happens, rather than discovering it in a customer's pipeline.

VirtualDirSha256 deliberately doesn't handle .kosli_ignore, and content mode honours a root one today via DirSha256. A temp-name content mode would have to apply exclusions itself. That's not free, though it does close the S3 route into the self-hiding ignore file, which is a separate ticket and a plus.

Keeping this PR as the interim guard, since it's small and can ship now. Happy to be wrong about the ordering if you'd rather go straight at it.

@AlexKantor87
AlexKantor87 merged commit 02db35a into main Sep 10, 2026
22 checks passed
@AlexKantor87
AlexKantor87 deleted the 6781-s3-key-containment branch September 10, 2026 14:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants