Skip to content

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: %252e decodes 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 separators
  • path.relative() check verifies resolved path doesn't escape allowed directory
  • fs.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