Skip to content
highCVSS 7.5CWE-22A01:2021 – Broken Access Control

Node.js

No stream-wrapper layer, so no php://filter equivalent — but path.join collapses .. before the filesystem sees it, which is where the escape actually happens.

What Node does and does not have

fs takes filesystem paths. There is no wrapper layer, so php://filter, data:// and zip:// are all just filenames with colons in them. Everything in Escalation to RCE is unavailable.

What Node does have is require() and dynamic import(), both of which load and execute code by name. require() resolves filesystem paths only, so it is a local inclusion sink. Dynamic import() takes a URL specifier and accepts data:, which makes it the one genuinely remote code-loading sink in the runtime — see Remote Include Outside PHP.

The common bug is more mundane: traversal through path.join.

path.join collapses before the filesystem sees it

JavaScriptthe mistakeVulnerable
const path = require('path')
const fs = require('fs')

app.get('/view', (req, res) => {
  // path.join normalises as it joins, so the traversal is resolved HERE,
  // in the join, before fs ever runs. A check on req.query.page would be
  // checking a string that no longer describes what will be opened.
  const p = path.join(__dirname, 'views', req.query.page)
  //  page = ../../etc/passwd   ->  p = '/etc/passwd'
  res.send(fs.readFileSync(p, 'utf8'))
})

// The instructive failure is checking AFTER the join but with the wrong
// comparison:
const p = path.join(root, req.query.page)
if (p.startsWith(root)) { /* ... */ }
//   root = '/app/views'
//   page = '../views-backup/secret'  ->  '/app/views-backup/secret'
//   startsWith('/app/views') is TRUE.  Compare against root + path.sep.

The fix

JavaScriptSecure
// The parameter is a key. Same answer as every other runtime.
const VIEWS = {
  home: 'home.html',
  about: 'about.html',
  contact: 'contact.html',
}

app.get('/view', (req, res) => {
  const file = VIEWS[req.query.page]
  if (!file) return res.sendStatus(404)
  res.sendFile(path.join(__dirname, 'views', file))
})

require() and dynamic import()

JavaScriptthe execute sinksVulnerable
// require() with anything request-derived is code execution — the resolved
// module is executed, not read.
const handler = require('./handlers/' + req.query.type)
//   type = '../../../../tmp/uploaded'   -> runs /tmp/uploaded.js
//   type = '../package.json'            -> JSON is parsed, not run
//
// require() resolves filesystem paths only. A URL is not a module
// specifier it understands, so there is no remote variant here.

// Dynamic import() is different: the specifier is a URL, and data: works.
await import(req.query.mod)
//   mod = data:text/javascript,console.log(1)   -> executes
//
// http(s): specifiers need the experimental network-imports flag and are
// not available by default. data: needs no flag. That is the exposure.

// The fix is a map, as always:
const HANDLERS = { csv: () => require('./handlers/csv'), json: () => require('./handlers/json') }
const load = HANDLERS[req.query.type]
if (!load) return res.sendStatus(400)
const handler = load()

Zip Slip

JavaScriptthe write-side variantVulnerable
// The traversal is in the ARCHIVE, not in a request parameter. An entry
// named ../../../../app/routes/index.js overwrites application code when
// extracted, which is inclusion's write-side twin.

for (const entry of archive.entries) {
  // Bug: entry.name is attacker-controlled and joined without confinement.
  fs.writeFileSync(path.join(dest, entry.name), entry.data)
}

// Fix: resolve and confine every entry before writing, exactly as for a
// read.
const DEST = path.resolve(dest)
for (const entry of archive.entries) {
  const out = path.resolve(DEST, entry.name)
  if (!out.startsWith(DEST + path.sep)) throw new Error('zip slip: ' + entry.name)
  fs.writeFileSync(out, entry.data)
}

// Also reject entries that are symlinks, which escape without containing
// any .. at all.

What to grep for

Bash
# Execute sinks
grep -rnE '\b(require|import)\s*\(' --include='*.js' --include='*.ts' . \
  | grep -E 'req\.|request\.|params|query|body'

# Read sinks
grep -rnE 'fs\.(readFile|readFileSync|createReadStream|open|openSync)' . \
  | grep -E 'req\.|params|query|body'

# sendFile without a root option — the root is what does the confining
grep -rn 'sendFile' . | grep -v 'root'

# Manual joins involving request data
grep -rnE 'path\.(join|resolve)\([^)]*req\.' .

# Archive extraction — zip slip
grep -rnE '(unzip|extract|tar|adm-zip|yauzl|decompress)' package.json