Description
Using user input directly as a filesystem path may let an attacker access files outside the intended directory. Inputs such as ../, absolute paths (/, C:\), or symbolic links can redirect access. Depending on the operation and process permissions, an attacker may read system files such as /etc/passwd, overwrite configuration or logs, or delete files.
Potential impact
- Disclosure of system files, source code, or configuration such as
.env - Overwriting or deleting uploaded files, logs, or configuration
- Indirect privilege escalation if attacker-controlled content is written to a location that is later executed
- Service disruption from deletion or corruption of essential files
Remediation
- Avoid deriving paths directly from user input. Canonicalize with
path.resolveand verify the boundary.- Resolve the existing path, for example with
const resolved = fs.realpathSync(path.resolve(SAFE_ROOT, input));. - Reject results outside the canonical root. A prefix check must include
SAFE_ROOT + path.septo distinguish adjacent directory names.
- Resolve the existing path, for example with
- If only a filename is needed, use an allow-list,
sanitize-filename, or strict format validation such as^[a-zA-Z0-9._-]{1,100}$. - Reject absolute paths,
.., and empty values where they are not permitted. - Resolve existing symbolic links with
realpath. Files can still change afterrealpathorfs.lstatchecks, so protect the root and descendant paths from attacker modification. - Minimize filesystem permissions and do not expose real paths in error responses.
- Keep a fixed upload or download root (
SAFE_ROOT) and reject access outside it.
Examples
Before
javascript
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
// Before: joining input alone permits traversal with ..
app.get('/view', (req, res) => {
const name = req.query.f; // Example: ../../etc/passwd
const unsafePath = path.join(__dirname, 'public', name);
fs.createReadStream(unsafePath)
.on('error', () => res.status(404).end('not found'))
.pipe(res);
});
app.listen(3000);
After
javascript
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
// SAFE_ROOT must be server-managed and not modifiable by attackers
const SAFE_ROOT = fs.realpathSync(path.resolve(__dirname, 'public'));
function isInside(baseDir, targetPath) {
const relative = path.relative(baseDir, targetPath);
return (
relative !== '' &&
relative !== '..' &&
!relative.startsWith('..' + path.sep) &&
!path.isAbsolute(relative)
);
}
app.get('/view', (req, res) => {
const name = req.query.f;
if (typeof name !== 'string') {
return res.status(400).send('parameter f is required');
}
// Normalize the path and resolve symbolic links
const candidate = path.resolve(SAFE_ROOT, name);
let resolved;
try {
resolved = fs.realpathSync(candidate);
} catch {
return res.status(404).end('not found');
}
// Check the boundary: allow only descendants of SAFE_ROOT
if (!isInside(SAFE_ROOT, resolved)) {
return res.status(403).end('forbidden');
}
// Use only the validated path
const stream = fs.createReadStream(resolved);
stream.on('error', () => res.status(404).end('not found'));
stream.pipe(res);
});
app.listen(3000);
Explanation:
- Before:
fis joined to a path without validation.path.joinnormalizes..but does not guarantee that the result remains inside the base directory. - After: A string input is resolved relative to
SAFE_ROOT, andfs.realpathSyncresolves symbolic links.path.relativechecks that the actual path is a descendant of the server-managed root. That directory structure must remain trusted between validation and opening; these checks alone do not eliminate races if an attacker can modify descendant paths.