fix: reject object keys and path components that escape their own tenant - #155
Merged
Merged
Conversation
A key like ../victim/secret.txt leaves its own bucket but stays under baseDir, so the containment check against baseDir alone lets it through. RED: the attacker overwrites and deletes another tenant's objects.
An S3 key like ../victim/secret.txt left its own bucket while staying under baseDir, so the containment check against baseDir alone allowed cross-bucket and cross-account read, overwrite and delete. Guard every user-controlled path component with filepath.IsLocal, which is also the barrier CodeQL recognises for go/path-injection.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Object keys could leave their own bucket while staying under the store base directory, so one tenant could read, overwrite and delete another tenant's objects. Every user-controlled path component is now guarded with
filepath.IsLocal, which also happens to be the containment guard CodeQL recognises — closing the seven opengo/path-injectionalerts as a side effect rather than by hand-dismissing them.Related Issue
Refs private security advisory
GHSA-m6g9-7473-89ww(draft, Security tab). No public issue, perSECURITY.md.Changes
internal/services/s3/store.go—objectPathguardskeywithfilepath.IsLocal. This is the actual fix:keylegally contains/, and checking the joined path againstbaseDiralone let../victim/secret.txtthrough.validPathComponentandsafePathget the same guard.internal/shared/validate.go—IsWithinDirreturnsfilepath.IsLocal(rel); same truth table, one fewer import.internal/services/lambda/store.go—isSafePathComponentgets the guard, andDeleteFunctionreusescodePathinstead of repeating a containment check inline (15 lines removed).Net production diff: -4 lines.
Why the alerts kept coming back
Every containment check in this repo is
filepath.Rel+strings.HasPrefix(rel, ".."). CodeQL'sTaintedPathCustomizations.qllrecognises onlystrings.HasPrefix(p, prefix),!strings.Contains(p, ".."),filepath.IsLocal(p), a regexp match, orfilepath.Clean("/" + e)—filepath.Relis not on the list. That is why 7 alerts stayed open and 9 more were dismissed by hand as "false positive: path built via validated helper".Writing the reproducer showed the dismissals had missed something: the check was not merely unrecognised, it was incomplete. It proved a path stays under
baseDir, not that it stays inside the tenant that owns it.Test Plan
RED (
c6bfc14) —CGO_ENABLED=0 go test ./internal/services/s3/ -run 'TestFileStore_KeyCannotEscape' -v:The attacker overwrote another bucket's object and deleted another account's object. Reachable over HTTP:
gateway.go:55forwardsr.URL.Pathunchanged andsplitBucketKeysplits on the first/without cleaning, so..survives from the request line.GREEN (
2a40ab9) —CGO_ENABLED=0 go test ./...: 111 packages ok, 0 failures.Guard coverage (
673f7f8) —codePath66.7% → 77.8%,safePath75.0% → 81.2%,DeleteFunction64.3% → 71.4%. Remaining uncovered lines arefilepath.Abs/Relerror returns, which need a broken OS to reach.golangci-lint runon the changed packages: 0 issues.gofmt: clean.Not yet verified: whether CodeQL actually clears the seven alerts. That is what this PR's scan run is for.
Behaviour notes
photos/a.jpganda/b/../../c/file.txtstill work (covered by the existingTestFileStore_PathTraversal).filepath.IsLocalalso rejects reserved device names (con,nul,aux, …), so a key or bucket with such a name is refused on Windows builds. The filesystem could not store it under that name anyway.filepath.Relcontainment is kept underneath the new guard — redundant for the taint analysis, but it still catches symlink escapes thatIsLocaldoes not see.Checklist
golangci-lint run)changes/unreleased/Security-20260907-074946.yaml, one sentence, 335/400 chars)