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 double URL encoding, path traversal sequences, or symbolic links. Node.js's path.join() does not resolve . and .. components or symlinks, making string-based validation insufficient.
Primary Defence: Use path.resolve() to canonicalize all user-supplied paths to absolute form, perform single-pass URL decoding with decodeURIComponent() to prevent double-encoding attacks, verify the resolved path stays within the allowed directory using path.relative() (checking the result doesn't start with .. or is absolute), validate file type with fs.stat().isFile(), and reject filenames containing path separators.
Common Vulnerable Patterns
URL Decoding Without Double-Encoding Protection
const express = require('express');
const fs = require('fs');
const path = require('path');
app.get('/file', (req, res) => {
const filename = req.query.name;
const filepath = path.join('/app/data', filename);
// Only decodes once - double encoding bypasses
const decoded = decodeURIComponent(filename);
if (decoded.includes('..')) {
return res.status(403).send('Invalid path');
}
// Attack: name=%252e%252e%252f%252e%252e%252fetc%252fpasswd
// First decode: %2e%2e%2f%2e%2e%2fetc%2fpasswd (passes check)
// Second decode by filesystem: ../../etc/passwd (traversal!)
fs.readFile(filepath, (err, data) => {
if (err) return res.status(404).send('Not found');
res.send(data);
});
});
Why this is vulnerable:
- Double URL encoding bypasses single decoding check:
%252edecodes to%2e, which the filesystem decodes to. - Validation occurs on first decoded version, but file system receives the original encoded version
path.join()doesn't canonicalize 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');
}
// Decode URL encoding (only once - prevents double encoding)
try {
filename = decodeURIComponent(filename);
} catch (e) {
return res.status(400).send('Invalid encoding');
}
// Construct and resolve to absolute canonical 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.startsWith('..') || 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.startsWith('..') || 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:
- Single-pass URL decoding prevents double-encoding bypass attacks
path.resolve()canonicalizes path, resolving.,.., and normalizing separatorspath.relative()check verifies resolved path doesn't escape allowed directoryfs.realpath()resolves symlinks and re-validates the real target stays under the allowed directory- Validates file type to prevent directory or special file access
- All checks use canonical paths, eliminating path equivalence vulnerabilities
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