Repository navigation
fix(core): prevent path traversal in DummyPayloadStorage - #1651
Merged
nthmost-orkes merged 10 commits intoSep 25, 2026
Merged
nthmost-orkes merged 10 commits into
nthmost-orkes merged 10 commits into
Conversation
DummyPayloadStorage resolved the caller-supplied path against payloadDir
with no validation, in both upload() and download():
File file = new File(payloadDir, path);
return new FileInputStream(new File(payloadDir, path));
so a "../"-style path could read or write outside the payload directory.
This is the default payload storage when no external storage (S3/GCS) is
configured, so it is reachable in dev and default deployments.
Mirrors the fix merged for MockExternalPayloadStorage in conductor-oss#723: a private
validateAndResolvePath() that normalizes the path, rejects a normalized
path still containing "..", and verifies the canonical path stays within
payloadDir, throwing SecurityException on escape. Both call sites route
through it and catch, so the existing contract is unchanged - download()
still returns null on failure rather than propagating.
Adds tests for traversal on both read and write paths, and for a nested
path, which also covers the parent-directory creation the shared pattern
relies on.
Closes conductor-oss#1639
nthmost-orkes
self-requested a review
September 21, 2026 23:16
…payload on rejection The upload-traversal test previously asserted only that download() of the same "../" path returned null -- but download() rejects any "../" path regardless of whether a file exists, so the test passed even if upload() had written outside payloadDir. It now asserts directly on disk that the escaped file was never created, via a package-private getPayloadDir() seam. Also restructures upload() so the payload InputStream is always closed, including when validateAndResolvePath() rejects the path -- previously the stream leaked on the rejection branch because the close lived in an inner finally that validation skipped past.
nthmost-orkes
approved these changes
Sep 25, 2026
nthmost-orkes
left a comment
Contributor
There was a problem hiding this comment.
Pushed one commit on top:
- The upload-traversal test now asserts on disk that no file lands outside
payloadDir. Before, it only checkeddownload()returned null — but download rejects any../path on its own, so the test passed even if upload had written the escaped file. Added a package-privategetPayloadDir()seam so the test can look where a../write would actually go. - Restructured
upload()so the payload stream is always closed, including when validation rejects the path (previously it leaked on that branch, since the close sat in an inner finally that rejection skipped).
Guard logic itself is unchanged and still mirrors #723. LGTM.
nthmost-orkes
added a commit
that referenced
this pull request
Sep 25, 2026
* Prevent path traversal in DummyPayloadStorage
DummyPayloadStorage resolved the caller-supplied path against payloadDir
with no validation, in both upload() and download():
File file = new File(payloadDir, path);
return new FileInputStream(new File(payloadDir, path));
so a "../"-style path could read or write outside the payload directory.
This is the default payload storage when no external storage (S3/GCS) is
configured, so it is reachable in dev and default deployments.
Mirrors the fix merged for MockExternalPayloadStorage in #723: a private
validateAndResolvePath() that normalizes the path, rejects a normalized
path still containing "..", and verifies the canonical path stays within
payloadDir, throwing SecurityException on escape. Both call sites route
through it and catch, so the existing contract is unchanged - download()
still returns null on failure rather than propagating.
Adds tests for traversal on both read and write paths, and for a nested
path, which also covers the parent-directory creation the shared pattern
relies on.
Closes #1639
* test: assert upload traversal never writes outside payloadDir; close payload on rejection
The upload-traversal test previously asserted only that download() of the
same "../" path returned null -- but download() rejects any "../" path
regardless of whether a file exists, so the test passed even if upload()
had written outside payloadDir. It now asserts directly on disk that the
escaped file was never created, via a package-private getPayloadDir() seam.
Also restructures upload() so the payload InputStream is always closed,
including when validateAndResolvePath() rejects the path -- previously the
stream leaked on the rejection branch because the close lived in an inner
finally that validation skipped past.
---------
Co-authored-by: Naomi Most <naomi.most@orkes.io>
(cherry picked from commit da32513)
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.
🎟️ Tracking
Fixes #1639 (CodeQL alert #4,
java/path-injection), sub-issue of #1010.Approach taken from @nthmost-orkes on the issue:
📔 Objective
DummyPayloadStorageresolved the caller-suppliedpathagainstpayloadDirwith no validation, at both call sites:A
../-style path could therefore read or write outside the payload directory. This is the default payload storage when no external storage is configured, so it is reachable in dev and default deployments.This adds a private
validateAndResolvePathmirroring the one merged in #723: normalize withPaths.get(path).normalize(), reject a normalized path that still contains.., then verify the canonical path remains withinpayloadDir, throwingSecurityExceptionon escape. Both methods route through it.Following #723, both call sites catch
SecurityExceptionrather than letting it propagate, sodownload()keeps returningnullon failure and the existing contract is unchanged.upload()also gained thegetParentFile().mkdirs()from that same fix, which is what makes legal nested paths work.Validation
DummyPayloadStorageTestgains three cases — traversal rejected on read, traversal rejected on write, and a nested path still round-tripping.6/6pass in:conductor-core:test, and:conductor-core:spotlessApplyreports no changes.I also checked the helper's logic in isolation against the case the
..string check cannot see on its own — a symlink insidepayloadDirpointing outside it. The canonical-path comparison rejects it, so both halves of the guard are load-bearing. Absolute paths such as/etc/passwdare contained rather than rejected, sinceFile(parent, child)treats an absolute child as relative and resolves it underpayloadDir.The sibling implementations in the same package already raise constant messages with no path values embedded, so nothing here logs the rejected path beyond the existing
LOGGER.errorcall sites.