Fix the flaky test testDoDownloadDirectory_PathTraversalProtection - #422
Fix the flaky test testDoDownloadDirectory_PathTraversalProtection#422sharatchandrag wants to merge 4 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #422 +/- ##
============================================
+ Coverage 81.97% 81.98% +0.01%
Complexity 646 646
============================================
Files 198 198
Lines 12802 12815 +13
Branches 1695 1698 +3
============================================
+ Hits 10495 10507 +12
Misses 1567 1567
- Partials 740 741 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
iamabhilaksh
left a comment
There was a problem hiding this comment.
Thanks for putting this together!
Could you shed a little more light on the context behind this? It would be really helpful to understand the exact root cause, how this specific implementation fixes it, and the steps you took to verify it. Thanks!
|
@iamabhilaksh Please note: How the fix works: Before downloading each blob, we now check — "does this file's destination path actually live inside the intended target folder?" If not, we skip it and record it as a failed download with a security error. Example: |
iamabhilaksh
left a comment
There was a problem hiding this comment.
Great work! The path-traversal protection is secure and properly implemented.
I have one minor nit regarding consistency:
In the traversal check, you compute the failed destination using safeTargetDir (which is absolute). However, in the TransferManager failure-handling block later on, you use targetDir (which could be relative, depending on user input).
Suggestion: Use safeTargetDir in both places so API consumers receive a consistent, absolute path format for all FailedBlobDownload errors.
Diff
- Path destination = targetDir.resolve(relative).normalize();
+ Path destination = safeTargetDir.resolve(relative).normalize();
Other than that, this looks excellent! 🚀
There was a problem hiding this comment.
Suggestion: There's no test for the prefix=null path. When prefix is null, stripPrefix
is "", so relative = name (the raw blob key), and destination = safeTargetDir.resolve(name) .normalize(). A blob named "../../../etc/passwd" should still be blocked. Worth adding a
test to confirm the protection holds when no prefix is specified?
There was a problem hiding this comment.
Yes, will take this edge case in the next iteration
There was a problem hiding this comment.
Suggestion: The test only covers the all-blocked case (single malicious blob, blobInfos
ends up empty). There's no test for the mixed case — one malicious + one legitimate blob —
where we'd want to assert that downloadBlobs IS invoked with exactly the legitimate blob
while the malicious one stays in failedTransfers. That's the load-bearing scenario:
path-traversal protection working alongside normal downloads. Could we add a second test
case for that path?
There was a problem hiding this comment.
Yes this is good point, will take up this in the next iteration
| String name = blob.getName(); | ||
| String relative = | ||
| name.startsWith(stripPrefix) ? name.substring(stripPrefix.length()) : name; | ||
| Path destination = safeTargetDir.resolve(relative).normalize(); |
There was a problem hiding this comment.
Question: Path#startsWith is a segment-based check, not a lexical prefix match, so it
correctly blocks safeTargetDir/../sibling after normalize() resolves it. One edge case:
if safeTargetDir itself normalizes to / on an unusual mount (e.g. a container root), then
every path starts with it and the check becomes a no-op. Is that worth a comment, or do we
consider that out-of-scope for this fix?
There was a problem hiding this comment.
This is out of scope usecase for us, its unsual for the users to pass the root directory as the download directory and also most systems will reject such downloads
| // Resolve the canonical target directory once so path-traversal checks below | ||
| // compare absolute, normalized paths (e.g. blocks "../../../etc/passwd"). | ||
| final Path safeTargetDir = targetDir.toAbsolutePath().normalize(); | ||
| final String stripPrefix = prefix != null ? prefix : ""; |
There was a problem hiding this comment.
nit : Use StringUtils.EMPTY instead.
final String stripPrefix = prefix != null ? prefix : StringUtils.EMPTY;
iamabhilaksh
left a comment
There was a problem hiding this comment.
Left some minor comments. Rest LGTM 👍
SriramGuduri
left a comment
There was a problem hiding this comment.
LGTM — clean, correct path-traversal protection using the resolve-normalize-startsWith pattern. Malicious blobs are filtered before reaching TransferManager.
Nitpicks (non-blocking):
-
GcpBlobStoreTest.java— consider usingassertInstanceOf(SecurityException.class, failure.getException())(JUnit 5.8+) instead ofassertTrue(x instanceof Y)for clearer failure messages. -
GcpBlobStore.java— minor:stripPrefixand the path-traversal check both derive from the same request field. Just a note to keep these in sync if the prefix semantics ever diverge.
| String relative = | ||
| name.startsWith(stripPrefix) ? name.substring(stripPrefix.length()) : name; |
There was a problem hiding this comment.
The prefix strip here does not match how the GCS SDK computes the destination, so the guard validates a path the SDK will not use — and it rejects legitimate blobs.
In google-cloud-storage 2.62.0 (the version pinned in blob-gcp/pom.xml), TransferManagerUtils.createDestPath computes the destination as
config.getDownloadDirectory().resolve(blob.getName().replaceFirst(config.getStripPrefix(), "")).
String#replaceFirst treats the strip prefix as a regex, while line 1043 strips it as a literal prefix. The two agree only when the prefix contains no regex metacharacters.
Concrete case — prefixToDownload = "backup(1)/", blob key backup(1)/../evil.txt:
- SDK:
replaceFirst("backup(1)/", "")matches nothing (that pattern meansbackup1/), so it writes<dir>/backup(1)/../evil.txt→<dir>/evil.txt, safely inside the target directory. - This code: strips literally →
../evil.txt→ resolves to<parent>/evil.txt→ the blob is failed withSecurityException.
A download that works today starts failing, which contradicts "legitimate keys (including ones with .. segments that stay within the target directory) are unaffected". The same mismatch makes the reported destination wrong.
Suggested fix: make both sides strip identically, e.g. setStripPrefix(Pattern.quote(prefix)) at line 1067 — getStripPrefix() is consumed only by createDestPath, and quoting also avoids PatternSyntaxException for prefixes such as data[2024/.
Summary
Add path-traversal protection to doDownloadDirectory and re-enable disabled test
Description
GcpBlobStore#doDownloadDirectory: each blob's post-strip destination is resolved against a canonicalizedsafeTargetDir = targetDir.toAbsolutePath().normalize()and rejected if it escapes viaPath#startsWith(segment-based, not lexical).FailedBlobDownloadentries with aSecurityException("Blocked path traversal for blob: <name>")and are filtered beforeTransferManager.downloadBlobs(...), so attacker-controlled keys never reach the GCS transfer pipeline.blobInfosare preserved;stripPrefixsemantics continue to matchTransferManager.setStripPrefix.GcpBlobStoreTest#testDoDownloadDirectory_PathTraversalProtection(was@Disabled("Failing because of temp directory permission")). The original test was unfixable as written — it verifiedBlob#downloadTo(never called by the implementation) and did not stubTransferManager. Rewritten to assert the real contract: malicious blob is infailedTransferswith aSecurityException, destination is outsidetempDir, andmockTransferManager.downloadBlobs(...)is never invoked...segments that stay within the target directory) are unaffected. GcpBlobStoreTest passes (300/300).