Skip to content

fix: reject object keys and path components that escape their own tenant - #155

Merged
skyoo2003 merged 4 commits into
mainfrom
fix/path-injection-sanitizers
Sep 6, 2026
Merged

skyoo2003 merged 4 commits into
mainfrom
fix/path-injection-sanitizers

Conversation

@skyoo2003

@skyoo2003 skyoo2003 commented Sep 6, 2026 •

Copy link
Copy Markdown
Owner

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 open go/path-injection alerts 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, per SECURITY.md.

Changes

  • internal/services/s3/store.go — objectPath guards key with filepath.IsLocal. This is the actual fix: key legally contains /, and checking the joined path against baseDir alone let ../victim/secret.txt through. validPathComponent and safePath get the same guard.
  • internal/shared/validate.go — IsWithinDir returns filepath.IsLocal(rel); same truth table, one fewer import.
  • internal/services/lambda/store.go — isSafePathComponent gets the guard, and DeleteFunction reuses codePath instead 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's TaintedPathCustomizations.qll recognises only strings.HasPrefix(p, prefix), !strings.Contains(p, ".."), filepath.IsLocal(p), a regexp match, or filepath.Clean("/" + e) — filepath.Rel is 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:

--- FAIL: TestFileStore_KeyCannotEscapeItsBucket
    -([]uint8) (len=6) { 73 65 63 72 65 74  |secret| }
    +([]uint8) (len=5) { 6f 77 6e 65 64     |owned|  }
    Messages: victim object should be untouched
--- FAIL: TestFileStore_KeyCannotEscapeItsAccount
    Error: An error is expected but got nil.
    Error: open .../000000000000/victim/secret.txt: no such file or directory
    Messages: victim object should still exist

The attacker overwrote another bucket's object and deleted another account's object. Reachable over HTTP: gateway.go:55 forwards r.URL.Path unchanged and splitBucketKey splits 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) — codePath 66.7% → 77.8%, safePath 75.0% → 81.2%, DeleteFunction 64.3% → 71.4%. Remaining uncovered lines are filepath.Abs/Rel error returns, which need a broken OS to reach.

golangci-lint run on 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

  • Legitimate keys are unaffected: photos/a.jpg and a/b/../../c/file.txt still work (covered by the existing TestFileStore_PathTraversal).
  • On Windows, filepath.IsLocal also 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.
  • The filepath.Rel containment is kept underneath the new guard — redundant for the taint analysis, but it still catches symlink escapes that IsLocal does not see.

Checklist

  • Self-reviewed the code
  • Added/updated tests
  • Lint/format passes (golangci-lint run)
  • Updated documentation (if applicable) — N/A
  • Added a Changie changelog fragment (changes/unreleased/Security-20260907-074946.yaml, one sentence, 335/400 chars)

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.
@github-actions github-actions Bot added tests Test code and test infrastructure services AWS service implementations labels Sep 6, 2026
@skyoo2003
skyoo2003 merged commit 728c9fc into main Sep 6, 2026
10 checks passed
@skyoo2003
skyoo2003 deleted the fix/path-injection-sanitizers branch September 6, 2026 22:57
@skyoo2003 skyoo2003 mentioned this pull request Sep 6, 2026
1 of 5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

services AWS service implementations tests Test code and test infrastructure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant