CVE-2025-13437
Description
When zx is invoked with --prefer-local=<path>, the CLI creates a symlink named ./node_modules pointing to <path>/node_modules. Due to a logic error in src/cli.ts (linkNodeModules / cleanup), the function returns the target path instead of the alias (symlink path). The later cleanup routine removes what it received, which deletes the target directory itself. Result: zx can delete an external <path>/node_modules outside the current working directory.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
zxnpm | < 8.8.5 | 8.8.5 |
Affected products
1Patches
2a4d1bc2467f3fix: checks `node_modules` ref on linking (#1355)
4 files changed · +127 −59
build/cli.cjs+19 −16 modified@@ -218,11 +218,9 @@ function main() { }); } var rmrf = (p) => { + var _a2; 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) { - } + ((_a2 = lstat(p)) == null ? void 0 : _a2.isSymbolicLink()) ? import_index.fs.unlinkSync(p) : import_index.fs.rmSync(p, { force: true, recursive: true }); }; function runScript(script, scriptPath, tempPath) { return __async(this, null, function* () { @@ -238,16 +236,7 @@ function runScript(script, scriptPath, tempPath) { } const cwd = import_index.path.dirname(scriptPath); if (typeof argv.preferLocal === "string") { - linkNodeModules(cwd, argv.preferLocal); - try { - const aliasPath = import_index.path.resolve(cwd, "node_modules"); - if (import_index.fs.existsSync(aliasPath) && import_index.fs.lstatSync(aliasPath).isSymbolicLink()) { - nmLink = aliasPath; - } else { - nmLink = ""; - } - } catch (e) { - } + nmLink = linkNodeModules(cwd, argv.preferLocal); } if (argv.install) { yield (0, import_deps.installDeps)((0, import_deps.parseDeps)(script), cwd, argv.registry); @@ -264,9 +253,23 @@ function linkNodeModules(cwd, external) { const nm = "node_modules"; const alias = import_index.path.resolve(cwd, nm); const target = import_index.path.basename(external) === nm ? import_index.path.resolve(external) : import_index.path.resolve(external, nm); - if (import_index.fs.existsSync(alias) || !import_index.fs.existsSync(target)) return ""; + const aliasStat = lstat(alias); + const targetStat = lstat(target); + if (!(targetStat == null ? void 0 : targetStat.isDirectory())) + throw new import_index.Fail( + `Can't link node_modules: ${target} doesn't exist or is not a directory` + ); + if ((aliasStat == null ? void 0 : aliasStat.isDirectory()) && alias !== target) + throw new import_index.Fail(`Can't link node_modules: ${alias} already exists`); + if (aliasStat) return ""; import_index.fs.symlinkSync(target, alias, "junction"); - return target; + return alias; +} +function lstat(p) { + try { + return import_index.fs.lstatSync(p); + } catch (e) { + } } function readScript() { return __async(this, null, function* () {
.size-limit.json+1 −1 modified@@ -66,7 +66,7 @@ "README.md", "LICENSE" ], - "limit": "874.6 kB", + "limit": "874.70 kB", "brotli": false, "gzip": false }
src/cli.ts+22 −28 modified@@ -128,22 +128,19 @@ export async function main(): Promise<void> { await runScript(script, scriptPath, tempPath) } -// Short & safe remove: unlink symlinks; recurse only for real dirs/files const rmrf = (p: string) => { if (!p) return - try { - fs.lstatSync(p).isSymbolicLink() - ? fs.unlinkSync(p) - : fs.rmSync(p, { force: true, recursive: true }) - } catch {} -} + lstat(p)?.isSymbolicLink() + ? fs.unlinkSync(p) + : fs.rmSync(p, { force: true, recursive: true }) +} async function runScript( script: string, scriptPath: string, tempPath: string ): Promise<void> { - let nmLink = '' // will hold the alias path (./node_modules) ONLY if it's a symlink + let nmLink = '' const rmTemp = () => { rmrf(tempPath) rmrf(nmLink) @@ -154,25 +151,9 @@ async function runScript( await fs.writeFile(tempPath, script) } const cwd = path.dirname(scriptPath) - if (typeof argv.preferLocal === 'string') { - // Keep original behaviour: linkNodeModules returns TARGET (unchanged API) - linkNodeModules(cwd, argv.preferLocal) - - // For cleanup, compute ALIAS and only unlink if it's a symlink - try { - const aliasPath = path.resolve(cwd, 'node_modules') - if ( - fs.existsSync(aliasPath) && - fs.lstatSync(aliasPath).isSymbolicLink() - ) { - nmLink = aliasPath - } else { - nmLink = '' - } - } catch {} + nmLink = linkNodeModules(cwd, argv.preferLocal) } - if (argv.install) { await installDeps(parseDeps(script), cwd, argv.registry) } @@ -194,12 +175,25 @@ function linkNodeModules(cwd: string, external: string): string { path.basename(external) === nm ? path.resolve(external) : path.resolve(external, nm) + const aliasStat = lstat(alias) + const targetStat = lstat(target) - if (fs.existsSync(alias) || !fs.existsSync(target)) return '' + if (!targetStat?.isDirectory()) + throw new Fail( + `Can't link node_modules: ${target} doesn't exist or is not a directory` + ) + if (aliasStat?.isDirectory() && alias !== target) + throw new Fail(`Can't link node_modules: ${alias} already exists`) + if (aliasStat) return '' fs.symlinkSync(target, alias, 'junction') - // Keep behaviour stable: return TARGET (not alias) - return target + return alias +} + +function lstat(p: string) { + try { + return fs.lstatSync(p) + } catch {} } async function readScript() {
test/cli.test.js+85 −14 modified@@ -175,28 +175,99 @@ describe('cli', () => { } }) - test('supports --prefer-local to load modules', async () => { - const cwd = tmpdir() - const external = tmpdir() - await fs.outputJson(path.join(external, 'node_modules/a/package.json'), { + describe('`--prefer-local`', () => { + const pkgIndex = `export const a = 'AAA'` + const pkgJson = { name: 'a', version: '1.0.0', type: 'module', exports: './index.js', - }) - await fs.outputFile( - path.join(external, 'node_modules/a/index.js'), - ` -export const a = 'AAA' -` - ) + } const script = ` import {a} from 'a' console.log(a); ` - const out = - await $`node build/cli.js --cwd=${cwd} --prefer-local=${external} --test <<< ${script}` - assert.equal(out.stdout, 'AAA\n') + + test('true', async () => { + const cwd = tmpdir() + await fs.outputFile(path.join(cwd, 'node_modules/a/index.js'), pkgIndex) + await fs.outputJson( + path.join(cwd, 'node_modules/a/package.json'), + pkgJson + ) + + const out = + await $`node build/cli.js --cwd=${cwd} --prefer-local=true --test <<< ${script}` + assert.equal(out.stdout, 'AAA\n') + assert.ok(await fs.exists(path.join(cwd, 'node_modules/a/index.js'))) + }) + + test('external dir', async () => { + const cwd = tmpdir() + const external = tmpdir() + await fs.outputFile( + path.join(external, 'node_modules/a/index.js'), + pkgIndex + ) + await fs.outputJson( + path.join(external, 'node_modules/a/package.json'), + pkgJson + ) + + const out = + await $`node build/cli.js --cwd=${cwd} --prefer-local=${external} --test <<< ${script}` + assert.equal(out.stdout, 'AAA\n') + assert.ok(await fs.exists(path.join(external, 'node_modules/a/index.js'))) + }) + + test('external alias', async () => { + const cwd = tmpdir() + const external = tmpdir() + await fs.outputFile( + path.join(external, 'node_modules/a/index.js'), + pkgIndex + ) + await fs.outputJson( + path.join(external, 'node_modules/a/package.json'), + pkgJson + ) + await fs.symlinkSync( + path.join(external, 'node_modules'), + path.join(cwd, 'node_modules'), + 'junction' + ) + + const out = + await $`node build/cli.js --cwd=${cwd} --prefer-local=true --test <<< ${script}` + assert.equal(out.stdout, 'AAA\n') + assert.ok(await fs.exists(path.join(cwd, 'node_modules'))) + }) + + test('throws if exists', async () => { + const cwd = tmpdir() + const external = tmpdir() + await fs.outputFile(path.join(cwd, 'node_modules/a/index.js'), pkgIndex) + await fs.outputFile( + path.join(external, 'node_modules/a/index.js'), + pkgIndex + ) + assert.rejects( + () => + $`node build/cli.js --cwd=${cwd} --prefer-local=${external} --test <<< ${script}`, + /node_modules already exists/ + ) + }) + + test('throws if not dir', async () => { + const cwd = tmpdir() + const external = tmpdir() + await fs.outputFile(path.join(external, 'node_modules'), pkgIndex) + assert.rejects( + () => + $`node build/cli.js --cwd=${cwd} --prefer-local=${external} --test <<< ${script}`, + /node_modules doesn't exist or is not a directory/ + ) + }) }) test('scripts from https 200', async () => {
9ef6d3c9962cfix(cli): prevent external node_modules deletion with --prefer-local (#1349)
3 files changed · +48 −7
build/cli.cjs+17 −2 modified@@ -217,7 +217,13 @@ function main() { yield runScript(script, scriptPath, tempPath); }); } -var rmrf = (p) => p && import_index.fs.rmSync(p, { force: true, recursive: true }); +var rmrf = (p) => { + 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) { + } +}; function runScript(script, scriptPath, tempPath) { return __async(this, null, function* () { let nmLink = ""; @@ -232,7 +238,16 @@ function runScript(script, scriptPath, tempPath) { } const cwd = import_index.path.dirname(scriptPath); if (typeof argv.preferLocal === "string") { - nmLink = linkNodeModules(cwd, argv.preferLocal); + linkNodeModules(cwd, argv.preferLocal); + try { + const aliasPath = import_index.path.resolve(cwd, "node_modules"); + if (import_index.fs.existsSync(aliasPath) && import_index.fs.lstatSync(aliasPath).isSymbolicLink()) { + nmLink = aliasPath; + } else { + nmLink = ""; + } + } catch (e) { + } } if (argv.install) { yield (0, import_deps.installDeps)((0, import_deps.parseDeps)(script), cwd, argv.registry);
.size-limit.json+2 −2 modified@@ -33,7 +33,7 @@ "build/globals.js", "build/deno.js" ], - "limit": "816.65 kB", + "limit": "817.2 kB", "brotli": false, "gzip": false }, @@ -66,7 +66,7 @@ "README.md", "LICENSE" ], - "limit": "874.15 kB", + "limit": "874.6 kB", "brotli": false, "gzip": false }
src/cli.ts+29 −3 modified@@ -128,13 +128,22 @@ export async function main(): Promise<void> { await runScript(script, scriptPath, tempPath) } -const rmrf = (p: string) => p && fs.rmSync(p, { force: true, recursive: true }) +// Short & safe remove: unlink symlinks; recurse only for real dirs/files +const rmrf = (p: string) => { + if (!p) return + try { + fs.lstatSync(p).isSymbolicLink() + ? fs.unlinkSync(p) + : fs.rmSync(p, { force: true, recursive: true }) + } catch {} +} + async function runScript( script: string, scriptPath: string, tempPath: string ): Promise<void> { - let nmLink = '' + let nmLink = '' // will hold the alias path (./node_modules) ONLY if it's a symlink const rmTemp = () => { rmrf(tempPath) rmrf(nmLink) @@ -145,9 +154,25 @@ async function runScript( await fs.writeFile(tempPath, script) } const cwd = path.dirname(scriptPath) + if (typeof argv.preferLocal === 'string') { - nmLink = linkNodeModules(cwd, argv.preferLocal) + // Keep original behaviour: linkNodeModules returns TARGET (unchanged API) + linkNodeModules(cwd, argv.preferLocal) + + // For cleanup, compute ALIAS and only unlink if it's a symlink + try { + const aliasPath = path.resolve(cwd, 'node_modules') + if ( + fs.existsSync(aliasPath) && + fs.lstatSync(aliasPath).isSymbolicLink() + ) { + nmLink = aliasPath + } else { + nmLink = '' + } + } catch {} } + if (argv.install) { await installDeps(parseDeps(script), cwd, argv.registry) } @@ -173,6 +198,7 @@ function linkNodeModules(cwd: string, external: string): string { if (fs.existsSync(alias) || !fs.existsSync(target)) return '' fs.symlinkSync(target, alias, 'junction') + // Keep behaviour stable: return TARGET (not alias) return target }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-w87r-vg9q-crqmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-13437ghsaADVISORY
- github.com/google/zx/commit/9ef6d3c9962c4ba01e3fb8075855570c192b4681ghsaWEB
- github.com/google/zx/commit/a4d1bc2467f305f1c91d62506e215f307dc1fbebghsaWEB
- github.com/google/zx/issues/1348nvdWEB
- github.com/google/zx/pull/1349ghsaWEB
- github.com/google/zx/pull/1355ghsaWEB
News mentions
0No linked articles in our index yet.