fix(security): contain path traversal, classify driver errors, stop leaking paths over HTTP - #13
Merged
Merged
Conversation
Storage drivers returned not-found as a descriptive error and nothing else, so errors.Is(err, trove.ErrNotFound) came back false and callers had no way to tell a permanently missing object from a backend having a bad minute. That distinction matters to anything scheduling work on top of Trove. A job whose input was deleted would burn every retry attempt, minutes to hours of worker time, before it finally reached the dead letter queue, and Dispatch carries a substring fallback matching "not found" and "no such key" to work around exactly this. The canonical sentinels now live in the driver package rather than the root. The cloud drivers are separate Go modules, and reaching into the root package for an error value would drag cas, middleware, stream and vfs into every one of them. The root package re-exports all of them, and since errors.Is compares by identity, trove.ErrObjectNotFound and driver.ErrObjectNotFound are the same value. ErrObjectNotFound and ErrBucketNotFound unwrap to ErrNotFound, so you can match the specific resource or just ask whether something is gone. Every driver now wraps with %w and keeps its descriptive message. The cloud drivers read typed errors and status codes rather than message text: S3 modeled types first, then the API error code so MinIO and Ceph classify correctly, then the HTTP status. GCS uses its storage sentinels and googleapi.Error, including the reason code that separates a quota 403 from a permission 403. Azure reads the service code via bloberror.HasCode, replacing a Delete path that searched the rendered message for "BlobNotFound" and "404". Also classified where the backend reports them: ErrPermissionDenied, which is new, and ErrQuotaExceeded. cas.ErrNotFound wraps ErrNotFound too, so missing content does not need special-casing. Permanent(err) bool answers whether retrying can change the outcome, so consumers share one taxonomy instead of each writing their own switch. Not found, permission denied and bucket exists are permanent. Quota is not, since it may be granted later. Neither is an unclassified error: retrying something permanent wastes a backoff budget, but dead-lettering something transient throws away work that would have succeeded. The conformance suite asserts the sentinels instead of merely asserting that an error happened, so the gated cloud drivers are held to the contract too. Table-driven tests per driver, with the cloud mappings tested against synthesized provider errors so they need no credentials. Includes path traversal hardening for localdriver and sftpdriver, where safeJoin now guards every path the drivers touch rather than just Put, with traversal tests for both.
…HTTP
localdriver and sftpdriver reject traversal buckets and keys in safeJoin,
but returned bare descriptive errors. Every rejection reached the HTTP
extension unclassified, so a malformed key answered 500 (or 404 on GET and
HEAD, which reported every failure as not-found) with the raw driver
message in the body.
Add driver.ErrInvalidPath and wrap it from both guards. It deliberately
does not unwrap to ErrNotFound: nothing was looked up, so the request is
malformed rather than the resource missing. Answering 404 would both
misdescribe the failure and let a client use rejected keys to probe which
objects exist. Permanent() reports it as permanent — a malformed key does
not become well-formed on retry. Object-store drivers do not return it,
since their keys are opaque strings where "../" has nothing to reject, so
RunDriverSuite does not require it.
extension/handler now maps classified errors to their documented status
codes across the object, bucket and CAS routes, and no longer echoes raw
error text into 5xx responses. Driver errors wrap os-level ones, so a
failed write answered with e.g.
mkdir /srv/trove/data/nested: permission denied
disclosing the absolute storage root and its directory layout to any
client that could provoke a failure. An unclassified error is now logged
server-side with its operation and request path; the client receives
{"error":"internal error"}. Classifying an error is therefore also a
statement that its message is safe to publish, which the driver contract
documents.
Tests cover traversal through percent-encoded request targets, which is
the only form that reaches the handler — net/http cleans a literal "../"
out of the path and answers 307, while "%2e%2e%2f" is matched escaped and
then unescaped into the path value. A source-level assertion guards
against a future handler reintroducing the echo.
The traversal work landed inside 28c6f6b, whose message covers only the
error-classification refactor, so nothing in the history says the
containment guard was wired to one localdriver operation out of eight,
that a key could cross bucket boundaries while still passing the root
check, that DeleteBucket(".") handed the storage root to os.RemoveAll,
or that sftpdriver had no containment check at all.
The existing Error Classification entries describe how those rejections
are now classified, which reads as though the guard already worked. This
records the fix itself.
_project_files/ holds SHARED-CONVENTIONS.md and TROVE-DESIGN.md, which are working notes rather than part of the published module. Untracked and unignored, they surfaced in every git status and were easy to stage by accident. The pattern is anchored: a leading slash confines it to the repo root and a trailing slash matches only a directory, so it cannot swallow a similarly named path inside the driver submodules.
The extension job compiled the module and stopped there, so every test under extension/ was built and discarded. The handler tests added in this branch — HTTP status mapping and the error-disclosure rule — would have been carried by CI without ever executing. Adds the same `go test -race -count=1 ./...` step the driver jobs use. Passes as-is; nothing was hiding behind the gap.
juicycleff
force-pushed
the
fix/invalid-path-classification
branch
from
August 14, 2026 01:42
3cfecb3 to
2db1eb9
Compare
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.
Three things, in the order they depend on each other. The filesystem drivers now contain path traversal. Every driver classifies its failures through one shared set of sentinels. The HTTP surface reports both correctly, without handing server internals to whoever asked.
Path traversal
localdriverhad asafeJoinguard already. OnlyPutcalled it.Get,Head,Delete,Copy(both sides),List,CreateBucketandDeleteBucketeach built their own path with a barefilepath.Join(rootDir, bucket, key), so a key of../../etc/passwdreached the filesystem on every one of them. You could read it back withGetand remove it withDelete.sftpdriverwas worse, becauseobjectPathjoined the base path, the bucket and the key with a plainpath.Joinand returned whatever came out, so every one of these traversals applied just as well to whatever remote server you pointed it at.Two subtler problems sat in the same code.
Containment was checked once, against the root, after every segment had already been joined. That let a key spend the bucket segment on its way out and still land inside the root, so
Put("tenant-a", "../tenant-b/secret.txt")wrote into a different bucket and passed the check. Buckets are the tenancy boundary here, so root-only containment was simply the wrong invariant. It is now enforced per segment, against the directory the preceding segments produced.Then there's
DeleteBucket("."). It handed the whole storage root toos.RemoveAll, because.resolves torootDiritself and the facade rejects an empty bucket name but says nothing at all about a single dot. A segment that resolves to its own parent is refused now, which also stopsDelete(bucket, ".")removing a bucket directory through the object path.Rooted segments get refused now instead of quietly reinterpreted.
filepath.Jointreats/etc/passwdas relative and would store that object at<root>/<bucket>/etc/passwd, handing you back an object at a key you never asked for. Leading separators, backslashes and volume names are rejected on every platform, becausefilepath.IsAbsanswers false for/etc/passwdwhen you build for Windows.Error classification
The canonical sentinels now live in
driver/, so an out-of-tree driver in its own module can wrap them without importing the root package:ErrNotFound,ErrObjectNotFound,ErrBucketNotFound,ErrBucketExists,ErrPermissionDenied,ErrQuotaExceeded. The two specific not-found sentinels unwrap to the general one, so you matchErrNotFoundwhen any missing resource is handled the same way and the specific one when the distinction changes what you do.trove.Permanent(err)gives every consumer the same retry taxonomy instead of each hand-rolling a switch. Unclassified errors count as transient on purpose. The cost is lopsided: retrying something permanent wastes a backoff budget, while dead-lettering something transient throws away work that would have succeeded.One more.
azuredriverused to classify deletes by matching"BlobNotFound"and"404"against the rendered message, which breaks the moment the SDK rewords anything. It reads the typed service error code now.HTTP status codes, and what reaches the client
With the guards in place, every traversal rejection arrived at
extension/handlerunclassified and turned into a 500 carrying the raw driver message. Two separate defects fell out of that.Malformed keys answered the wrong status. A percent-encoded traversal returned 500 on
PUT,DELETEand list, and 404 onGETandHEAD. The encoding is the interesting part:net/http's mux cleans a literal../out of the path and answers 307, while%2e%2e%2fgets matched escaped and only then unescaped into the path value, so it reaches the driver intact. The tests use that form. A literal../would have tested nothing.New
driver.ErrInvalidPath, wrapped from both guards, maps to 400. It deliberately does not unwrap toErrNotFound. Nothing was looked up, so the request is malformed rather than the resource missing, and answering 404 would both misdescribe the failure and let a client use rejected keys to probe which objects exist.GETandHEADalso used to report every failure as 404, which made a backend outage indistinguishable from a missing object.The second defect: raw error text went straight into 5xx bodies. Driver errors wrap os-level ones, so a failed write answered with this.
That's real test output, not a hypothetical. It hands the absolute storage root and the directory layout underneath it to anyone who can provoke a failure, which on a public endpoint is anyone at all.
The rule now is that a classified error's message gets published and an unclassified one gets logged and replaced.
So classifying an error is now also a claim that its message is safe to publish. The driver guards already held up their end, naming the caller's own segment but never the directory it resolved against, and the driver contract documents the obligation. If a condition cannot be described without server-side detail, leave it unclassified and let it be an opaque 500.
This covers the object, bucket and CAS routes.
DELETE /buckets/%2ereturning 500 was the case that would have handedos.RemoveAllthe storage root before the guards existed.Testing
The traversal suites run every operation against traversal keys and bucket names, and assert the rejection alongside proof that a planted out-of-root file and a sibling bucket's object are byte-identical afterwards. An error on its own does not prove containment. Control cases run throughout, so a guard that rejected everything would fail rather than pass.
extension/handler/errors_test.gocovers the status table across all routes, reproduces the absolute-path disclosure against a real read-only directory, and pins the message-preservation behaviour so the leak fix cannot be satisfied by blanking every message.Two source-level assertions guard the wiring, since the SFTP operations need a live server and the handler failure paths mostly need a broken backend, so nothing else here would notice a new call site that skipped the guard. One checks that no remote path gets built with a bare
path.Join. The other checks that no handler writes raw error text into a response. That second one is what caughtcas.go.The last commit adds a
Teststep to theExtensionCI job. That job only rango build, so the handler tests above would have been compiled in CI and then thrown away. They pass as-is. Nothing was hiding behind the gap.Worth knowing before you review
ErrInvalidPathis deliberately not part oftrovetest.RunDriverSuite. S3, GCS and Azure keys are opaque strings where../is ordinary text, so requiring it there would fail drivers that are behaving correctly. Conformance coverage for it needs a separate opt-in suite for drivers whose keys address a real hierarchical namespace.DELETEof a missing object still answers 204, matching S3's idempotent semantics. It is pinned in the status table with a comment so it does not read as an oversight.upload.gohas no error paths yet. Its handlers return canned JSON without calling into Trove, so the source-level guard passes it trivially today and will start failing it once real driver calls land there.The gosec
G703suppression onlocaldriver'sos.Renameis documented inline. The path is containment-checked before it reaches the sink, but gosec cannot recognise afilepath.Relcheck as a sanitizer. Silencing it structurally would mean laundering the path through something the analyzer cannot follow, which would hide real traversal bugs there later.