fix: prevent symlink traversal during retrieve (partial-delete and writes) @W-24124120@ @W-24138711@ - #1833
fix: prevent symlink traversal during retrieve (partial-delete and writes) @W-24124120@ @W-24138711@#1833WillieRuemmele wants to merge 6 commits into
Conversation
…4124120@ Use lstatSync instead of statSync to reject symlinked content directories from partial-delete processing. Filter out symlinks within content directories before deletion. Add defense-in-depth symlink check in deleteFilePath.
QA InstructionsSetup
Test 1: Symlinked content directory is NOT followed during partial-delete (W-24124120)
Test 2: Normal partial-delete still works (no false positives)
Test 3: Symlinked file blocks retrieve writes (W-24138711)
Test 4: Symlinked directory blocks retrieve writes (W-24138711)
Test 5: Normal retrieve writes work (no false positives)
|
Extract findSymlinkOnPath to shared fileSystemHandler utility and add symlink traversal protection to StandardWriter._write() — covers both the write and delete paths for all metadata types during retrieve.
Resolves conflicts in streams.ts (combined symlink + path traversal imports), streams.test.ts (kept both symlink and path traversal tests), and eslint-suppressions.json (combined _write dangle counts).
| (fr: FileResponseSuccess): FileResponseSuccess => { | ||
| if (fr.filePath) { | ||
| if (isSymlinkSync(fr.filePath)) { | ||
| logger.debug(`Skipping delete of symlink ${fr.filePath} to prevent modification of files outside the project.`); |
There was a problem hiding this comment.
logger.warn might me more appropriate for this.
|
🔴 Partial-delete still deletes outside the project when a bundle ancestor dir is a symlink (W-24124120 not fully closed) The delete path only guards the leaf (the last segment of the path), so the containment boundary can still be escaped by a symlinked ancestor directory. The gap
Neither inspects an ancestor of the content root. Local resolution uses The write-path guard would catch the symlinked ancestor (it walks all segments), but it throws only later in Repro (confirmed against this branch) End-to-end through the real The delete precedes the write abort, so the write-path guard does not protect against this. (I have a failing mocha I can share) Suggested fix Give the delete path the same full-path protection the write path already has. In |
The delete path only checked the leaf segment for symlinks, so a symlinked ancestor directory (e.g. digitalExperiences/ → external) could let rmSync delete files outside the project before the write-path guard fired. - Add findSymlinkOnPathSync to walk all segments from package root - Use it in the pre-filter (before FileResponse creation) and as defense-in-depth in deleteFilePath - Guard both findSymlinkOnPath variants against destinations that resolve outside the root (.. traversal) - Remove now-dead isSymlinkSync helper
Exercises the exact gap Eric identified: a DEB project where digitalExperiences/ is a symlink to an external directory. Verifies findSymlinkOnPathSync catches the ancestor symlink and the external file survives.
Summary
lstatSyncinstead ofstatSyncwhen checking if component content paths are directories, preventing symlinked content directories from entering the partial-delete flow. Filter out symlinks within content directories before deletion and add defense-in-depth symlink check indeleteFilePath.findSymlinkOnPathcheck inStandardWriter._write()before every file write and delete during retrieve, covering all metadata types. If any path segment between the project root and the output file is a symbolic link, the operation is rejected with a clear error.findSymlinkOnPathfromStaticResourceMetadataTransformertofileSystemHandler.tsso it can be reused across the partial-delete, write, and static resource extraction paths.Security Context
During
sf project retrieve start, two distinct I/O paths followed symbolic links without validation:supportsPartialDeletetypes (e.g. DigitalExperienceBundle), the logic would follow symlinks when enumerating and deleting local-only content. If a content directory was a symlink to an external path, retrieve could delete files outside the Salesforce project.StandardWriterthat writes all retrieved metadata to disk would follow symlinks in the output path. A symlinked file or directory inside the project (e.g. planted in a shared Git repo) could redirect writes to overwrite files outside the workspace.Both attack vectors require a crafted project (e.g. a malicious Git repo with preserved symlinks) that a victim clones and retrieves against.
@W-24124120@ @W-24138711@
Proof of Work
retrieveExtract.test.ts, 3 instreams.test.ts, 4 infileSystemHandler.test.ts)Test plan