-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(cli): prevent external node_modules deletion with --prefer-local (return alias; unlink symlinks) #1349
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(cli): prevent external node_modules deletion with --prefer-local (return alias; unlink symlinks) #1349
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
src/cli.ts
Outdated
|
|
||
| const rmrf = (p: string) => p && fs.rmSync(p, { force: true, recursive: true }) | ||
| // Safer remove: unlink symlinks, recurse only for real dirs/files | ||
| const safeRemove = (p: string) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Seems good, but let's shorten a bit:
const rmrf = (p: string) => {
if (!p) return
try {
if (fs.lstatSync(p).isSymbolicLink()) {
fs.unlinkSync(p)
} else {
fs.rmSync(p, { force: true, recursive: true })
}
} catch {}
}- Apply
npm buildto update bundles too. We keep them in repo. - Run tests. Update
size-limitif necessary.
Given the impact (external deletion of an attacker-controlled /node_modules outside the current working directory when --prefer-local= is supplied), I believe this qualifies as a security vulnerability rather than a regular bug: Impact: Data loss / DoS of an external project directory chosen via --prefer-local. Trigger: Running zx (incl. via npx) with an attacker-influenced --prefer-local value. Scope: Deletion happens outside the current repo dir (symlink cleanup was resolving to the target). Root cause: Incorrect path used on cleanup (alias vs target). See fix returning alias + unlinking symlinks. CWE: CWE-706 (Use of Incorrectly-Resolved Name or Reference); alternatively CWE-59 (Link Following). CVSS v3.1 (proposed): CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H → 7.1 (High). Repro: The four-step script in the PR description reliably shows DELETED instead of expected PRESENT. If you’re open to it, I suggest we coordinate a GitHub Security Advisory and request a CVE (GitHub can assign one during advisory publication). I’m happy to help with the write-up or provide more detail. Suggested advisory metadata: Title: zx: --prefer-local symlink cleanup may delete external node_modules Affected versions: confirmed on 8.8.3 (likely earlier; not exhaustively tested). Patched by: this PR (return alias; unlink symlink on cleanup). Mitigations: avoid untrusted --prefer-local values; run without the flag; isolate builds. Credit: Ali Firas (The Smart Shadow) – @thesmartshadow. Please let me know if you prefer that I file a private “Report a vulnerability” and add you as collaborators, or if you’d like to open the advisory on your side and add me for coordination. Thanks again! |
build/cli.cjs
Outdated
| if (import_index.fs.existsSync(alias) || !import_index.fs.existsSync(target)) return ""; | ||
| import_index.fs.symlinkSync(target, alias, "junction"); | ||
| return target; | ||
| return alias; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This mutates the behaviour probably.
24eea9e to
623f902
Compare
|
f334ca7 to
b66d2cd
Compare
|
Rebuilt bundles, adjusted size-limit slightly, and re-signed the tip commit with SSH. Latest commit is signed/verified. |
7ccfad8 to
b66d2cd
Compare
|
I dug into the issue and realized more changes are needed. Let's do it this way: rollback to 6e744c3, merge it as is, and I'll finish the rest in another PR. |
b66d2cd to
6e744c3
Compare
|
Thanks, @antongolub done. I’ve force-pushed the branch back to
Let me know if you want me to help with any follow-ups or the advisory write-up. |
|
Not every bug is a CVE.
|
|
6e744c3 to
bd88e07
Compare
|
@antongolub thanks!
|
test/export.test.js
Outdated
| assert.equal(typeof index.fs.Stats, 'function', 'index.fs.Stats') | ||
| assert.equal(typeof index.fs.W_OK, 'number', 'index.fs.W_OK') | ||
| assert.equal(typeof index.fs.WriteStream, 'function', 'index.fs.WriteStream') | ||
| assert.equal(typeof index.fs.X_OK, 'number', 'index.fs.X_OK') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This breaks tests. Revert this part.
|
Ready to finalize this? Just one more step. |
|
Thanks @antongolub I checked the current test/export.test.js in this branch and the index.fs.W_OK / index.fs.X_OK assertions are not present anymore (it looks like they were already reverted). Could you point me to the exact commit/lines you saw? I can restore the file from upstream/main and force-push a signed commit if you'd prefer me to revert to upstream's version. |
fa848f2 to
1969a8e
Compare
|
Done. I addressed both points:
Extra context:
|
…with --prefer-local build: regenerate bundles chore(size-limit): adjust thresholds slightly (cherry picked from commit bd88e07)
(cherry picked from commit 1969a8e)
545a748 to
810c63f
Compare
|
@antongolub thanks for the review
If you prefer a squash into a single signed commit or any further tweaks happy to do it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR fixes a critical security vulnerability where the --prefer-local flag could cause zx to delete external node_modules directories outside the current working directory. The root cause was that linkNodeModules returned the target path instead of the symlink alias, causing cleanup to remove the wrong directory.
- Modified cleanup logic to safely handle symlinks by unlinking them instead of recursively deleting
- Updated the cleanup path assignment to use the symlink alias rather than the target directory
- Maintained backward compatibility by keeping the original
linkNodeModulesreturn value
Reviewed Changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| src/cli.ts | Fixed symlink cleanup logic and path handling for --prefer-local flag |
| build/cli.cjs | Compiled JavaScript output reflecting the TypeScript changes |
| .size-limit.json | Updated size limits to accommodate the additional cleanup logic |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
| fs.lstatSync(p).isSymbolicLink() | ||
| ? fs.unlinkSync(p) | ||
| : fs.rmSync(p, { force: true, recursive: true }) | ||
| } catch {} |
Copilot
AI
Oct 14, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The empty catch block silently suppresses all errors. Consider logging the error or at least adding a comment explaining why errors are intentionally ignored.
| } catch {} | |
| } catch (err) { | |
| // Ignore errors during cleanup, but log for debugging purposes | |
| console.warn(`rmrf: Failed to remove "${p}":`, err); | |
| } |
| } catch {} | ||
| } |
Copilot
AI
Oct 14, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The empty catch block silently suppresses all errors. Consider logging the error or at least adding a comment explaining why errors are intentionally ignored.
| } catch {} | |
| } | |
| } catch (err) { | |
| console.error('Error while checking node_modules symlink:', err); | |
| } |
| if (!p) return; | ||
| try { | ||
| import_index.fs.lstatSync(p).isSymbolicLink() ? import_index.fs.unlinkSync(p) : import_index.fs.rmSync(p, { force: true, recursive: true }); | ||
| } catch (e) { |
Copilot
AI
Oct 14, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The empty catch block silently suppresses all errors. Consider logging the error or at least adding a comment explaining why errors are intentionally ignored.
| } catch (e) { | |
| } catch (e) { | |
| // Ignore errors (e.g., file may not exist), but log in verbose mode for debugging. | |
| if (import_index && import_index.$ && import_index.$.verbose) { | |
| console.error("rmrf error:", e); | |
| } |
| } else { | ||
| nmLink = ""; | ||
| } | ||
| } catch (e) { |
Copilot
AI
Oct 14, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The empty catch block silently suppresses all errors. Consider logging the error or at least adding a comment explaining why errors are intentionally ignored.
| } catch (e) { | |
| } catch (e) { | |
| // Log the error to avoid silent failure | |
| console.error("Error while checking node_modules symlink:", e); |
|
Kind request for acknowledgment in the next release notes: |
|
Regarding a security advisory / CVE: |
|
Hi @antongolub @antonmedv now that 8.8.5 is released and explicitly references #1348/#1349/#1355, could you please open a GitHub Security Advisory for coordinated disclosure and (optionally) request a CVE via GHSA? I’m happy to supply the CWE-59, CVSS, affected ranges (≤ 8.8.4; fixed in 8.8.5), and PoC details. Refs: issue #1348, PRs #1349/#1355, commits 6914c20 / bd88e07 / a4d1bc2, PoC gist. |


Summary
When zx is invoked with
--prefer-local=<path>, the CLI creates a symlink./node_modulespointing to<path>/node_modules. Due to a logic error insrc/cli.ts(linkNodeModules / cleanup), the function returns the target path instead of the alias (symlink path). The later cleanup removes what it received, which deletes the target directory itself. Result: zx can delete an external<path>/node_modulesoutside the current working directory.Impact
Arbitrary deletion of an external
node_modulesdirectory chosen via the--prefer-localvalue (Data loss / DoS). This can break builds, CI agents, or developer projects.Affected file / component
src/cli.ts(linkNodeModules + cleanup path handling)Reproduction
node_modules:rm -rf /tmp/victim && mkdir -p /tmp/victim/node_modules
echo KEEP_ME > /tmp/victim/node_modules/proof.txt
rm -rf /tmp/clean-npx && mkdir -p /tmp/clean-npx && cd /tmp/clean-npx
npx [email protected] --prefer-local=/tmp/victim -e "console.log('run')"
test -e /tmp/victim/node_modules && echo PRESENT || echo DELETED
=> Expected: PRESENT; Actual: DELETED
PoC
GitHub secret gist: https://gist.github.com/thesmartshadow/f0bcde29379bda5b5b044abe689b176f
Root cause
linkNodeModules(cwd, external)creates a symlink:alias = resolve(cwd, 'node_modules')
target = resolve(external, 'node_modules')
but returns target instead of alias. Later, cleanup removes the path it received, so it deletes target (the real external directory) rather than unlinking the alias.
Fix (in this PR)
linkNodeModules.unlinkit; only recurse-remove real directories.Security classification (for context)
Notes
Researcher credit: Ali Firas (The Smart Shadow)