CWE-35: Path Equivalence - JavaScript/Node.js
Overview
Path equivalence vulnerabilities in Node.js applications occur when user-supplied paths are validated using string operations without canonicalization, allowing attackers to bypass restrictions through encoding mismatches, path traversal sequences, or symbolic links. Node.js's path.join() and path.resolve() normalize . and .. path syntax, but they do not enforce containment or resolve symlinks, so string-based validation remains insufficient.
Primary Defence: Use path.resolve() to convert user-supplied paths to an absolute normalized form, decode raw URL components at most once before validation when your framework has not already decoded them, verify the resolved path stays within the allowed directory using path.relative() (checking the result is not exactly .., does not start with .. plus a path separator, and is not absolute), validate file type with fs.stat().isFile(), and reject filenames containing path separators when only a filename is expected.
Common Vulnerable Patterns
Validation Before Decoding
const express = require('express');
const fs = require('fs');
const path = require('path');
function getRawQueryValue(url, key) {
const query = url.split('?', 2)[1] || '';
const pair = query.split('&').find(p => p.startsWith(`${key}=`));
return pair ? pair.slice(key.length + 1) : '';
}
app.get('/file', (req, res) => {
// Example for applications that parse raw URL components themselves.
const rawName = getRawQueryValue(req.url, 'name');
// VULNERABLE - validation happens before decoding
if (rawName.includes('..')) {
return res.status(403).send('Invalid path');
}
const decoded = decodeURIComponent(rawName);
const filepath = path.join('/app/data', decoded);
// Attack: name=%2e%2e%2f%2e%2e%2fetc%2fpasswd
// Raw check passes, decodeURIComponent produces ../../etc/passwd
fs.readFile(filepath, (err, data) => {
if (err) return res.status(404).send('Not found');
res.send(data);
});
});
Why this is vulnerable:
- Validation occurs before decoding, so encoded separators and
..segments are not checked in the form used for file access - Repeated or inconsistent decoding between layers can create similar bypasses
path.join()normalizes syntax but does not enforce that the result stays inside the intended base directory or resolve symlinks- No verification that resolved path stays within allowed directory
Secure Patterns
Single-Pass Decoding with Canonicalization
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const ALLOWED_DIR = path.resolve('/app/data');
app.get('/file', async (req, res) => {
let filename = req.query.name;
if (!filename) {
return res.status(400).send('Filename required');
}
// If filename came from a framework-decoded query/body parameter, do not decode again.
// If it came from a raw URL component, decode exactly once before validation.
// Construct and resolve to absolute normalized path
const requestedPath = path.resolve(ALLOWED_DIR, filename);
// Verify resolved path is within allowed directory
// Use path.relative and check it doesn't start with '..'
const relative = path.relative(ALLOWED_DIR, requestedPath);
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
return res.status(403).send('Access denied');
}
// Resolve real paths to prevent symlink escapes
let allowedRealPath;
let requestedRealPath;
try {
allowedRealPath = await fs.realpath(ALLOWED_DIR);
requestedRealPath = await fs.realpath(requestedPath);
} catch (err) {
return res.status(404).send('File not found');
}
const realRelative = path.relative(allowedRealPath, requestedRealPath);
if (realRelative === '..' || realRelative.startsWith('..' + path.sep) || path.isAbsolute(realRelative)) {
return res.status(403).send('Access denied');
}
// Verify file exists and is a file
try {
const stats = await fs.stat(requestedRealPath);
if (!stats.isFile()) {
return res.status(404).send('Not a file');
}
} catch (err) {
return res.status(404).send('File not found');
}
// Read and send file
try {
const data = await fs.readFile(requestedRealPath);
res.send(data);
} catch (err) {
res.status(500).send('Error reading file');
}
});
Why this works:
- Decode raw URL components at most once before validation; do not validate one representation and open another
path.resolve()resolves.,.., and normalizes separators to an absolute pathpath.relative()check verifies the resolved path does not escape the allowed directory without rejecting same-prefix sibling namesfs.realpath()resolves symlinks and re-validates the real target stays under the allowed directory- Validates file type to prevent directory or special file access
- File access uses the validated real path, reducing path equivalence and symlink bypasses for existing files
Additional Resources
- CWE-35: Path Equivalence
- Node.js Path Module Documentation - path.resolve(), path.relative(), path.normalize()
- Node.js File System Module - fs.stat(), fs.readFile()
- OWASP Path Traversal