From 8b89d0ac6e58205a2fdbd3dff5d7cce091fba813 Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Wed, 15 Oct 2025 15:55:13 +0200 Subject: [PATCH 1/7] feat: first draft of new article --- .../nodejs-path-traversal-security/index.md | 689 ++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100644 src/content/blog/nodejs-path-traversal-security/index.md diff --git a/src/content/blog/nodejs-path-traversal-security/index.md b/src/content/blog/nodejs-path-traversal-security/index.md new file mode 100644 index 0000000..ac6e036 --- /dev/null +++ b/src/content/blog/nodejs-path-traversal-security/index.md @@ -0,0 +1,689 @@ +--- +date: 2025-10-15T10:00:00 +updatedAt: 2025-10-15T10:00:00 +title: How to Protect Your Node.js Web Server from Path Traversal Vulnerabilities +slug: nodejs-path-traversal-security +description: Learn how to detect and prevent path traversal attacks in Node.js. From understanding the vulnerability to building secure file servers with modern APIs, this comprehensive guide covers everything you need to protect your applications. +authors: ['luciano-mammino'] +tags: ['blog'] +--- + +Building on our [Node.js File Operations Guide](/blog/reading-writing-files-nodejs/), let's explore one of the most critical security vulnerabilities in web applications: **path traversal attacks**. + +--- + +## Introduction: When Simple File Serving Becomes Dangerous + +You've built a Node.js application that serves user-uploaded images. The implementation is clean, efficient, and uses modern streaming APIs. But what happens when a malicious user requests `../../etc/passwd` instead of `cat.jpg`? Suddenly, your simple file server becomes a gateway to your entire filesystem. + +This article will guide you through understanding, detecting, and preventing path traversal attacks in Node.js web servers. We'll start with a vulnerable implementation, demonstrate how attackers exploit it, and then build a secure solution using modern Node.js APIs. + +--- + +## Understanding Path Traversal Vulnerabilities + +### What is a Path Traversal Attack? + +A **path traversal** (also known as **directory traversal**) attack is a security vulnerability that allows an attacker to access files and directories stored outside the intended web root folder. By manipulating variables that reference files with "dot-dot-slash (../)" sequences and variations, an attacker can read arbitrary files on the server. + +### Why Are These Attacks Dangerous? + +Path traversal vulnerabilities can lead to: + +1. **Information Disclosure**: Attackers can read sensitive files like configuration files, database credentials, or private keys. +2. **System Compromise**: In some cases, attackers might access system files that reveal information about the server's architecture. +3. **Data Theft**: Access to application data files could lead to data breaches. +4. **Lateral Movement**: Information gained from these attacks can help attackers plan further attacks on your system. + +### The Anatomy of a Path Traversal Attack + +Path traversal attacks typically follow these steps: + +1. **Identify Vulnerability**: The attacker discovers that user input is used to construct file paths. +2. **Craft Payload**: The attacker creates a payload with directory traversal sequences. +3. **Exploit**: The attacker sends the payload to the server. +4. **Access Files**: If successful, the attacker can now access files outside the intended directory. + +--- + +## The Vulnerable Implementation: A Naive Image Server + +Let's begin with a common but vulnerable implementation of an image server: + +```js +import { createServer } from 'node:http' +import { createReadStream } from 'node:fs' +import path from 'node:path' + +// VULNERABLE: Do not use in production +const server = createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`) + + // Extract user-provided path (e.g., /images/cats/kitty.jpg) + const rel = url.pathname.replace(/^\/images\//, '') + + // DANGEROUS: Directly joining user input with our directory + const filePath = path.join(process.cwd(), 'uploads', rel) + + // Set content type based on file extension + const ext = path.extname(filePath).toLowerCase() + const type = + ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : + ext === '.png' ? 'image/png' : + ext === '.gif' ? 'image/gif' : + 'application/octet-stream' + + const stream = createReadStream(filePath) + stream.once('open', () => { + res.writeHead(200, { 'Content-Type': type }) + stream.pipe(res) + }) + stream.once('error', () => { + res.writeHead(404) + res.end('Image not found') + }) +}) + +server.listen(3000, () => { + console.log('Image server running at http://localhost:3000') +}) +``` + +### Why This Code is Vulnerable + +Let's break down the security issues in this implementation: + +1. **Unvalidated User Input**: The code directly uses `url.pathname` without any validation. +2. **Unsafe Path Construction**: `path.join()` simply concatenates paths without checking if the result stays within the intended directory. +3. **No Boundary Checking**: There's no verification that the final path is still within the `uploads` directory. +4. **No Input Sanitization**: Special characters like `../` are not filtered or handled safely. + +When a user requests `/images/../../etc/passwd`, the code: +1. Extracts `../../etc/passwd` from the URL +2. Joins it with the current working directory and `uploads` +3. Results in a path like `/home/user/myapp/uploads/../../etc/passwd` +4. Which resolves to `/home/user/etc/passwd` - completely outside our intended directory! + +:::warning[Real-World Impact] +This exact pattern has led to serious security breaches in production applications. Attackers can use path traversal to: +- Read `/etc/passwd` to enumerate system users +- Access `.env` files containing API keys and database credentials +- Read private SSH keys from `~/.ssh/id_rsa` +- Access application source code to find more vulnerabilities +::: + +--- + +## The Attack: Common Exploitation Techniques + +### Understanding the Vulnerability in Action + +A path traversal attack occurs when user input is used to construct file paths without proper validation. Attackers exploit this by using special sequences like `../` to navigate up the directory structure. + +Consider what happens when a malicious user requests: +``` +/images/../../etc/passwd +``` + +Our server takes this path, removes the `/images/` prefix, and joins it with our uploads directory: +```js +path.join(process.cwd(), 'uploads', '../../etc/passwd') +``` + +This resolves to something like `/home/user/myapp/uploads/../../etc/passwd`, which is equivalent to `/home/user/etc/passwd` - completely outside our intended uploads directory! + +### Common Attack Vectors + +Path traversal attacks can take many sophisticated forms: + +1. **Basic traversal**: `../../etc/passwd` +2. **URL encoding**: `..%2F..%2Fetc%2Fpasswd` +3. **Double encoding**: `..%252F..%252Fetc%252Fpasswd` +4. **Windows paths**: `..\..\windows\system32\config\sam` +5. **Mixed encoding**: `..%2F..%5Cetc%2Fpasswd` +6. **Overlong UTF-8**: `..%c0%af..%c0%afetc%c0%afpasswd` + +:::tip[URL Decoding Matters] +Most HTTP servers and frameworks automatically decode URL-encoded characters, which is why `%2F` becomes `/` before your code sees it. This is why validation must happen **after** URL decoding, not before. +::: + +### Real-World Examples + +Path traversal vulnerabilities have affected many major applications: + +1. **Apache Struts (CVE-2017-5638)**: A vulnerability that allowed remote code execution through path traversal. +2. **WordPress Plugins**: Various plugins have had path traversal issues that could expose sensitive files. +3. **Adobe ColdFusion**: Multiple path traversal vulnerabilities that could lead to remote code execution. +4. **Jenkins (CVE-2024-23897)**: Path traversal in CLI argument handling allowing arbitrary file reads. + +--- + +## The Defense: Building a Secure File Server + +Now that we understand the threat, let's build a secure implementation. We'll create a multi-layered defense using modern Node.js APIs. + +### Step 1: Path Validation and Canonicalization + +First, let's create a utility function that safely resolves user-provided paths: + +```js +import path from 'node:path' +import fs from 'node:fs/promises' + +function decodeInput(input) { + try { + return decodeURIComponent(String(input)) + } catch { + return String(input) + } +} + +export async function safeResolve(root, userPath) { + // Decode any URL-encoded characters + const decoded = decodeInput(userPath) + + // Reject absolute paths immediately + if (path.isAbsolute(decoded)) { + throw new Error('Absolute paths not allowed') + } + + // Normalize the path and resolve against our root + const resolved = path.resolve(root, decoded) + + // Resolve symlinks to prevent symlink-based escapes + const real = await fs.realpath(resolved).catch(() => resolved) + + // Verify the resolved path is within our root directory + if (real === root || real.startsWith(root + path.sep)) { + return real + } + + throw new Error('Path traversal detected') +} +``` + +### Understanding the Security Measures + +Let's break down each security measure in our `safeResolve` function: + +1. **Input Decoding**: `decodeURIComponent` handles URL-encoded characters that attackers might use to bypass filters. We wrap it in a try-catch because malformed encoding can throw errors. + +2. **Absolute Path Rejection**: `path.isAbsolute()` prevents attackers from specifying absolute paths like `/etc/passwd` directly. + +3. **Path Resolution**: `path.resolve()` normalizes the path, handling `.` and `..` segments correctly. This converts relative paths to absolute paths and removes any path traversal sequences. + +4. **Symlink Resolution**: `fs.realpath()` follows symbolic links to their actual destinations, preventing symlink-based escapes. An attacker could create a symlink inside the uploads directory pointing to sensitive files elsewhere - this prevents that attack. + +5. **Boundary Checking**: The final check ensures the real path is still within our root directory using `startsWith(root + path.sep)`. We add `path.sep` to prevent a subtle bypass: without it, `/app/uploads-evil` would pass the check for root `/app/uploads`. + +:::important[Why Both Resolve and Realpath?] +`path.resolve()` handles `..` sequences but doesn't follow symlinks. `fs.realpath()` follows symlinks but requires the file to exist. Using both provides defense in depth - even if one has a subtle bug or edge case, the other provides protection. +::: + +### Step 2: Secure Streaming Implementation + +Now let's update our server to use this secure path resolution: + +```js +import { createServer } from 'node:http' +import { createReadStream } from 'node:fs' +import { pipeline } from 'node:stream/promises' +import path from 'node:path' +import { safeResolve } from './safe-resolve.js' + +const ROOT = path.resolve(process.cwd(), 'uploads') + +function contentTypeFor(filePath) { + const ext = path.extname(filePath).toLowerCase() + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg' + if (ext === '.png') return 'image/png' + if (ext === '.gif') return 'image/gif' + if (ext === '.webp') return 'image/webp' + return 'application/octet-stream' +} + +const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host}`) + const rel = url.pathname.replace(/^\/images\//, '') + + // Safely resolve the user-provided path + const imagePath = await safeResolve(ROOT, rel) + + res.writeHead(200, { 'Content-Type': contentTypeFor(imagePath) }) + + // Stream the file to the response with proper error handling + await pipeline(createReadStream(imagePath), res) + } catch (error) { + // Don't expose internal error details to the client + if (!res.headersSent) { + res.writeHead(400) + } + res.end('Invalid path or image not found') + + // Log the actual error for debugging + console.error('File serving error:', error.message) + } +}) + +server.listen(3000, () => { + console.log('Secure image server running at http://localhost:3000') +}) +``` + +### Why This Implementation is Secure + +1. **Path Validation**: Uses our `safeResolve` function to validate all paths before file access. +2. **Error Handling**: Provides generic error messages to avoid information leakage while logging details for debugging. +3. **Streaming with Pipeline**: Uses [`pipeline()`](/blog/reading-writing-files-nodejs/#stream-composition-and-processing) for proper backpressure handling and automatic cleanup. +4. **Immutable Root**: The `ROOT` constant is resolved once at startup and never modified. + +:::tip[Why Pipeline Over Pipe?] +The `pipeline()` function from `node:stream/promises` is superior to `.pipe()` because it: +- Properly propagates errors from any stream in the chain +- Automatically cleans up streams on error or completion +- Returns a promise that resolves when streaming is complete +- Handles backpressure correctly across all streams + +Learn more about streams in our [file operations guide](/blog/reading-writing-files-nodejs/#nodejs-streams-memory-efficient-file-processing). +::: + +--- + +## Additional Security Measures + +### Defense in Depth + +While our secure implementation addresses the primary vulnerability, security best practice demands multiple layers of protection: + +1. **Input Validation**: Implement strict validation for allowed characters and patterns +2. **Allowlist Approach**: When possible, maintain an allowlist of permitted files +3. **Rate Limiting**: Prevent brute force attempts to discover files +4. **File Permissions**: Run your Node.js process with minimal filesystem permissions +5. **Containerization**: Use containers to limit filesystem access at the OS level + +### Implementing Input Validation + +Add an extra layer of validation with strict filename rules: + +```js +function validateFileName(fileName) { + // Only allow alphanumeric characters, dots, hyphens, and underscores + const validPattern = /^[a-zA-Z0-9._-]+$/ + + if (!validPattern.test(fileName)) { + throw new Error('Invalid filename') + } + + // Reject files starting with a dot (hidden files) + if (fileName.startsWith('.')) { + throw new Error('Hidden files not allowed') + } + + // Reject files with path separators + if (fileName.includes('/') || fileName.includes('\\')) { + throw new Error('Path separators not allowed') + } + + return fileName +} +``` + +This validation layer catches attacks even before path resolution, providing defense in depth. + +### Windows-Specific Considerations + +Windows has unique path characteristics that require additional attention: + +1. **Drive Letters**: Ensure paths can't escape to different drives (`C:\`, `D:\`) +2. **UNC Paths**: Block UNC paths like `\\server\share\file` +3. **Reserved Names**: Avoid Windows reserved names like `CON`, `PRN`, etc. +4. **Case Insensitivity**: Windows treats `File.txt` and `file.txt` as the same + +```js +function isWindowsReservedName(name) { + const reservedNames = [ + 'CON', 'PRN', 'AUX', 'NUL', + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' + ] + + const baseName = path.basename(name, path.extname(name)).toUpperCase() + return reservedNames.includes(baseName) +} + +function isUNCPath(pathStr) { + // UNC paths start with \\ or // + return pathStr.startsWith('\\\\') || pathStr.startsWith('//') +} + +function hasDriveLetter(pathStr) { + // Check for C:, D:, etc. + return /^[a-zA-Z]:/.test(pathStr) +} +``` + +:::warning[Platform-Specific Attacks] +Always test your security measures on the platforms you deploy to. A validator that works on Linux might fail on Windows due to path separator differences (`/` vs `\`) or case sensitivity. +::: + +### Race Condition Protection (TOCTOU) + +**Time-of-Check-Time-of-Use (TOCTOU)** attacks exploit the time gap between validating a path and actually accessing the file. During this gap, an attacker might: +- Replace a safe file with a symlink to a sensitive file +- Swap directories to bypass validation + +To mitigate TOCTOU vulnerabilities: + +1. Minimize the time between path validation and file access +2. Use file descriptors instead of paths when possible +3. Consider using file handles for critical operations + +```js +import { open } from 'node:fs/promises' + +async function safeFileOpen(root, userPath, flags) { + const safePath = await safeResolve(root, userPath) + + // Open the file immediately after validation to minimize TOCTOU window + return await open(safePath, flags) +} + +// Usage with file handles +async function serveFile(root, userPath, res) { + let fileHandle + try { + fileHandle = await safeFileOpen(root, userPath, 'r') + const stream = fileHandle.createReadStream() + await pipeline(stream, res) + } finally { + await fileHandle?.close() + } +} +``` + +:::note[TOCTOU in Practice] +While TOCTOU attacks are theoretically possible, they're difficult to exploit in practice with our `safeResolve` implementation because: +1. `realpath()` validates at access time, not just check time +2. The attack window is extremely small (microseconds) +3. The attacker needs write access to the uploads directory + +However, defense in depth means we still minimize the risk where possible. +::: + +--- + +## Testing Your Implementation + +### Security Testing with Assertions + +Always test your security implementations rigorously: + +```js +import assert from 'node:assert' +import { safeResolve } from './safe-resolve.js' + +async function testSafeResolve() { + const root = '/app/uploads' + + // Valid paths should resolve correctly + const validPath = await safeResolve(root, 'images/cat.jpg') + assert(validPath.startsWith(root)) + console.log('✓ Valid path resolves correctly') + + // Traversal attempts should throw + try { + await safeResolve(root, '../../etc/passwd') + assert.fail('Should have thrown for traversal attempt') + } catch (error) { + assert(error.message.includes('Path traversal detected')) + console.log('✓ Basic traversal blocked') + } + + // Absolute paths should be rejected + try { + await safeResolve(root, '/etc/passwd') + assert.fail('Should have thrown for absolute path') + } catch (error) { + assert(error.message.includes('Absolute paths not allowed')) + console.log('✓ Absolute paths rejected') + } + + // URL-encoded traversal should be caught + try { + await safeResolve(root, '..%2F..%2Fetc%2Fpasswd') + assert.fail('Should have thrown for encoded traversal') + } catch (error) { + assert(error.message.includes('Path traversal detected')) + console.log('✓ Encoded traversal blocked') + } + + // Null byte injection should be handled + try { + await safeResolve(root, 'valid.jpg\0../../etc/passwd') + // Path should be truncated at null byte or rejected + console.log('✓ Null byte handled') + } catch (error) { + console.log('✓ Null byte rejected:', error.message) + } + + console.log('\n✓ All security tests passed!') +} + +testSafeResolve().catch(console.error) +``` + +### Penetration Testing Checklist + +Test your implementation against these attack scenarios: + +- [ ] **Basic Traversal**: `../../../etc/passwd` +- [ ] **URL Encoding**: `..%2F..%2Fetc%2Fpasswd` +- [ ] **Double Encoding**: `..%252F..%252Fetc%252Fpasswd` +- [ ] **Unicode/UTF-8**: `..%c0%af..%c0%afetc%c0%afpasswd` +- [ ] **Null Bytes**: `valid.jpg%00../../etc/passwd` +- [ ] **Backslashes**: `..\..\..\windows\system32\config\sam` +- [ ] **Mixed Separators**: `..\/..\/etc/passwd` +- [ ] **Absolute Paths**: `/etc/passwd`, `C:\Windows\System32` +- [ ] **UNC Paths**: `\\server\share\sensitive.txt` +- [ ] **Long Paths**: Extremely long path strings (buffer overflow attempts) +- [ ] **Symlink Attacks**: Create symlink in uploads pointing outside + +:::tip[Automated Security Testing] +Consider using tools like: +- **[Snyk](https://snyk.io/)** for dependency vulnerability scanning +- **[npm audit](https://docs.npmjs.com/cli/v8/commands/npm-audit)** for known vulnerabilities in dependencies +- **[OWASP ZAP](https://www.zaproxy.org/)** for web application security testing +- **[Burp Suite](https://portswigger.net/burp)** for manual penetration testing +::: + +--- + +## Monitoring and Incident Response + +### Logging Suspicious Activity + +Implement comprehensive logging to detect and respond to attacks: + +```js +import { createWriteStream } from 'node:fs' + +const securityLog = createWriteStream('security.log', { flags: 'a' }) + +function logSecurityEvent(event, details) { + const timestamp = new Date().toISOString() + const logEntry = { + timestamp, + event, + ...details + } + + securityLog.write(JSON.stringify(logEntry) + '\n') + console.error(`[SECURITY] ${event}:`, details) +} + +// Usage in your server +const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host}`) + const rel = url.pathname.replace(/^\/images\//, '') + + const imagePath = await safeResolve(ROOT, rel) + // ... serve file + } catch (error) { + logSecurityEvent('path_traversal_attempt', { + path: rel, + decoded: decodeInput(rel), + userAgent: req.headers['user-agent'], + ip: req.socket.remoteAddress, + error: error.message + }) + + // Return generic error to client + if (!res.headersSent) { + res.writeHead(400) + } + res.end('Invalid path') + } +}) +``` + +### Detecting Attack Patterns + +Monitor logs for suspicious patterns that might indicate an attack: + +```js +const suspiciousPatterns = [ + /\.\./, // Directory traversal + /%2e%2e/i, // Encoded dots + /%252e/i, // Double encoded + /\0/, // Null bytes + /etc\/passwd/, // Common target + /\.env/, // Environment files + /\.ssh/, // SSH keys + /\/\.\./, // Absolute traversal +] + +function isSuspiciousPath(pathStr) { + return suspiciousPatterns.some(pattern => pattern.test(pathStr)) +} + +// Enhanced logging +if (isSuspiciousPath(rel)) { + logSecurityEvent('high_risk_path_detected', { + path: rel, + ip: req.socket.remoteAddress, + timestamp: Date.now() + }) +} +``` + +### Incident Response Plan + +If you detect a path traversal attack: + +1. **Immediate Response** + - Block the attacking IP address (temporarily or permanently) + - Review recent logs for the same IP or user agent + - Check if any sensitive files were actually accessed + +2. **Investigation** + - Analyze access logs for patterns and scope + - Check file access timestamps for sensitive files + - Review application logs for other suspicious activities + - Determine if the attack was automated or targeted + +3. **Remediation** + - Patch the vulnerability immediately + - Review and strengthen validation logic + - Update security tests to prevent regression + - Consider implementing Web Application Firewall (WAF) rules + +4. **Post-Incident** + - Document the incident and response + - Update incident response procedures + - Rotate any credentials that might have been exposed + - Notify relevant stakeholders if data was compromised + +--- + +## Best Practices Summary + +### Secure Coding Checklist + +- [ ] **Never Trust User Input** - Always validate and sanitize all user-provided data +- [ ] **Use Absolute Paths** - Work with resolved, canonical paths internally +- [ ] **Implement Boundary Checks** - Verify paths stay within allowed directories +- [ ] **Handle Errors Gracefully** - Don't expose internal details to users +- [ ] **Layer Your Defenses** - Multiple validation steps (defense in depth) +- [ ] **Decode Before Validation** - Handle URL encoding, double encoding, etc. +- [ ] **Follow Symlinks** - Use `realpath()` to prevent symlink-based escapes +- [ ] **Log Security Events** - Track suspicious activities for monitoring +- [ ] **Test Thoroughly** - Include security tests in your test suite + +### Node.js Specific Recommendations + +1. **Use Modern APIs**: Prefer `fs/promises` over callbacks for cleaner async code +2. **Stream Large Files**: Use [streams for memory efficiency](/blog/reading-writing-files-nodejs/#nodejs-streams-memory-efficient-file-processing) +3. **Handle Backpressure**: Use `pipeline()` for proper stream management +4. **Validate Early**: Check paths before any file system operation +5. **Consider Security Modules**: Use packages like `helmet` for HTTP security headers + +### Operational Security + +1. **Principle of Least Privilege**: Run your application with minimal filesystem permissions +2. **Regular Updates**: Keep Node.js and dependencies updated with security patches +3. **Security Audits**: Regularly audit code and dependencies (`npm audit`) +4. **Comprehensive Monitoring**: Implement logging and alerting for security events +5. **Incident Response Plan**: Have procedures ready for responding to security incidents +6. **Container Isolation**: Use Docker or similar to limit filesystem access at OS level + +--- + +## Conclusion: Security is Not Optional + +Path traversal vulnerabilities are deceptively simple to introduce but can have devastating consequences. A single missing validation can expose your entire filesystem to attackers. + +**Key takeaways:** + +1. **Never trust user input** - Validate, decode, and sanitize all user-provided paths +2. **Use canonical paths** - Resolve symlinks and normalize paths with `path.resolve()` and `fs.realpath()` +3. **Implement boundary checks** - Verify resolved paths stay within allowed directories +4. **Handle errors securely** - Don't leak internal details; log them server-side instead +5. **Layer your defenses** - Multiple validation steps provide protection even if one fails +6. **Test thoroughly** - Include security tests alongside functional tests + +By incorporating these practices into your development workflow, you'll build Node.js applications that can withstand common attack vectors. Security isn't an afterthought - it's a fundamental aspect of writing reliable, professional code. + +:::tip[Continue Learning] +This article builds on concepts from our [Node.js File Operations Guide](/blog/reading-writing-files-nodejs/). If you haven't read it yet, check it out to deepen your understanding of Node.js file handling with promises, streams, and file handles. +::: + +--- + +## Additional Resources + +### Essential Reading + +1. **[OWASP Path Traversal](https://owasp.org/www-community/attacks/Path_Traversal)** - Comprehensive overview from OWASP +2. **[CWE-22: Improper Limitation of a Pathname](https://cwe.mitre.org/data/definitions/22.html)** - Common Weakness Enumeration entry +3. **[Node.js Security Best Practices](https://nodejs.org/en/docs/guides/security/)** - Official Node.js security guide +4. **[SANS Top 25 Software Errors](https://www.sans.org/top25-software-errors/)** - Industry-standard security issues + +### Tools and Libraries + +- **[@sindresorhus/is-path-inside](https://github.com/sindresorhus/is-path-inside)** - Utility to check if a path is inside another path +- **[path-type](https://github.com/sindresorhus/path-type)** - Check what a path is (file, directory, symlink) +- **[helmet](https://helmetjs.github.io/)** - Security HTTP headers for Express.js + +### Further Learning + +For a deeper dive into Node.js security, consider: + +- **Node.js Design Patterns** - Our book covers security patterns and best practices throughout ([learn more](/)) +- **[Liran Tal's Node.js Security](https://www.nodejs-security.com/)** - Comprehensive Node.js security resources +- **[Snyk's Node.js Security Guide](https://snyk.io/blog/nodejs-security-best-practices/)** - Modern security practices + +--- + +Remember: **security is an ongoing process**, not a one-time fix. Stay informed about new vulnerabilities, regularly review your code, and always prioritize secure coding practices from the start. From ef9bb9330f297e52cbd009bb01d85d0c720c55db Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Sun, 1 Feb 2026 19:31:13 +0100 Subject: [PATCH 2/7] chore: work in progress --- .claude/content-calendar.md | 2 + .claude/settings.local.json | 3 +- .../nodejs-path-traversal-security/index.md | 280 ++++++++++++------ .../reading-writing-files-nodejs/index.md | 6 + 4 files changed, 201 insertions(+), 90 deletions(-) diff --git a/.claude/content-calendar.md b/.claude/content-calendar.md index 8bc8615..f2cd725 100644 --- a/.claude/content-calendar.md +++ b/.claude/content-calendar.md @@ -69,6 +69,7 @@ | Stream consumers | COMPLETE | /blog/node-js-stream-consumer | | Async iterators | COMPLETE | /blog/javascript-async-iterators | | Race conditions | COMPLETE | /blog/node-js-race-conditions | +| Path traversal security | COMPLETE | /blog/nodejs-path-traversal-security | | Checking Node.js version | COMPLETE | /blog/checking-node-js-version | | Installing Node.js | UPDATED | /blog/5-ways-to-install-node-js | | Docker development | COMPLETE | /blog/node-js-development-with-docker-and-docker-compose | @@ -88,6 +89,7 @@ Environment Variables Files & Paths └── links to → Reading/Writing Files (existing) └── links to → Hashing Files + └── links to → Path Traversal Security (existing) └── links to → Encrypting Files Streams Guide diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bfa4855..812f978 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -43,7 +43,8 @@ "mcp__playwright__browser_network_requests", "mcp__playwright__browser_close", "mcp__playwright__browser_resize", - "mcp__playwright__browser_run_code" + "mcp__playwright__browser_run_code", + "Skill(marketing-skills:seo-audit)" ] } } diff --git a/src/content/blog/nodejs-path-traversal-security/index.md b/src/content/blog/nodejs-path-traversal-security/index.md index ac6e036..041a207 100644 --- a/src/content/blog/nodejs-path-traversal-security/index.md +++ b/src/content/blog/nodejs-path-traversal-security/index.md @@ -1,24 +1,56 @@ --- -date: 2025-10-15T10:00:00 -updatedAt: 2025-10-15T10:00:00 -title: How to Protect Your Node.js Web Server from Path Traversal Vulnerabilities +date: 2026-01-30T10:00:00 +updatedAt: 2026-01-30T10:00:00 +title: 'Node.js Path Traversal: Prevention & Security Guide' slug: nodejs-path-traversal-security -description: Learn how to detect and prevent path traversal attacks in Node.js. From understanding the vulnerability to building secure file servers with modern APIs, this comprehensive guide covers everything you need to protect your applications. +description: Learn to prevent path traversal attacks in Node.js. Secure file servers with input validation, boundary checks, and defense-in-depth patterns. authors: ['luciano-mammino'] tags: ['blog'] +faq: + - question: What is a path traversal attack in Node.js? + answer: A path traversal attack exploits improper input validation to access files outside the intended directory using sequences like "../" to navigate up the filesystem. In Node.js, this often happens when user input is passed directly to path.join() or fs functions without validation. + - question: How do I prevent path traversal in Node.js? + answer: Prevent path traversal by (1) decoding user input, (2) rejecting absolute paths, (3) resolving paths with path.resolve(), (4) following symlinks with fs.realpath(), and (5) verifying the final path stays within your root directory using startsWith(). + - question: Is path.join() safe from path traversal? + answer: No, path.join() does not prevent path traversal. It simply concatenates paths without security validation. An input like "../../etc/passwd" will be joined as-is, allowing directory escape. Always validate paths after joining. + - question: How do I secure file uploads in Node.js? + answer: Secure file uploads by (1) validating filenames with strict patterns, (2) storing files with generated names rather than user-provided ones, (3) serving files through a validated path resolution function, and (4) using file handles to minimize TOCTOU race conditions. --- -Building on our [Node.js File Operations Guide](/blog/reading-writing-files-nodejs/), let's explore one of the most critical security vulnerabilities in web applications: **path traversal attacks**. +Building on our extensive [Node.js File Operations Guide](/blog/reading-writing-files-nodejs/), let's explore one of the most critical security vulnerabilities related to handling files and paths in web applications: **path traversal attacks**. ---- - -## Introduction: When Simple File Serving Becomes Dangerous +## When Simple File Serving can Become Dangerous -You've built a Node.js application that serves user-uploaded images. The implementation is clean, efficient, and uses modern streaming APIs. But what happens when a malicious user requests `../../etc/passwd` instead of `cat.jpg`? Suddenly, your simple file server becomes a gateway to your entire filesystem. +You've built a Node.js application that serves user-uploaded images. The implementation is clean, efficient, and uses modern streaming APIs. But what happens when a malicious user requests `../../etc/passwd` instead of `cat.jpg`? Suddenly, your simple file server could become a gateway to your entire server filesystem. This article will guide you through understanding, detecting, and preventing path traversal attacks in Node.js web servers. We'll start with a vulnerable implementation, demonstrate how attackers exploit it, and then build a secure solution using modern Node.js APIs. ---- +If you build applications that allow users to read files from the filesystem—whether it's serving uploads, generating downloads, or processing user-specified paths—this is essential reading. + + +## Quick Answer: Secure Path Resolution + +If you're already familiar with path traversal attacks and just need a quick checklist to sanity-check your implementation, here's the summary: + +To prevent path traversal in Node.js: + +1. **Decode user input** with `decodeURIComponent()` (handle double encoding with a loop) +2. **Reject null bytes** that can truncate paths +3. **Reject absolute paths** with `path.isAbsolute()` +4. **Resolve to canonical path** with `path.resolve()` +5. **Follow symlinks** with `fs.realpath()` +6. **Verify path stays within root** using `startsWith(root + path.sep)` + +```js +const safePath = path.resolve(root, decodeURIComponent(userInput)) +const realPath = await fs.realpath(safePath) +if (!realPath.startsWith(root + path.sep)) { + throw new Error('Path traversal detected') +} +``` + +Read on for the complete implementation with all edge cases handled, and to understand *why* each of these measures is necessary and how they work together to protect your application. + ## Understanding Path Traversal Vulnerabilities @@ -44,7 +76,6 @@ Path traversal attacks typically follow these steps: 3. **Exploit**: The attacker sends the payload to the server. 4. **Access Files**: If successful, the attacker can now access files outside the intended directory. ---- ## The Vulnerable Implementation: A Naive Image Server @@ -112,7 +143,6 @@ This exact pattern has led to serious security breaches in production applicatio - Access application source code to find more vulnerabilities ::: ---- ## The Attack: Common Exploitation Techniques @@ -141,22 +171,26 @@ Path traversal attacks can take many sophisticated forms: 3. **Double encoding**: `..%252F..%252Fetc%252Fpasswd` 4. **Windows paths**: `..\..\windows\system32\config\sam` 5. **Mixed encoding**: `..%2F..%5Cetc%2Fpasswd` -6. **Overlong UTF-8**: `..%c0%af..%c0%afetc%c0%afpasswd` +6. **Overlong UTF-8**: `..%c0%af..%c0%afetc%c0%afpasswd` (largely a legacy attack vector - modern UTF-8 parsers reject these malformed sequences, but older systems may be vulnerable) :::tip[URL Decoding Matters] -Most HTTP servers and frameworks automatically decode URL-encoded characters, which is why `%2F` becomes `/` before your code sees it. This is why validation must happen **after** URL decoding, not before. +Many HTTP servers and frameworks decode URL-encoded characters once, but behavior varies by framework. The important point is that validation must happen **after** full URL decoding, not before. Always decode input explicitly rather than relying on framework behavior. ::: ### Real-World Examples Path traversal vulnerabilities have affected many major applications: -1. **Apache Struts (CVE-2017-5638)**: A vulnerability that allowed remote code execution through path traversal. -2. **WordPress Plugins**: Various plugins have had path traversal issues that could expose sensitive files. -3. **Adobe ColdFusion**: Multiple path traversal vulnerabilities that could lead to remote code execution. -4. **Jenkins (CVE-2024-23897)**: Path traversal in CLI argument handling allowing arbitrary file reads. +1. **Apache HTTP Server (CVE-2021-41773)**: A path traversal flaw in Apache httpd 2.4.49 that allowed attackers to map URLs to files outside the document root, leading to arbitrary file reads and potential RCE. +2. **`st` npm module (CVE-2014-6394)**: A classic Node.js ecosystem example where the popular static file serving module was vulnerable to directory traversal via URL-encoded sequences. +3. **`serve` npm module (CVE-2019-5418)**: Path traversal vulnerability in the serve package allowing access to files outside the served directory through crafted requests. +4. **Jenkins (CVE-2024-23897)**: Arbitrary file read via CLI "@file" argument expansion - while not pure path traversal, it demonstrates how path-based input can lead to unauthorized file access. +5. **Node.js (CVE-2023-32002)**: Policy bypass via path traversal in Node.js experimental policy feature, allowing module loading restrictions to be circumvented. + +:::tip[Keep Node.js Updated] +The Node.js team actively patches security vulnerabilities. Always keep your Node.js runtime updated to the latest LTS version to benefit from security fixes. Run `node --version` to check your version and visit [nodejs.org](https://nodejs.org) for the latest releases. +::: ---- ## The Defense: Building a Secure File Server @@ -170,35 +204,67 @@ First, let's create a utility function that safely resolves user-provided paths: import path from 'node:path' import fs from 'node:fs/promises' -function decodeInput(input) { - try { - return decodeURIComponent(String(input)) - } catch { - return String(input) +function fullyDecode(input) { + let result = String(input) + // Decode repeatedly until the string stops changing (handles double/triple encoding) + // Limit iterations to prevent infinite loops on malformed input + for (let i = 0; i < 10; i++) { + try { + const decoded = decodeURIComponent(result) + if (decoded === result) break + result = decoded + } catch { + break // Stop on decode error (malformed encoding) + } } + return result } export async function safeResolve(root, userPath) { - // Decode any URL-encoded characters - const decoded = decodeInput(userPath) + // Fully decode any URL-encoded characters (handles double encoding) + const decoded = fullyDecode(userPath) + + // Reject null bytes (used to bypass extension checks) + if (decoded.includes('\0')) { + throw new Error('Null bytes not allowed') + } // Reject absolute paths immediately if (path.isAbsolute(decoded)) { throw new Error('Absolute paths not allowed') } + // Reject Windows drive letters (e.g., C:, D:) + if (/^[a-zA-Z]:/.test(decoded)) { + throw new Error('Drive letters not allowed') + } + + // Reject UNC paths (e.g., \\server\share or //server/share) + if (decoded.startsWith('\\\\') || decoded.startsWith('//')) { + throw new Error('UNC paths not allowed') + } + // Normalize the path and resolve against our root const resolved = path.resolve(root, decoded) // Resolve symlinks to prevent symlink-based escapes const real = await fs.realpath(resolved).catch(() => resolved) - // Verify the resolved path is within our root directory - if (real === root || real.startsWith(root + path.sep)) { - return real + // Also resolve root to handle symlinks in the root path + const realRoot = await fs.realpath(root).catch(() => path.resolve(root)) + + // Use path.relative for robust containment check + // This handles: Windows case-insensitivity, root edge cases (e.g., root === '/'), + // and trailing slash variations + const relative = path.relative(realRoot, real) + + // If relative path starts with '..' or is absolute, it's outside root + // Empty string means they're the same path (accessing root itself is allowed) + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error('Path traversal detected') } - throw new Error('Path traversal detected') + return real } ``` @@ -206,18 +272,24 @@ export async function safeResolve(root, userPath) { Let's break down each security measure in our `safeResolve` function: -1. **Input Decoding**: `decodeURIComponent` handles URL-encoded characters that attackers might use to bypass filters. We wrap it in a try-catch because malformed encoding can throw errors. +1. **Full Input Decoding**: The `fullyDecode` function handles URL-encoded characters in a loop, decoding repeatedly until the string stops changing. This catches double encoding attacks (`%252F` → `%2F` → `/`) and triple encoding. We limit to 10 iterations to prevent infinite loops on malicious input. -2. **Absolute Path Rejection**: `path.isAbsolute()` prevents attackers from specifying absolute paths like `/etc/passwd` directly. +2. **Null Byte Rejection**: Null bytes (`\0`) are used in null byte injection attacks to truncate paths. For example, `valid.jpg\0../../etc/passwd` might pass extension checks but access different files. We reject these explicitly. -3. **Path Resolution**: `path.resolve()` normalizes the path, handling `.` and `..` segments correctly. This converts relative paths to absolute paths and removes any path traversal sequences. +3. **Absolute Path Rejection**: `path.isAbsolute()` prevents attackers from specifying absolute paths like `/etc/passwd` directly. -4. **Symlink Resolution**: `fs.realpath()` follows symbolic links to their actual destinations, preventing symlink-based escapes. An attacker could create a symlink inside the uploads directory pointing to sensitive files elsewhere - this prevents that attack. +4. **Windows Drive Letter Rejection**: Paths like `C:` or `D:` can escape to different drives on Windows. We reject these with a regex check. -5. **Boundary Checking**: The final check ensures the real path is still within our root directory using `startsWith(root + path.sep)`. We add `path.sep` to prevent a subtle bypass: without it, `/app/uploads-evil` would pass the check for root `/app/uploads`. +5. **UNC Path Rejection**: UNC paths (`\\server\share` or `//server/share`) can access network resources. We block these to prevent network-based attacks. + +6. **Path Resolution**: `path.resolve()` normalizes the path, handling `.` and `..` segments correctly. This converts relative paths to absolute paths and removes any path traversal sequences. + +7. **Symlink Resolution**: `fs.realpath()` follows symbolic links to their actual destinations, preventing symlink-based escapes. An attacker could create a symlink inside the uploads directory pointing to sensitive files elsewhere - this prevents that attack. We also resolve the root path to handle symlinks in the root itself. + +8. **Boundary Checking**: We use `path.relative()` to compute the relative path from root to the resolved path. If it starts with `..` or is absolute, the path is outside our root. This approach correctly handles: Windows case-insensitivity, edge cases when `root === '/'`, and trailing slash variations. :::important[Why Both Resolve and Realpath?] -`path.resolve()` handles `..` sequences but doesn't follow symlinks. `fs.realpath()` follows symlinks but requires the file to exist. Using both provides defense in depth - even if one has a subtle bug or edge case, the other provides protection. +`path.resolve()` handles `..` sequences but doesn't follow symlinks. `fs.realpath()` follows symlinks but only works for paths that exist on disk - for non-existent paths, it throws an error (which we catch and fall back to the resolved path). Using both provides defense in depth - even if one has a subtle bug or edge case, the other provides protection. Note that symlink protection only applies to files that already exist. ::: ### Step 2: Secure Streaming Implementation @@ -226,7 +298,7 @@ Now let's update our server to use this secure path resolution: ```js import { createServer } from 'node:http' -import { createReadStream } from 'node:fs' +import { open } from 'node:fs/promises' import { pipeline } from 'node:stream/promises' import path from 'node:path' import { safeResolve } from './safe-resolve.js' @@ -243,6 +315,7 @@ function contentTypeFor(filePath) { } const server = createServer(async (req, res) => { + let fileHandle try { const url = new URL(req.url, `http://${req.headers.host}`) const rel = url.pathname.replace(/^\/images\//, '') @@ -250,10 +323,14 @@ const server = createServer(async (req, res) => { // Safely resolve the user-provided path const imagePath = await safeResolve(ROOT, rel) + // Open file handle immediately after validation to minimize TOCTOU window + fileHandle = await open(imagePath, 'r') + res.writeHead(200, { 'Content-Type': contentTypeFor(imagePath) }) - // Stream the file to the response with proper error handling - await pipeline(createReadStream(imagePath), res) + // Stream from the file handle, not the path + const stream = fileHandle.createReadStream() + await pipeline(stream, res) } catch (error) { // Don't expose internal error details to the client if (!res.headersSent) { @@ -263,6 +340,9 @@ const server = createServer(async (req, res) => { // Log the actual error for debugging console.error('File serving error:', error.message) + } finally { + // Ensure the file handle is always closed + await fileHandle?.close() } }) @@ -274,9 +354,11 @@ server.listen(3000, () => { ### Why This Implementation is Secure 1. **Path Validation**: Uses our `safeResolve` function to validate all paths before file access. -2. **Error Handling**: Provides generic error messages to avoid information leakage while logging details for debugging. -3. **Streaming with Pipeline**: Uses [`pipeline()`](/blog/reading-writing-files-nodejs/#stream-composition-and-processing) for proper backpressure handling and automatic cleanup. -4. **Immutable Root**: The `ROOT` constant is resolved once at startup and never modified. +2. **TOCTOU Mitigation**: Opens a file handle immediately after validation, minimizing the window for race condition attacks. +3. **Error Handling**: Provides generic error messages to avoid information leakage while logging details for debugging. +4. **Streaming with Pipeline**: Uses [`pipeline()`](/blog/reading-writing-files-nodejs/#stream-composition-and-processing) for proper backpressure handling and automatic cleanup. +5. **Resource Cleanup**: Uses `try/finally` to ensure file handles are always closed, even on errors. +6. **Immutable Root**: The `ROOT` constant is resolved once at startup and never modified. :::tip[Why Pipeline Over Pipe?] The `pipeline()` function from `node:stream/promises` is superior to `.pipe()` because it: @@ -285,10 +367,9 @@ The `pipeline()` function from `node:stream/promises` is superior to `.pipe()` b - Returns a promise that resolves when streaming is complete - Handles backpressure correctly across all streams -Learn more about streams in our [file operations guide](/blog/reading-writing-files-nodejs/#nodejs-streams-memory-efficient-file-processing). +Learn more about streams in our [file operations guide](/blog/reading-writing-files-nodejs/#nodejs-streams-memory-efficient-file-processing) and our [stream consumer patterns article](/blog/node-js-stream-consumer/). ::: ---- ## Additional Security Measures @@ -302,6 +383,33 @@ While our secure implementation addresses the primary vulnerability, security be 4. **File Permissions**: Run your Node.js process with minimal filesystem permissions 5. **Containerization**: Use containers to limit filesystem access at the OS level +### Integration with Express.js + +If you're using Express.js, here's how to integrate secure path resolution: + +```js +import express from 'express' +import path from 'node:path' +import { safeResolve } from './safe-resolve.js' + +const app = express() +const ROOT = path.resolve(process.cwd(), 'uploads') + +app.get('/files/:filepath(*)', async (req, res) => { + try { + const safePath = await safeResolve(ROOT, req.params.filepath) + res.sendFile(safePath) + } catch (error) { + console.error('Path validation failed:', error.message) + res.status(400).send('Invalid path') + } +}) + +app.listen(3000) +``` + +Note: Express's `res.sendFile()` has some built-in protections, but always validate paths yourself rather than relying on framework behavior. + ### Implementing Input Validation Add an extra layer of validation with strict filename rules: @@ -344,8 +452,9 @@ Windows has unique path characteristics that require additional attention: function isWindowsReservedName(name) { const reservedNames = [ 'CON', 'PRN', 'AUX', 'NUL', - 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', - 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' + 'COM0', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT0', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', + 'CONIN$', 'CONOUT$' ] const baseName = path.basename(name, path.extname(name)).toUpperCase() @@ -369,38 +478,11 @@ Always test your security measures on the platforms you deploy to. A validator t ### Race Condition Protection (TOCTOU) -**Time-of-Check-Time-of-Use (TOCTOU)** attacks exploit the time gap between validating a path and actually accessing the file. During this gap, an attacker might: +**Time-of-Check-Time-of-Use (TOCTOU)** attacks exploit the time gap between validating a path and actually accessing the file. For a deeper dive into race conditions in Node.js, see our [comprehensive guide on Node.js race conditions](/blog/node-js-race-conditions/). During this gap, an attacker might: - Replace a safe file with a symlink to a sensitive file - Swap directories to bypass validation -To mitigate TOCTOU vulnerabilities: - -1. Minimize the time between path validation and file access -2. Use file descriptors instead of paths when possible -3. Consider using file handles for critical operations - -```js -import { open } from 'node:fs/promises' - -async function safeFileOpen(root, userPath, flags) { - const safePath = await safeResolve(root, userPath) - - // Open the file immediately after validation to minimize TOCTOU window - return await open(safePath, flags) -} - -// Usage with file handles -async function serveFile(root, userPath, res) { - let fileHandle - try { - fileHandle = await safeFileOpen(root, userPath, 'r') - const stream = fileHandle.createReadStream() - await pipeline(stream, res) - } finally { - await fileHandle?.close() - } -} -``` +Our main server implementation above already mitigates this by opening a file handle immediately after path validation using `fs/promises.open()`. By streaming from the file handle rather than the path, we ensure the file being accessed is the same one that was validated. :::note[TOCTOU in Practice] While TOCTOU attacks are theoretically possible, they're difficult to exploit in practice with our `safeResolve` implementation because: @@ -408,10 +490,9 @@ While TOCTOU attacks are theoretically possible, they're difficult to exploit in 2. The attack window is extremely small (microseconds) 3. The attacker needs write access to the uploads directory -However, defense in depth means we still minimize the risk where possible. +However, defense in depth means we still minimize the risk by using file handles. ::: ---- ## Testing Your Implementation @@ -458,13 +539,40 @@ async function testSafeResolve() { console.log('✓ Encoded traversal blocked') } - // Null byte injection should be handled + // Double-encoded traversal should be caught + try { + await safeResolve(root, '..%252F..%252Fetc%252Fpasswd') + assert.fail('Should have thrown for double-encoded traversal') + } catch (error) { + assert(error.message.includes('Path traversal detected')) + console.log('✓ Double-encoded traversal blocked') + } + + // Windows backslash traversal should be caught + try { + await safeResolve(root, '..\\..\\..\\windows\\system32\\config\\sam') + assert.fail('Should have thrown for backslash traversal') + } catch (error) { + assert(error.message.includes('Path traversal detected')) + console.log('✓ Windows backslash traversal blocked') + } + + // Null byte injection should be rejected try { await safeResolve(root, 'valid.jpg\0../../etc/passwd') - // Path should be truncated at null byte or rejected - console.log('✓ Null byte handled') + assert.fail('Should have thrown for null byte') + } catch (error) { + assert(error.message.includes('Null bytes not allowed')) + console.log('✓ Null byte rejected') + } + + // UNC paths should be rejected + try { + await safeResolve(root, '//server/share/sensitive.txt') + assert.fail('Should have thrown for UNC path') } catch (error) { - console.log('✓ Null byte rejected:', error.message) + assert(error.message.includes('UNC paths not allowed')) + console.log('✓ UNC paths rejected') } console.log('\n✓ All security tests passed!') @@ -497,7 +605,6 @@ Consider using tools like: - **[Burp Suite](https://portswigger.net/burp)** for manual penetration testing ::: ---- ## Monitoring and Incident Response @@ -533,7 +640,7 @@ const server = createServer(async (req, res) => { } catch (error) { logSecurityEvent('path_traversal_attempt', { path: rel, - decoded: decodeInput(rel), + decoded: fullyDecode(rel), // Using the fullyDecode helper from earlier userAgent: req.headers['user-agent'], ip: req.socket.remoteAddress, error: error.message @@ -605,7 +712,6 @@ If you detect a path traversal attack: - Rotate any credentials that might have been exposed - Notify relevant stakeholders if data was compromised ---- ## Best Practices Summary @@ -638,7 +744,6 @@ If you detect a path traversal attack: 5. **Incident Response Plan**: Have procedures ready for responding to security incidents 6. **Container Isolation**: Use Docker or similar to limit filesystem access at OS level ---- ## Conclusion: Security is Not Optional @@ -659,7 +764,6 @@ By incorporating these practices into your development workflow, you'll build No This article builds on concepts from our [Node.js File Operations Guide](/blog/reading-writing-files-nodejs/). If you haven't read it yet, check it out to deepen your understanding of Node.js file handling with promises, streams, and file handles. ::: ---- ## Additional Resources @@ -667,7 +771,7 @@ This article builds on concepts from our [Node.js File Operations Guide](/blog/r 1. **[OWASP Path Traversal](https://owasp.org/www-community/attacks/Path_Traversal)** - Comprehensive overview from OWASP 2. **[CWE-22: Improper Limitation of a Pathname](https://cwe.mitre.org/data/definitions/22.html)** - Common Weakness Enumeration entry -3. **[Node.js Security Best Practices](https://nodejs.org/en/docs/guides/security/)** - Official Node.js security guide +3. **[Node.js Security Best Practices](https://nodejs.org/en/learn/getting-started/security-best-practices)** - Official Node.js security guide 4. **[SANS Top 25 Software Errors](https://www.sans.org/top25-software-errors/)** - Industry-standard security issues ### Tools and Libraries @@ -684,6 +788,4 @@ For a deeper dive into Node.js security, consider: - **[Liran Tal's Node.js Security](https://www.nodejs-security.com/)** - Comprehensive Node.js security resources - **[Snyk's Node.js Security Guide](https://snyk.io/blog/nodejs-security-best-practices/)** - Modern security practices ---- - Remember: **security is an ongoing process**, not a one-time fix. Stay informed about new vulnerabilities, regularly review your code, and always prioritize secure coding practices from the start. diff --git a/src/content/blog/reading-writing-files-nodejs/index.md b/src/content/blog/reading-writing-files-nodejs/index.md index f4bc835..e8b6bd7 100644 --- a/src/content/blog/reading-writing-files-nodejs/index.md +++ b/src/content/blog/reading-writing-files-nodejs/index.md @@ -92,6 +92,12 @@ Notice we're using `try/catch` here as well, because file write operations can f - **ENOTDIR**: Part of the path is not a directory ::: +:::warning[Security: Validating User-Provided File Paths] +When your application handles user-provided file paths (e.g., serving uploaded files or processing user-specified paths), you must validate them to prevent **path traversal attacks**. Attackers can use sequences like `../` to escape your intended directory and access sensitive files like `/etc/passwd` or application credentials. + +Never pass user input directly to `readFile()`, `writeFile()`, or similar functions without validation. Learn how to properly validate paths and build secure file servers in our comprehensive guide on [preventing path traversal attacks in Node.js](/blog/nodejs-path-traversal-security/). +::: + ### Reading and Writing Binary Files So far, we've been working with text files, but what happens when you need to handle images, videos, or audio files? Not all files are text-based, and Node.js handles binary data just as elegantly. Here's a more advanced example showing how to work with binary data by creating and reading WAV audio files: From 4845137cd122331e644f8c54a8c08d8ff63670ff Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Mon, 2 Feb 2026 13:40:54 +0100 Subject: [PATCH 3/7] chore: work in progress --- .../blog/nodejs-path-traversal-security/index.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/content/blog/nodejs-path-traversal-security/index.md b/src/content/blog/nodejs-path-traversal-security/index.md index 041a207..5a6ebd1 100644 --- a/src/content/blog/nodejs-path-traversal-security/index.md +++ b/src/content/blog/nodejs-path-traversal-security/index.md @@ -19,13 +19,17 @@ faq: Building on our extensive [Node.js File Operations Guide](/blog/reading-writing-files-nodejs/), let's explore one of the most critical security vulnerabilities related to handling files and paths in web applications: **path traversal attacks**. -## When Simple File Serving can Become Dangerous +It's surprisingly easy to build Node.js applications where users can influence which files get loaded from the filesystem. Think about a simple image server: depending on the URL a user requests, your application decides which file to return. Or consider a document download endpoint, a static file server, or even a template engine that loads views based on route parameters. In all these cases, user input directly or indirectly determines a file path. -You've built a Node.js application that serves user-uploaded images. The implementation is clean, efficient, and uses modern streaming APIs. But what happens when a malicious user requests `../../etc/passwd` instead of `cat.jpg`? Suddenly, your simple file server could become a gateway to your entire server filesystem. +If we're not careful with our implementation, we might accidentally expose the *entire* filesystem. An attacker could read configuration files containing database credentials, access private SSH keys, or examine application source code to discover additional vulnerabilities. This information disclosure often becomes the gateway for attackers to move laterally through your infrastructure, escalating what started as a simple web request into a full system compromise. + +Path traversal has been one of the most severely exploited attack vectors in recent years, affecting everything from Apache web servers to popular npm packages. This is not a theoretical concern—it's a real and present danger that deserves your full attention when building production applications. -This article will guide you through understanding, detecting, and preventing path traversal attacks in Node.js web servers. We'll start with a vulnerable implementation, demonstrate how attackers exploit it, and then build a secure solution using modern Node.js APIs. +In this article, you'll learn exactly what a path traversal attack is, how it happens in practice, and—most importantly—what you must do to build Node.js applications that are not vulnerable. -If you build applications that allow users to read files from the filesystem—whether it's serving uploads, generating downloads, or processing user-specified paths—this is essential reading. +## When Simple File Serving can Become Dangerous + +You've built a Node.js application that serves user-uploaded images. The implementation is clean, efficient, and uses modern streaming APIs. But what happens when a malicious user requests `../../etc/passwd` instead of `cat.jpg`? Suddenly, your simple file server could become a gateway to your entire server filesystem. ## Quick Answer: Secure Path Resolution From b82df80c1236eadd9b0946d9d2cdcd324edab0f2 Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Mon, 2 Feb 2026 14:07:12 +0100 Subject: [PATCH 4/7] chore: improve content style and instructions for LLMs --- .claude/article-brief-template.md | 11 ++ .claude/constitution.md | 28 ++++ .claude/instructions.md | 1 + AGENTS.md | 122 ++++++++++++++++++ CLAUDE.md | 1 + .../nodejs-path-traversal-security/index.md | 104 +++++++++------ 6 files changed, 230 insertions(+), 37 deletions(-) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/.claude/article-brief-template.md b/.claude/article-brief-template.md index f40e940..3b7ecd1 100644 --- a/.claude/article-brief-template.md +++ b/.claude/article-brief-template.md @@ -60,6 +60,17 @@ Use this template when planning new blog articles for Node.js Design Patterns. --- +### Writing Style Checklist + +- [ ] No em dashes (—) used anywhere +- [ ] Context provided before diving into implementation details +- [ ] Explains "why" not just "how" for each concept +- [ ] Friendly, conversational tone throughout +- [ ] Sections connected with transitions (forward/backward references) +- [ ] Technical explanations start with the problem/use case + +--- + ### Internal Links Link to related existing articles: diff --git a/.claude/constitution.md b/.claude/constitution.md index 35e5e0b..b26d364 100644 --- a/.claude/constitution.md +++ b/.claude/constitution.md @@ -41,6 +41,34 @@ We adhere to a set of coding standards to ensure code quality and maintainabilit The repository uses well-defined formatting rules through [Prettier](https://prettier.io/) and linting through [ESLint](https://eslint.org/). +### Content Writing Style + +When creating or editing written content (blog articles, documentation, tutorials), follow these style guidelines: + +#### Tone and Readability +- Use a **friendly, approachable tone** - almost colloquial, as if explaining to a colleague +- Write in a conversational style while maintaining technical accuracy +- Avoid overly formal or academic language + +#### Punctuation and Formatting +- **Never use em dashes (—)** - use commas, parentheses, or separate sentences instead +- Use short paragraphs and clear sentence structure +- Break up long explanations with code examples, lists, or callouts + +#### Structure and Flow +- **Always provide context first** - explain the "what" and "why" before diving into "how" +- **Emphasize the "why"** - readers should understand not just how something works, but why it matters and why it works that way +- **Connect sections smoothly** using: + - Forward references: "In the next section, we'll see how to handle errors" + - Backward references: "As we saw earlier, streams provide memory efficiency" + - Brief previews of what's coming: "Before we implement this, let's understand the underlying concept" + +#### Technical Explanations +- Start with the problem or use case before introducing the solution +- Explain concepts progressively - simple version first, then edge cases and advanced usage +- Use real-world analogies when explaining abstract concepts +- Include practical examples that readers can relate to their own projects + ## Deployment The website is automatically deployed to GitHub Pages through a GitHub Actions workflow. The deployment process is triggered on every push to the `main` branch. The workflow builds the static site and publishes it to GitHub Pages, making it accessible to users. Contributors do not need to manually handle deployment - focus on code quality and the automated pipeline will handle the rest. diff --git a/.claude/instructions.md b/.claude/instructions.md index b09bb76..203a4e2 100644 --- a/.claude/instructions.md +++ b/.claude/instructions.md @@ -9,6 +9,7 @@ Key principles to remember: - Maintain accessibility standards (semantic HTML, ARIA, keyboard navigation) - Use data-driven approach with strongly typed content collections - Follow established coding standards with ESLint and Prettier +- Follow content writing style guidelines (friendly tone, context-first, explain the "why") - All changes are automatically deployed via GitHub Actions to GitHub Pages Always read the constitution file when starting work to ensure alignment with project principles. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e0fc71e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,122 @@ +# AGENTS.md + +This file provides guidance to AI coding assistants when working with code in this repository. + +## Project Overview + +Official website for the book "Node.js Design Patterns" by Mario Casciaro and Luciano Mammino. Built with Astro as a minimal static site, deployed to GitHub Pages at https://nodejsdesignpatterns.com. + +## Commands + +```bash +pnpm dev # Start dev server at localhost:4321 +pnpm build # Build production site to ./dist/ +pnpm preview # Preview production build locally +pnpm lint # Run ESLint +pnpm lint:fix # Fix auto-fixable ESLint issues +pnpm format # Format with Prettier +pnpm format:check # Check formatting +pnpm typecheck # TypeScript type checking +``` + +## Architecture + +### Core Stack + +- **Astro 5** - Static site generator (ESM, TypeScript) +- **Tailwind CSS 4** - Styling via Vite plugin +- **React** - Used sparingly for interactive components only +- **pnpm** - Package manager + +### Content Collections (`src/content/`) + +Strongly typed via Zod schemas in `src/content.config.ts`: + +- `authors/` - Author JSON files with profile pics +- `blog/` - Blog posts (markdown with frontmatter, each in its own folder) +- `chapters/` - Book chapter descriptions +- `faq/` - FAQ entries +- `quotes/` - Testimonials +- `reviews/` - Book reviews + +### Key Directories + +- `src/components/` - Astro components (`.astro`) and React components (`.tsx`) +- `src/components/blog/` - Blog-specific components including `BlogLayout.astro` +- `src/components/ui/` - Reusable UI components (badge, button, card) +- `src/pages/` - Astro routes (index, blog, RSS, 404) +- `src/plugins/` - Remark plugins (admonitions for tip/note/warning/etc.) +- `src/lib/` - Utilities, constants, theme configuration +- `src/images/` - Images optimized by Astro + +### Markdown Features + +Blog posts support admonitions via remark-directive: + +```markdown +:::tip[Custom Title] +Content here +::: +``` + +Types: `tip`, `note`, `important`, `caution`, `warning` + +## Development Principles + +1. **Astro-first**: Prefer Astro components over React. Use React only for interactive features that require client-side JS. + +2. **Mobile-first responsive**: Use Tailwind breakpoint prefixes (`sm:`, `md:`, `lg:`) starting from mobile layouts. + +3. **Accessibility required**: Semantic HTML, proper heading hierarchy, ARIA labels, keyboard navigation, WCAG AA contrast. + +4. **Data-driven content**: Separate data from templates. Use content collections with Zod schemas. + +5. **Lean and fast**: Optimize images, minimize JS, pre-build everything possible. + +## Spec-Driven Development Workflow + +This project uses a spec-driven development workflow: + +1. **Specify** - Create feature branch and specification +2. **Plan** - Generate implementation plan from spec +3. **Tasks** - Break plan into executable tasks + +Templates are in `templates/` and helper scripts in `scripts/`. + +## Blog Article Creation + +New blog articles should: + +- Follow the template in `.claude/article-brief-template.md` +- Use modern ESM syntax and async/await +- Include FAQ section for schema markup +- Place each article in its own folder under `src/content/blog//` + +### Content Writing Style + +When writing or editing content, follow these guidelines: + +**Tone and Readability** + +- Use a friendly, approachable tone, almost colloquial, as if explaining to a colleague +- Write conversationally while maintaining technical accuracy +- Avoid overly formal or academic language + +**Punctuation** + +- Never use em dashes (—). Use commas, parentheses, or separate sentences instead + +**Structure and Flow** + +- Always provide context first: explain "what" and "why" before diving into "how" +- Emphasize the "why": readers should understand not just how something works, but why it matters +- Connect sections smoothly using: + - Forward references: "In the next section, we'll see how to handle errors" + - Backward references: "As we saw earlier, streams provide memory efficiency" + - Previews: "Before we implement this, let's understand the underlying concept" + +**Technical Explanations** + +- Start with the problem or use case before the solution +- Explain progressively: simple version first, then edge cases +- Use real-world analogies for abstract concepts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/content/blog/nodejs-path-traversal-security/index.md b/src/content/blog/nodejs-path-traversal-security/index.md index 5a6ebd1..8a2023a 100644 --- a/src/content/blog/nodejs-path-traversal-security/index.md +++ b/src/content/blog/nodejs-path-traversal-security/index.md @@ -21,7 +21,7 @@ Building on our extensive [Node.js File Operations Guide](/blog/reading-writing- It's surprisingly easy to build Node.js applications where users can influence which files get loaded from the filesystem. Think about a simple image server: depending on the URL a user requests, your application decides which file to return. Or consider a document download endpoint, a static file server, or even a template engine that loads views based on route parameters. In all these cases, user input directly or indirectly determines a file path. -If we're not careful with our implementation, we might accidentally expose the *entire* filesystem. An attacker could read configuration files containing database credentials, access private SSH keys, or examine application source code to discover additional vulnerabilities. This information disclosure often becomes the gateway for attackers to move laterally through your infrastructure, escalating what started as a simple web request into a full system compromise. +If we're not careful with our implementation, we might accidentally expose the _entire_ filesystem. An attacker could read configuration files containing database credentials, access private SSH keys, or examine application source code to discover additional vulnerabilities. This information disclosure often becomes the gateway for attackers to move laterally through your infrastructure, escalating what started as a simple web request into a full system compromise. Path traversal has been one of the most severely exploited attack vectors in recent years, affecting everything from Apache web servers to popular npm packages. This is not a theoretical concern—it's a real and present danger that deserves your full attention when building production applications. @@ -31,7 +31,6 @@ In this article, you'll learn exactly what a path traversal attack is, how it ha You've built a Node.js application that serves user-uploaded images. The implementation is clean, efficient, and uses modern streaming APIs. But what happens when a malicious user requests `../../etc/passwd` instead of `cat.jpg`? Suddenly, your simple file server could become a gateway to your entire server filesystem. - ## Quick Answer: Secure Path Resolution If you're already familiar with path traversal attacks and just need a quick checklist to sanity-check your implementation, here's the summary: @@ -53,8 +52,7 @@ if (!realPath.startsWith(root + path.sep)) { } ``` -Read on for the complete implementation with all edge cases handled, and to understand *why* each of these measures is necessary and how they work together to protect your application. - +Read on for the complete implementation with all edge cases handled, and to understand _why_ each of these measures is necessary and how they work together to protect your application. ## Understanding Path Traversal Vulnerabilities @@ -80,7 +78,6 @@ Path traversal attacks typically follow these steps: 3. **Exploit**: The attacker sends the payload to the server. 4. **Access Files**: If successful, the attacker can now access files outside the intended directory. - ## The Vulnerable Implementation: A Naive Image Server Let's begin with a common but vulnerable implementation of an image server: @@ -103,10 +100,13 @@ const server = createServer((req, res) => { // Set content type based on file extension const ext = path.extname(filePath).toLowerCase() const type = - ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : - ext === '.png' ? 'image/png' : - ext === '.gif' ? 'image/gif' : - 'application/octet-stream' + ext === '.jpg' || ext === '.jpeg' + ? 'image/jpeg' + : ext === '.png' + ? 'image/png' + : ext === '.gif' + ? 'image/gif' + : 'application/octet-stream' const stream = createReadStream(filePath) stream.once('open', () => { @@ -134,6 +134,7 @@ Let's break down the security issues in this implementation: 4. **No Input Sanitization**: Special characters like `../` are not filtered or handled safely. When a user requests `/images/../../etc/passwd`, the code: + 1. Extracts `../../etc/passwd` from the URL 2. Joins it with the current working directory and `uploads` 3. Results in a path like `/home/user/myapp/uploads/../../etc/passwd` @@ -141,25 +142,29 @@ When a user requests `/images/../../etc/passwd`, the code: :::warning[Real-World Impact] This exact pattern has led to serious security breaches in production applications. Attackers can use path traversal to: + - Read `/etc/passwd` to enumerate system users - Access `.env` files containing API keys and database credentials - Read private SSH keys from `~/.ssh/id_rsa` - Access application source code to find more vulnerabilities -::: - + ::: ## The Attack: Common Exploitation Techniques +Now that we understand why our naive implementation is dangerous, let's explore how attackers actually exploit these vulnerabilities. Understanding the attacker's perspective helps us build better defenses. + ### Understanding the Vulnerability in Action A path traversal attack occurs when user input is used to construct file paths without proper validation. Attackers exploit this by using special sequences like `../` to navigate up the directory structure. Consider what happens when a malicious user requests: + ``` /images/../../etc/passwd ``` Our server takes this path, removes the `/images/` prefix, and joins it with our uploads directory: + ```js path.join(process.cwd(), 'uploads', '../../etc/passwd') ``` @@ -195,10 +200,9 @@ Path traversal vulnerabilities have affected many major applications: The Node.js team actively patches security vulnerabilities. Always keep your Node.js runtime updated to the latest LTS version to benefit from security fixes. Run `node --version` to check your version and visit [nodejs.org](https://nodejs.org) for the latest releases. ::: - ## The Defense: Building a Secure File Server -Now that we understand the threat, let's build a secure implementation. We'll create a multi-layered defense using modern Node.js APIs. +Now that we've seen how attackers exploit path traversal vulnerabilities, let's build a secure implementation that blocks all these attack vectors. We'll create a multi-layered defense using modern Node.js APIs, and I'll explain why each layer matters. ### Step 1: Path Validation and Canonicalization @@ -366,6 +370,7 @@ server.listen(3000, () => { :::tip[Why Pipeline Over Pipe?] The `pipeline()` function from `node:stream/promises` is superior to `.pipe()` because it: + - Properly propagates errors from any stream in the chain - Automatically cleans up streams on error or completion - Returns a promise that resolves when streaming is complete @@ -374,9 +379,10 @@ The `pipeline()` function from `node:stream/promises` is superior to `.pipe()` b Learn more about streams in our [file operations guide](/blog/reading-writing-files-nodejs/#nodejs-streams-memory-efficient-file-processing) and our [stream consumer patterns article](/blog/node-js-stream-consumer/). ::: - ## Additional Security Measures +Our secure implementation handles the core path traversal vulnerability, but security is about layers. In this section, we'll explore additional measures that provide defense in depth, covering edge cases and platform-specific concerns that could otherwise leave gaps in your protection. + ### Defense in Depth While our secure implementation addresses the primary vulnerability, security best practice demands multiple layers of protection: @@ -445,6 +451,8 @@ This validation layer catches attacks even before path resolution, providing def ### Windows-Specific Considerations +If your application runs on Windows (or might be deployed there), you need to account for how Windows handles paths differently from Unix-like systems. These differences aren't just cosmetic; they can create security gaps if your validation logic assumes Unix conventions. + Windows has unique path characteristics that require additional attention: 1. **Drive Letters**: Ensure paths can't escape to different drives (`C:\`, `D:\`) @@ -455,10 +463,32 @@ Windows has unique path characteristics that require additional attention: ```js function isWindowsReservedName(name) { const reservedNames = [ - 'CON', 'PRN', 'AUX', 'NUL', - 'COM0', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', - 'LPT0', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', - 'CONIN$', 'CONOUT$' + 'CON', + 'PRN', + 'AUX', + 'NUL', + 'COM0', + 'COM1', + 'COM2', + 'COM3', + 'COM4', + 'COM5', + 'COM6', + 'COM7', + 'COM8', + 'COM9', + 'LPT0', + 'LPT1', + 'LPT2', + 'LPT3', + 'LPT4', + 'LPT5', + 'LPT6', + 'LPT7', + 'LPT8', + 'LPT9', + 'CONIN$', + 'CONOUT$', ] const baseName = path.basename(name, path.extname(name)).toUpperCase() @@ -483,6 +513,7 @@ Always test your security measures on the platforms you deploy to. A validator t ### Race Condition Protection (TOCTOU) **Time-of-Check-Time-of-Use (TOCTOU)** attacks exploit the time gap between validating a path and actually accessing the file. For a deeper dive into race conditions in Node.js, see our [comprehensive guide on Node.js race conditions](/blog/node-js-race-conditions/). During this gap, an attacker might: + - Replace a safe file with a symlink to a sensitive file - Swap directories to bypass validation @@ -490,6 +521,7 @@ Our main server implementation above already mitigates this by opening a file ha :::note[TOCTOU in Practice] While TOCTOU attacks are theoretically possible, they're difficult to exploit in practice with our `safeResolve` implementation because: + 1. `realpath()` validates at access time, not just check time 2. The attack window is extremely small (microseconds) 3. The attacker needs write access to the uploads directory @@ -497,9 +529,10 @@ While TOCTOU attacks are theoretically possible, they're difficult to exploit in However, defense in depth means we still minimize the risk by using file handles. ::: - ## Testing Your Implementation +Writing secure code is only half the battle. You also need to verify that your defenses actually work against the attack vectors we've discussed. In this section, we'll build a test suite that validates our `safeResolve` function against common attack patterns, giving you confidence that your implementation is solid. + ### Security Testing with Assertions Always test your security implementations rigorously: @@ -603,12 +636,12 @@ Test your implementation against these attack scenarios: :::tip[Automated Security Testing] Consider using tools like: + - **[Snyk](https://snyk.io/)** for dependency vulnerability scanning - **[npm audit](https://docs.npmjs.com/cli/v8/commands/npm-audit)** for known vulnerabilities in dependencies - **[OWASP ZAP](https://www.zaproxy.org/)** for web application security testing - **[Burp Suite](https://portswigger.net/burp)** for manual penetration testing -::: - + ::: ## Monitoring and Incident Response @@ -626,7 +659,7 @@ function logSecurityEvent(event, details) { const logEntry = { timestamp, event, - ...details + ...details, } securityLog.write(JSON.stringify(logEntry) + '\n') @@ -644,10 +677,10 @@ const server = createServer(async (req, res) => { } catch (error) { logSecurityEvent('path_traversal_attempt', { path: rel, - decoded: fullyDecode(rel), // Using the fullyDecode helper from earlier + decoded: fullyDecode(rel), // Using the fullyDecode helper from earlier userAgent: req.headers['user-agent'], ip: req.socket.remoteAddress, - error: error.message + error: error.message, }) // Return generic error to client @@ -665,18 +698,18 @@ Monitor logs for suspicious patterns that might indicate an attack: ```js const suspiciousPatterns = [ - /\.\./, // Directory traversal - /%2e%2e/i, // Encoded dots - /%252e/i, // Double encoded - /\0/, // Null bytes - /etc\/passwd/, // Common target - /\.env/, // Environment files - /\.ssh/, // SSH keys - /\/\.\./, // Absolute traversal + /\.\./, // Directory traversal + /%2e%2e/i, // Encoded dots + /%252e/i, // Double encoded + /\0/, // Null bytes + /etc\/passwd/, // Common target + /\.env/, // Environment files + /\.ssh/, // SSH keys + /\/\.\./, // Absolute traversal ] function isSuspiciousPath(pathStr) { - return suspiciousPatterns.some(pattern => pattern.test(pathStr)) + return suspiciousPatterns.some((pattern) => pattern.test(pathStr)) } // Enhanced logging @@ -684,7 +717,7 @@ if (isSuspiciousPath(rel)) { logSecurityEvent('high_risk_path_detected', { path: rel, ip: req.socket.remoteAddress, - timestamp: Date.now() + timestamp: Date.now(), }) } ``` @@ -716,7 +749,6 @@ If you detect a path traversal attack: - Rotate any credentials that might have been exposed - Notify relevant stakeholders if data was compromised - ## Best Practices Summary ### Secure Coding Checklist @@ -748,7 +780,6 @@ If you detect a path traversal attack: 5. **Incident Response Plan**: Have procedures ready for responding to security incidents 6. **Container Isolation**: Use Docker or similar to limit filesystem access at OS level - ## Conclusion: Security is Not Optional Path traversal vulnerabilities are deceptively simple to introduce but can have devastating consequences. A single missing validation can expose your entire filesystem to attackers. @@ -768,7 +799,6 @@ By incorporating these practices into your development workflow, you'll build No This article builds on concepts from our [Node.js File Operations Guide](/blog/reading-writing-files-nodejs/). If you haven't read it yet, check it out to deepen your understanding of Node.js file handling with promises, streams, and file handles. ::: - ## Additional Resources ### Essential Reading From 6b3ec6e5c07f38551bc24f9643e19ca3740f4549 Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Mon, 2 Feb 2026 16:28:25 +0100 Subject: [PATCH 5/7] chore: progress on article --- .../nodejs-path-traversal-security/index.md | 186 ++++++++++++------ 1 file changed, 124 insertions(+), 62 deletions(-) diff --git a/src/content/blog/nodejs-path-traversal-security/index.md b/src/content/blog/nodejs-path-traversal-security/index.md index 8a2023a..59b4699 100644 --- a/src/content/blog/nodejs-path-traversal-security/index.md +++ b/src/content/blog/nodejs-path-traversal-security/index.md @@ -19,40 +19,98 @@ faq: Building on our extensive [Node.js File Operations Guide](/blog/reading-writing-files-nodejs/), let's explore one of the most critical security vulnerabilities related to handling files and paths in web applications: **path traversal attacks**. -It's surprisingly easy to build Node.js applications where users can influence which files get loaded from the filesystem. Think about a simple image server: depending on the URL a user requests, your application decides which file to return. Or consider a document download endpoint, a static file server, or even a template engine that loads views based on route parameters. In all these cases, user input directly or indirectly determines a file path. +It's surprisingly easy to build Node.js applications where users can influence which files get loaded from the filesystem. Think about a simple image server: depending on the URL a user requests, your application decides which file to return. A user requests `/images/cat.jpg`, and your server dutifully streams the file from your uploads directory. But what happens when a malicious user requests `/images/../../etc/passwd` instead? If you're not careful, that request could escape your uploads folder entirely and expose sensitive system files. -If we're not careful with our implementation, we might accidentally expose the _entire_ filesystem. An attacker could read configuration files containing database credentials, access private SSH keys, or examine application source code to discover additional vulnerabilities. This information disclosure often becomes the gateway for attackers to move laterally through your infrastructure, escalating what started as a simple web request into a full system compromise. +An attacker can craft malicious requests to read configuration files containing database credentials, access private SSH keys, or examine application source code to discover additional vulnerabilities. This information disclosure often becomes the gateway for attackers to move laterally through your infrastructure, escalating what started as a simple web request into a full system compromise. -Path traversal has been one of the most severely exploited attack vectors in recent years, affecting everything from Apache web servers to popular npm packages. This is not a theoretical concern—it's a real and present danger that deserves your full attention when building production applications. +Path traversal has been one of the most severely exploited attack vectors in recent years, affecting everything from Apache web servers to popular npm packages. This is not a theoretical concern; it's a real and present danger that deserves your full attention when building production applications. -In this article, you'll learn exactly what a path traversal attack is, how it happens in practice, and—most importantly—what you must do to build Node.js applications that are not vulnerable. - -## When Simple File Serving can Become Dangerous - -You've built a Node.js application that serves user-uploaded images. The implementation is clean, efficient, and uses modern streaming APIs. But what happens when a malicious user requests `../../etc/passwd` instead of `cat.jpg`? Suddenly, your simple file server could become a gateway to your entire server filesystem. +In this article, you'll learn exactly what a path traversal attack is, how it happens in practice, and (most importantly) what you must do to build Node.js applications that are not vulnerable. ## Quick Answer: Secure Path Resolution +Here's the TLDR; + If you're already familiar with path traversal attacks and just need a quick checklist to sanity-check your implementation, here's the summary: To prevent path traversal in Node.js: -1. **Decode user input** with `decodeURIComponent()` (handle double encoding with a loop) +1. **Fully decode user input** handling double/triple encoding with a loop 2. **Reject null bytes** that can truncate paths 3. **Reject absolute paths** with `path.isAbsolute()` -4. **Resolve to canonical path** with `path.resolve()` -5. **Follow symlinks** with `fs.realpath()` -6. **Verify path stays within root** using `startsWith(root + path.sep)` +4. **Reject Windows-specific paths** (drive letters, UNC paths) +5. **Resolve to canonical path** with `path.resolve()` +6. **Follow symlinks** with `fs.realpath()` +7. **Verify path stays within root** using `startsWith(root + path.sep)` + +Here's a possible implementation of all these precautions: ```js -const safePath = path.resolve(root, decodeURIComponent(userInput)) -const realPath = await fs.realpath(safePath) -if (!realPath.startsWith(root + path.sep)) { - throw new Error('Path traversal detected') +// safe-resolve.js +import path from 'node:path' +import fs from 'node:fs/promises' + +function fullyDecode(input) { + let result = String(input) + for (let i = 0; i < 10; i++) { + try { + const decoded = decodeURIComponent(result) + if (decoded === result) break + result = decoded + } catch { + // decodeURIComponent throws a URIError on malformed sequences + break + } + } + return result +} + +export async function safeResolve(root, userInput) { + // 1. Fully decode (handles double/triple encoding) + const decoded = fullyDecode(userInput) + + // 2. Reject null bytes + if (decoded.includes('\0')) { + throw new Error('Null bytes not allowed') + } + + // 3. Reject absolute paths + if (path.isAbsolute(decoded)) { + throw new Error('Absolute paths not allowed') + } + + // 4. Reject Windows drive letters and UNC paths + if (/^[a-zA-Z]:/.test(decoded)) { + throw new Error('Drive letters not allowed') + } + if (decoded.startsWith('\\\\') || decoded.startsWith('//')) { + throw new Error('UNC paths not allowed') + } + + // 5. Resolve to canonical path + const safePath = path.resolve(root, decoded) + + // 6. Follow symlinks + const realPath = await fs.realpath(safePath) + + // 7. Verify path stays within root + if (!realPath.startsWith(root + path.sep)) { + throw new Error('Path traversal detected') + } + + return realPath } ``` -Read on for the complete implementation with all edge cases handled, and to understand _why_ each of these measures is necessary and how they work together to protect your application. +:::important[Resolve root with realpath at startup] +The `root` parameter should be pre-resolved with `fs.realpath()` at application startup. On systems where the root path contains symlinks (like macOS where `/var` is a symlink to `/private/var`), `fs.realpath()` on user files returns the fully resolved path. If your root isn't also resolved, the `startsWith` check will fail even for valid paths. +::: + +:::warning[Don't Forget Your Dependencies] +Path traversal vulnerabilities can also exist in your dependencies, not just in your own application code. A layered defense approach that combines secure coding practices, regular dependency updates, and vulnerability scanning (using tools like `npm audit`) is essential for maintaining a secure application. +::: + +Don't just copy-paste the snippet above into your app without understanding it. Read on to learn _why_ each of these measures is necessary and how they work together to protect your application. ## Understanding Path Traversal Vulnerabilities @@ -83,11 +141,12 @@ Path traversal attacks typically follow these steps: Let's begin with a common but vulnerable implementation of an image server: ```js +// vulnerable-image-server.js import { createServer } from 'node:http' import { createReadStream } from 'node:fs' import path from 'node:path' -// VULNERABLE: Do not use in production +// ⚠️ VULNERABLE: Do not use in production const server = createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`) @@ -124,6 +183,10 @@ server.listen(3000, () => { }) ``` +This server handles requests to `/images/*` by extracting the path after `/images/`, joining it with an `uploads` directory, and streaming the file back to the client. It determines the content type based on the file extension and uses Node.js streams to efficiently serve the file without loading it entirely into memory. + +At first glance, this looks like a reasonable implementation. But there's a critical security flaw lurking in this code. + ### Why This Code is Vulnerable Let's break down the security issues in this implementation: @@ -133,21 +196,21 @@ Let's break down the security issues in this implementation: 3. **No Boundary Checking**: There's no verification that the final path is still within the `uploads` directory. 4. **No Input Sanitization**: Special characters like `../` are not filtered or handled safely. -When a user requests `/images/../../etc/passwd`, the code: +When a user requests `/images/../../../../etc/passwd`, the code: -1. Extracts `../../etc/passwd` from the URL +1. Extracts `../../../../etc/passwd` from the URL 2. Joins it with the current working directory and `uploads` -3. Results in a path like `/home/user/myapp/uploads/../../etc/passwd` -4. Which resolves to `/home/user/etc/passwd` - completely outside our intended directory! +3. Results in a path like `/home/user/myapp/uploads/../../../../etc/passwd` +4. Which resolves to `/etc/passwd`, completely outside our intended directory! :::warning[Real-World Impact] -This exact pattern has led to serious security breaches in production applications. Attackers can use path traversal to: +The attack described above allows an attacker to read `/etc/passwd`, which on Unix systems reveals the list of system users. But that's just the beginning. Other ways attackers exploit path traversal vulnerabilities include: -- Read `/etc/passwd` to enumerate system users -- Access `.env` files containing API keys and database credentials -- Read private SSH keys from `~/.ssh/id_rsa` -- Access application source code to find more vulnerabilities - ::: +- Accessing `.env` files containing API keys and database credentials +- Reading private SSH keys from `~/.ssh/id_rsa` +- Examining application source code to discover additional vulnerabilities +- Reading configuration files to understand the system architecture +::: ## The Attack: Common Exploitation Techniques @@ -160,16 +223,16 @@ A path traversal attack occurs when user input is used to construct file paths w Consider what happens when a malicious user requests: ``` -/images/../../etc/passwd +/images/../../../../etc/passwd ``` Our server takes this path, removes the `/images/` prefix, and joins it with our uploads directory: ```js -path.join(process.cwd(), 'uploads', '../../etc/passwd') +path.join(process.cwd(), 'uploads', '../../../../etc/passwd') ``` -This resolves to something like `/home/user/myapp/uploads/../../etc/passwd`, which is equivalent to `/home/user/etc/passwd` - completely outside our intended uploads directory! +This resolves to something like `/home/user/myapp/uploads/../../../../etc/passwd`, which is equivalent to `/etc/passwd`. That's completely outside our intended uploads directory! ### Common Attack Vectors @@ -180,7 +243,7 @@ Path traversal attacks can take many sophisticated forms: 3. **Double encoding**: `..%252F..%252Fetc%252Fpasswd` 4. **Windows paths**: `..\..\windows\system32\config\sam` 5. **Mixed encoding**: `..%2F..%5Cetc%2Fpasswd` -6. **Overlong UTF-8**: `..%c0%af..%c0%afetc%c0%afpasswd` (largely a legacy attack vector - modern UTF-8 parsers reject these malformed sequences, but older systems may be vulnerable) +6. **Overlong UTF-8**: `..%c0%af..%c0%afetc%c0%afpasswd` (largely a legacy attack vector; modern UTF-8 parsers reject these malformed sequences, but older systems may be vulnerable) :::tip[URL Decoding Matters] Many HTTP servers and frameworks decode URL-encoded characters once, but behavior varies by framework. The important point is that validation must happen **after** full URL decoding, not before. Always decode input explicitly rather than relying on framework behavior. @@ -193,7 +256,7 @@ Path traversal vulnerabilities have affected many major applications: 1. **Apache HTTP Server (CVE-2021-41773)**: A path traversal flaw in Apache httpd 2.4.49 that allowed attackers to map URLs to files outside the document root, leading to arbitrary file reads and potential RCE. 2. **`st` npm module (CVE-2014-6394)**: A classic Node.js ecosystem example where the popular static file serving module was vulnerable to directory traversal via URL-encoded sequences. 3. **`serve` npm module (CVE-2019-5418)**: Path traversal vulnerability in the serve package allowing access to files outside the served directory through crafted requests. -4. **Jenkins (CVE-2024-23897)**: Arbitrary file read via CLI "@file" argument expansion - while not pure path traversal, it demonstrates how path-based input can lead to unauthorized file access. +4. **Jenkins (CVE-2024-23897)**: Arbitrary file read via CLI "@file" argument expansion. While not pure path traversal, it demonstrates how path-based input can lead to unauthorized file access. 5. **Node.js (CVE-2023-32002)**: Policy bypass via path traversal in Node.js experimental policy feature, allowing module loading restrictions to be circumvented. :::tip[Keep Node.js Updated] @@ -206,15 +269,18 @@ Now that we've seen how attackers exploit path traversal vulnerabilities, let's ### Step 1: Path Validation and Canonicalization -First, let's create a utility function that safely resolves user-provided paths: +First, let's create a utility function that safely resolves user-provided paths. This is the same function from the Quick Answer section, shown here with detailed comments: ```js import path from 'node:path' import fs from 'node:fs/promises' +/** + * Fully decodes URL-encoded input, handling double/triple encoding. + */ function fullyDecode(input) { let result = String(input) - // Decode repeatedly until the string stops changing (handles double/triple encoding) + // Decode repeatedly until the string stops changing // Limit iterations to prevent infinite loops on malformed input for (let i = 0; i < 10; i++) { try { @@ -222,57 +288,53 @@ function fullyDecode(input) { if (decoded === result) break result = decoded } catch { - break // Stop on decode error (malformed encoding) + // decodeURIComponent throws URIError on malformed sequences (e.g., '%', '%zz') + break } } return result } +/** + * Safely resolves a user-provided path within a root directory. + * IMPORTANT: root must be pre-resolved with fs.realpath() at startup. + */ export async function safeResolve(root, userPath) { - // Fully decode any URL-encoded characters (handles double encoding) + // 1. Fully decode any URL-encoded characters (handles double encoding) const decoded = fullyDecode(userPath) - // Reject null bytes (used to bypass extension checks) + // 2. Reject null bytes (used to bypass extension checks) if (decoded.includes('\0')) { throw new Error('Null bytes not allowed') } - // Reject absolute paths immediately + // 3. Reject absolute paths immediately if (path.isAbsolute(decoded)) { throw new Error('Absolute paths not allowed') } - // Reject Windows drive letters (e.g., C:, D:) + // 4. Reject Windows drive letters (e.g., C:, D:) if (/^[a-zA-Z]:/.test(decoded)) { throw new Error('Drive letters not allowed') } - // Reject UNC paths (e.g., \\server\share or //server/share) + // 5. Reject UNC paths (e.g., \\server\share or //server/share) if (decoded.startsWith('\\\\') || decoded.startsWith('//')) { throw new Error('UNC paths not allowed') } - // Normalize the path and resolve against our root - const resolved = path.resolve(root, decoded) - - // Resolve symlinks to prevent symlink-based escapes - const real = await fs.realpath(resolved).catch(() => resolved) - - // Also resolve root to handle symlinks in the root path - const realRoot = await fs.realpath(root).catch(() => path.resolve(root)) + // 6. Resolve to canonical path + const safePath = path.resolve(root, decoded) - // Use path.relative for robust containment check - // This handles: Windows case-insensitivity, root edge cases (e.g., root === '/'), - // and trailing slash variations - const relative = path.relative(realRoot, real) + // 7. Follow symlinks to get the real path + const realPath = await fs.realpath(safePath) - // If relative path starts with '..' or is absolute, it's outside root - // Empty string means they're the same path (accessing root itself is allowed) - if (relative.startsWith('..') || path.isAbsolute(relative)) { + // 8. Verify the path stays within root + if (!realPath.startsWith(root + path.sep)) { throw new Error('Path traversal detected') } - return real + return realPath } ``` @@ -280,7 +342,7 @@ export async function safeResolve(root, userPath) { Let's break down each security measure in our `safeResolve` function: -1. **Full Input Decoding**: The `fullyDecode` function handles URL-encoded characters in a loop, decoding repeatedly until the string stops changing. This catches double encoding attacks (`%252F` → `%2F` → `/`) and triple encoding. We limit to 10 iterations to prevent infinite loops on malicious input. +1. **Full Input Decoding**: The `fullyDecode` function handles URL-encoded characters in a loop, decoding repeatedly until the string stops changing. This catches double encoding attacks (`%252F` → `%2F` → `/`) and triple encoding. We limit to 10 iterations to prevent infinite loops on malicious input. Note that `decodeURIComponent` throws a `URIError` on malformed sequences like `%` or `%zz`, so we wrap it in a try/catch and stop decoding if an error occurs. 2. **Null Byte Rejection**: Null bytes (`\0`) are used in null byte injection attacks to truncate paths. For example, `valid.jpg\0../../etc/passwd` might pass extension checks but access different files. We reject these explicitly. @@ -292,12 +354,12 @@ Let's break down each security measure in our `safeResolve` function: 6. **Path Resolution**: `path.resolve()` normalizes the path, handling `.` and `..` segments correctly. This converts relative paths to absolute paths and removes any path traversal sequences. -7. **Symlink Resolution**: `fs.realpath()` follows symbolic links to their actual destinations, preventing symlink-based escapes. An attacker could create a symlink inside the uploads directory pointing to sensitive files elsewhere - this prevents that attack. We also resolve the root path to handle symlinks in the root itself. +7. **Symlink Resolution**: `fs.realpath()` follows symbolic links to their actual destinations, preventing symlink-based escapes. An attacker could create a symlink inside the uploads directory pointing to sensitive files elsewhere, and this check prevents that attack. -8. **Boundary Checking**: We use `path.relative()` to compute the relative path from root to the resolved path. If it starts with `..` or is absolute, the path is outside our root. This approach correctly handles: Windows case-insensitivity, edge cases when `root === '/'`, and trailing slash variations. +8. **Boundary Checking**: The `startsWith(root + path.sep)` check verifies the resolved path is still within our allowed directory. Adding `path.sep` prevents a subtle bug where a path like `/uploads-backup/secret.txt` would incorrectly pass a check against `/uploads`. :::important[Why Both Resolve and Realpath?] -`path.resolve()` handles `..` sequences but doesn't follow symlinks. `fs.realpath()` follows symlinks but only works for paths that exist on disk - for non-existent paths, it throws an error (which we catch and fall back to the resolved path). Using both provides defense in depth - even if one has a subtle bug or edge case, the other provides protection. Note that symlink protection only applies to files that already exist. +`path.resolve()` handles `..` sequences but doesn't follow symlinks. `fs.realpath()` follows symlinks and returns the canonical path, but throws `ENOENT` if the file doesn't exist. Using both provides defense in depth: `resolve()` normalizes traversal sequences, while `realpath()` catches symlink-based escapes. If a file doesn't exist, `realpath()` throws before the boundary check, which is the safe behavior (don't reveal whether paths outside root exist). ::: ### Step 2: Secure Streaming Implementation @@ -793,7 +855,7 @@ Path traversal vulnerabilities are deceptively simple to introduce but can have 5. **Layer your defenses** - Multiple validation steps provide protection even if one fails 6. **Test thoroughly** - Include security tests alongside functional tests -By incorporating these practices into your development workflow, you'll build Node.js applications that can withstand common attack vectors. Security isn't an afterthought - it's a fundamental aspect of writing reliable, professional code. +By incorporating these practices into your development workflow, you'll build Node.js applications that can withstand common attack vectors. Security isn't an afterthought; it's a fundamental aspect of writing reliable, professional code. :::tip[Continue Learning] This article builds on concepts from our [Node.js File Operations Guide](/blog/reading-writing-files-nodejs/). If you haven't read it yet, check it out to deepen your understanding of Node.js file handling with promises, streams, and file handles. From 00df48e996db0b6b6fbc47ecc2c0995583833912 Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Mon, 2 Feb 2026 19:07:41 +0100 Subject: [PATCH 6/7] chore: good progress on article --- .../nodejs-path-traversal-security/index.md | 286 ++++++++---------- 1 file changed, 129 insertions(+), 157 deletions(-) diff --git a/src/content/blog/nodejs-path-traversal-security/index.md b/src/content/blog/nodejs-path-traversal-security/index.md index 59b4699..cacfeed 100644 --- a/src/content/blog/nodejs-path-traversal-security/index.md +++ b/src/content/blog/nodejs-path-traversal-security/index.md @@ -1,6 +1,6 @@ --- -date: 2026-01-30T10:00:00 -updatedAt: 2026-01-30T10:00:00 +date: 2026-02-02T18:17:00 +updatedAt: 2026-02-02T18:17:00 title: 'Node.js Path Traversal: Prevention & Security Guide' slug: nodejs-path-traversal-security description: Learn to prevent path traversal attacks in Node.js. Secure file servers with input validation, boundary checks, and defense-in-depth patterns. @@ -214,31 +214,13 @@ The attack described above allows an attacker to read `/etc/passwd`, which on Un ## The Attack: Common Exploitation Techniques -Now that we understand why our naive implementation is dangerous, let's explore how attackers actually exploit these vulnerabilities. Understanding the attacker's perspective helps us build better defenses. - -### Understanding the Vulnerability in Action - -A path traversal attack occurs when user input is used to construct file paths without proper validation. Attackers exploit this by using special sequences like `../` to navigate up the directory structure. - -Consider what happens when a malicious user requests: - -``` -/images/../../../../etc/passwd -``` - -Our server takes this path, removes the `/images/` prefix, and joins it with our uploads directory: - -```js -path.join(process.cwd(), 'uploads', '../../../../etc/passwd') -``` - -This resolves to something like `/home/user/myapp/uploads/../../../../etc/passwd`, which is equivalent to `/etc/passwd`. That's completely outside our intended uploads directory! +Now that we understand why our naive implementation is dangerous, let's explore the various techniques attackers use to exploit path traversal vulnerabilities. Understanding these attack vectors helps us build better defenses. ### Common Attack Vectors Path traversal attacks can take many sophisticated forms: -1. **Basic traversal**: `../../etc/passwd` +1. **Basic traversal**: `../../etc/passwd` (the case we have just seen) 2. **URL encoding**: `..%2F..%2Fetc%2Fpasswd` 3. **Double encoding**: `..%252F..%252Fetc%252Fpasswd` 4. **Windows paths**: `..\..\windows\system32\config\sam` @@ -251,16 +233,17 @@ Many HTTP servers and frameworks decode URL-encoded characters once, but behavio ### Real-World Examples -Path traversal vulnerabilities have affected many major applications: +Path traversal vulnerabilities have affected many major applications even outside the realm of Node.js. Here are some notable examples: -1. **Apache HTTP Server (CVE-2021-41773)**: A path traversal flaw in Apache httpd 2.4.49 that allowed attackers to map URLs to files outside the document root, leading to arbitrary file reads and potential RCE. -2. **`st` npm module (CVE-2014-6394)**: A classic Node.js ecosystem example where the popular static file serving module was vulnerable to directory traversal via URL-encoded sequences. -3. **`serve` npm module (CVE-2019-5418)**: Path traversal vulnerability in the serve package allowing access to files outside the served directory through crafted requests. -4. **Jenkins (CVE-2024-23897)**: Arbitrary file read via CLI "@file" argument expansion. While not pure path traversal, it demonstrates how path-based input can lead to unauthorized file access. -5. **Node.js (CVE-2023-32002)**: Policy bypass via path traversal in Node.js experimental policy feature, allowing module loading restrictions to be circumvented. +1. **Apache HTTP Server ([CVE-2021-41773](https://www.cve.org/CVERecord?id=CVE-2021-41773))**: A path traversal flaw in Apache httpd 2.4.49 that allowed attackers to map URLs to files outside the document root, leading to arbitrary file reads and potential RCE. +2. **Ruby on Rails ([CVE-2019-5418](https://www.cve.org/CVERecord?id=CVE-2019-5418))**: File content disclosure in Action View through crafted HTTP accept headers, potentially exposing secrets and enabling RCE. +3. **`send` npm module ([CVE-2014-6394](https://www.cve.org/CVERecord?id=CVE-2014-6394))**: A classic Node.js ecosystem example where the popular static file serving module was vulnerable to directory traversal. +4. **`serve` npm module ([CVE-2019-5417](https://www.cve.org/CVERecord?id=CVE-2019-5417))**: Path traversal vulnerability in serve version 7.0.1 that allowed attackers to read arbitrary files on the server. +5. **Jenkins ([CVE-2024-23897](https://www.cve.org/CVERecord?id=CVE-2024-23897))**: Arbitrary file read via CLI "@file" argument expansion. While not pure path traversal, it demonstrates how path-based input can lead to unauthorized file access. +6. **Node.js ([CVE-2023-32002](https://www.cve.org/CVERecord?id=CVE-2023-32002))**: Policy bypass via path traversal in Node.js experimental policy feature, allowing module loading restrictions to be circumvented. :::tip[Keep Node.js Updated] -The Node.js team actively patches security vulnerabilities. Always keep your Node.js runtime updated to the latest LTS version to benefit from security fixes. Run `node --version` to check your version and visit [nodejs.org](https://nodejs.org) for the latest releases. +The Node.js team actively patches security vulnerabilities. Always keep your Node.js runtime updated to the latest LTS version to benefit from security fixes. Run `node --version` to check your version. If you need to update, check out our guide on [5 ways to install Node.js](/blog/5-ways-to-install-node-js/) which covers version managers like nvm and fnm that make switching and updating versions easy. ::: ## The Defense: Building a Secure File Server @@ -269,9 +252,10 @@ Now that we've seen how attackers exploit path traversal vulnerabilities, let's ### Step 1: Path Validation and Canonicalization -First, let's create a utility function that safely resolves user-provided paths. This is the same function from the Quick Answer section, shown here with detailed comments: +We've already seen how to safely resolve user-provided paths at the beginning of the article. Let's take a closer look at that code and explore in more detail why this approach protects us against path traversal attacks. Then we'll apply this utility to our naive image server. ```js +// safe-resolve.js import path from 'node:path' import fs from 'node:fs/promises' @@ -342,7 +326,7 @@ export async function safeResolve(root, userPath) { Let's break down each security measure in our `safeResolve` function: -1. **Full Input Decoding**: The `fullyDecode` function handles URL-encoded characters in a loop, decoding repeatedly until the string stops changing. This catches double encoding attacks (`%252F` → `%2F` → `/`) and triple encoding. We limit to 10 iterations to prevent infinite loops on malicious input. Note that `decodeURIComponent` throws a `URIError` on malformed sequences like `%` or `%zz`, so we wrap it in a try/catch and stop decoding if an error occurs. +1. **Full Input Decoding**: The `fullyDecode` function handles URL-encoded characters in a loop, decoding repeatedly until the string stops changing. This catches double encoding attacks (`%252F` → `%2F` → `/`) and triple encoding (`%25252F` → `%252F` → `%2F` → `/`). We limit to 10 iterations to prevent denial-of-service (DoS) attacks where an attacker sends deeply nested encoded input to keep the server busy, potentially making it unresponsive or causing it to crash. Note that `decodeURIComponent` throws a `URIError` on malformed sequences like `%` or `%zz`, so we wrap it in a try/catch and stop decoding if an error occurs. 2. **Null Byte Rejection**: Null bytes (`\0`) are used in null byte injection attacks to truncate paths. For example, `valid.jpg\0../../etc/passwd` might pass extension checks but access different files. We reject these explicitly. @@ -366,54 +350,49 @@ Let's break down each security measure in our `safeResolve` function: Now let's update our server to use this secure path resolution: -```js +```js {9,16-21} +// secure-image-server.js import { createServer } from 'node:http' -import { open } from 'node:fs/promises' -import { pipeline } from 'node:stream/promises' +import { createReadStream } from 'node:fs' +import { realpath } from 'node:fs/promises' import path from 'node:path' import { safeResolve } from './safe-resolve.js' -const ROOT = path.resolve(process.cwd(), 'uploads') - -function contentTypeFor(filePath) { - const ext = path.extname(filePath).toLowerCase() - if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg' - if (ext === '.png') return 'image/png' - if (ext === '.gif') return 'image/gif' - if (ext === '.webp') return 'image/webp' - return 'application/octet-stream' -} +// Resolve root at startup to handle symlinks (e.g., /var -> /private/var on macOS) +const ROOT = await realpath(path.resolve(process.cwd(), 'uploads')) const server = createServer(async (req, res) => { - let fileHandle - try { - const url = new URL(req.url, `http://${req.headers.host}`) - const rel = url.pathname.replace(/^\/images\//, '') - - // Safely resolve the user-provided path - const imagePath = await safeResolve(ROOT, rel) - - // Open file handle immediately after validation to minimize TOCTOU window - fileHandle = await open(imagePath, 'r') + const url = new URL(req.url, `http://${req.headers.host}`) + const rel = url.pathname.replace(/^\/images\//, '') - res.writeHead(200, { 'Content-Type': contentTypeFor(imagePath) }) + // SECURE: Use safeResolve to validate and resolve the path + const filePath = await safeResolve(ROOT, rel).catch(() => null) + if (!filePath) { + res.writeHead(400) + res.end('Invalid path') + return + } - // Stream from the file handle, not the path - const stream = fileHandle.createReadStream() - await pipeline(stream, res) - } catch (error) { - // Don't expose internal error details to the client - if (!res.headersSent) { - res.writeHead(400) - } - res.end('Invalid path or image not found') + // Set content type based on file extension + const ext = path.extname(filePath).toLowerCase() + const type = + ext === '.jpg' || ext === '.jpeg' + ? 'image/jpeg' + : ext === '.png' + ? 'image/png' + : ext === '.gif' + ? 'image/gif' + : 'application/octet-stream' - // Log the actual error for debugging - console.error('File serving error:', error.message) - } finally { - // Ensure the file handle is always closed - await fileHandle?.close() - } + const stream = createReadStream(filePath) + stream.once('open', () => { + res.writeHead(200, { 'Content-Type': type }) + stream.pipe(res) + }) + stream.once('error', () => { + res.writeHead(404) + res.end('Image not found') + }) }) server.listen(3000, () => { @@ -423,22 +402,15 @@ server.listen(3000, () => { ### Why This Implementation is Secure -1. **Path Validation**: Uses our `safeResolve` function to validate all paths before file access. -2. **TOCTOU Mitigation**: Opens a file handle immediately after validation, minimizing the window for race condition attacks. -3. **Error Handling**: Provides generic error messages to avoid information leakage while logging details for debugging. -4. **Streaming with Pipeline**: Uses [`pipeline()`](/blog/reading-writing-files-nodejs/#stream-composition-and-processing) for proper backpressure handling and automatic cleanup. -5. **Resource Cleanup**: Uses `try/finally` to ensure file handles are always closed, even on errors. -6. **Immutable Root**: The `ROOT` constant is resolved once at startup and never modified. - -:::tip[Why Pipeline Over Pipe?] -The `pipeline()` function from `node:stream/promises` is superior to `.pipe()` because it: +The changes are minimal but effective: -- Properly propagates errors from any stream in the chain -- Automatically cleans up streams on error or completion -- Returns a promise that resolves when streaming is complete -- Handles backpressure correctly across all streams +1. **Path Validation**: The `safeResolve` function validates all user input before any file access occurs, blocking traversal attempts, null bytes, absolute paths, and other attack vectors. +2. **Root Resolved at Startup**: Using `realpath()` on the root directory at startup ensures symlinks are resolved correctly (important on systems like macOS where `/var` is a symlink to `/private/var`). +3. **Early Rejection**: Invalid paths are caught and rejected with a generic error message before reaching any file operations, avoiding information leakage about the filesystem structure. +4. **Minimal Changes**: The rest of the code remains identical to the original, making it easy to understand and audit the security fix. -Learn more about streams in our [file operations guide](/blog/reading-writing-files-nodejs/#nodejs-streams-memory-efficient-file-processing) and our [stream consumer patterns article](/blog/node-js-stream-consumer/). +:::tip[Want Even More Security?] +For production applications, consider using [`pipeline()`](/blog/reading-writing-files-nodejs/#stream-composition-and-processing) instead of `.pipe()` for better error handling and automatic cleanup. You can also open a file handle immediately after validation to minimize TOCTOU (time-of-check-time-of-use) race conditions. Learn more in our [file operations guide](/blog/reading-writing-files-nodejs/#nodejs-streams-memory-efficient-file-processing). ::: ## Additional Security Measures @@ -459,7 +431,8 @@ While our secure implementation addresses the primary vulnerability, security be If you're using Express.js, here's how to integrate secure path resolution: -```js +```js {11} +// express-secure-image-server.js import express from 'express' import path from 'node:path' import { safeResolve } from './safe-resolve.js' @@ -480,13 +453,20 @@ app.get('/files/:filepath(*)', async (req, res) => { app.listen(3000) ``` -Note: Express's `res.sendFile()` has some built-in protections, but always validate paths yourself rather than relying on framework behavior. +:::note +Express's `res.sendFile()` has some built-in protections, but always validate paths yourself rather than relying on framework behavior. +::: ### Implementing Input Validation -Add an extra layer of validation with strict filename rules: +While `safeResolve` protects against path traversal, adding input validation creates an additional safety net. This follows the principle of defense in depth: if one layer fails (due to a bug, misconfiguration, or a novel attack vector), other layers can still catch the threat. + +Input validation is particularly valuable because it rejects malicious input early, before it reaches more complex logic. This makes your code easier to reason about and debug, and it can also improve performance by avoiding unnecessary filesystem operations on obviously invalid input. + +Here's an example of strict filename validation: ```js +// validate-filename.js function validateFileName(fileName) { // Only allow alphanumeric characters, dots, hyphens, and underscores const validPattern = /^[a-zA-Z0-9._-]+$/ @@ -523,6 +503,7 @@ Windows has unique path characteristics that require additional attention: 4. **Case Insensitivity**: Windows treats `File.txt` and `file.txt` as the same ```js +// windows-path-utils.js function isWindowsReservedName(name) { const reservedNames = [ 'CON', @@ -597,91 +578,76 @@ Writing secure code is only half the battle. You also need to verify that your d ### Security Testing with Assertions -Always test your security implementations rigorously: +Security code that isn't tested is security code you can't trust. Unlike functional bugs that cause visible failures, security vulnerabilities often remain silent until exploited. Automated tests ensure your defenses work as expected and catch regressions when code changes. + +A good security test suite should cover both positive cases (valid input works correctly) and negative cases (malicious input is rejected). For path traversal specifically, test against all the attack vectors we've discussed: basic traversal, URL encoding, double encoding, null bytes, and absolute paths. + +Here's an example test suite using the Node.js built-in test runner: ```js +// safe-resolve.test.js +import { describe, it } from 'node:test' import assert from 'node:assert' import { safeResolve } from './safe-resolve.js' -async function testSafeResolve() { - const root = '/app/uploads' - - // Valid paths should resolve correctly - const validPath = await safeResolve(root, 'images/cat.jpg') - assert(validPath.startsWith(root)) - console.log('✓ Valid path resolves correctly') +const root = '/app/uploads' - // Traversal attempts should throw - try { - await safeResolve(root, '../../etc/passwd') - assert.fail('Should have thrown for traversal attempt') - } catch (error) { - assert(error.message.includes('Path traversal detected')) - console.log('✓ Basic traversal blocked') - } - - // Absolute paths should be rejected - try { - await safeResolve(root, '/etc/passwd') - assert.fail('Should have thrown for absolute path') - } catch (error) { - assert(error.message.includes('Absolute paths not allowed')) - console.log('✓ Absolute paths rejected') - } - - // URL-encoded traversal should be caught - try { - await safeResolve(root, '..%2F..%2Fetc%2Fpasswd') - assert.fail('Should have thrown for encoded traversal') - } catch (error) { - assert(error.message.includes('Path traversal detected')) - console.log('✓ Encoded traversal blocked') - } +describe('safeResolve', () => { + it('should resolve valid paths correctly', async () => { + const result = await safeResolve(root, 'images/cat.jpg') + assert(result.startsWith(root)) + }) - // Double-encoded traversal should be caught - try { - await safeResolve(root, '..%252F..%252Fetc%252Fpasswd') - assert.fail('Should have thrown for double-encoded traversal') - } catch (error) { - assert(error.message.includes('Path traversal detected')) - console.log('✓ Double-encoded traversal blocked') - } + it('should block basic traversal', async () => { + await assert.rejects( + safeResolve(root, '../../etc/passwd'), + /Path traversal detected/ + ) + }) - // Windows backslash traversal should be caught - try { - await safeResolve(root, '..\\..\\..\\windows\\system32\\config\\sam') - assert.fail('Should have thrown for backslash traversal') - } catch (error) { - assert(error.message.includes('Path traversal detected')) - console.log('✓ Windows backslash traversal blocked') - } + it('should reject absolute paths', async () => { + await assert.rejects( + safeResolve(root, '/etc/passwd'), + /Absolute paths not allowed/ + ) + }) - // Null byte injection should be rejected - try { - await safeResolve(root, 'valid.jpg\0../../etc/passwd') - assert.fail('Should have thrown for null byte') - } catch (error) { - assert(error.message.includes('Null bytes not allowed')) - console.log('✓ Null byte rejected') - } + it('should block URL-encoded traversal', async () => { + await assert.rejects( + safeResolve(root, '..%2F..%2Fetc%2Fpasswd'), + /Path traversal detected/ + ) + }) - // UNC paths should be rejected - try { - await safeResolve(root, '//server/share/sensitive.txt') - assert.fail('Should have thrown for UNC path') - } catch (error) { - assert(error.message.includes('UNC paths not allowed')) - console.log('✓ UNC paths rejected') - } + it('should block double-encoded traversal', async () => { + await assert.rejects( + safeResolve(root, '..%252F..%252Fetc%252Fpasswd'), + /Path traversal detected/ + ) + }) - console.log('\n✓ All security tests passed!') -} + it('should reject null bytes', async () => { + await assert.rejects( + safeResolve(root, 'valid.jpg\0../../etc/passwd'), + /Null bytes not allowed/ + ) + }) -testSafeResolve().catch(console.error) + it('should reject UNC paths', async () => { + await assert.rejects( + safeResolve(root, '//server/share/sensitive.txt'), + /UNC paths not allowed|Absolute paths not allowed/ + ) + }) +}) ``` +Run the tests with `node --test safe-resolve.test.js`. + ### Penetration Testing Checklist +Automated tests are essential, but they only cover the scenarios you've anticipated. Manual penetration testing helps uncover edge cases and unexpected behaviors that automated tests might miss. Before deploying to production, walk through this checklist manually using tools like `curl` or a browser's developer tools to craft malicious requests. + Test your implementation against these attack scenarios: - [ ] **Basic Traversal**: `../../../etc/passwd` @@ -707,11 +673,16 @@ Consider using tools like: ## Monitoring and Incident Response +Even with robust defenses in place, monitoring is essential. Attackers often probe systems before launching full attacks, and detecting these early attempts can help you respond before any damage occurs. + +Comprehensive logging serves two critical purposes. First, it creates an audit trail for investigating incidents after the fact, helping you understand what happened, when, and how. Second, it enables automated mitigation strategies: when you detect suspicious patterns (like repeated traversal attempts from the same IP), you can automatically block the attacker, rate-limit their requests, or trigger alerts for manual review. This proactive approach can stop an attack in progress and prevent escalation. + ### Logging Suspicious Activity -Implement comprehensive logging to detect and respond to attacks: +Here's how to implement security event logging: ```js +// security-logger.js import { createWriteStream } from 'node:fs' const securityLog = createWriteStream('security.log', { flags: 'a' }) @@ -759,6 +730,7 @@ const server = createServer(async (req, res) => { Monitor logs for suspicious patterns that might indicate an attack: ```js +// detect-attacks.js const suspiciousPatterns = [ /\.\./, // Directory traversal /%2e%2e/i, // Encoded dots From 39f5001709f91005b84d2c12c4bdec6de8fcb4a1 Mon Sep 17 00:00:00 2001 From: Luciano Mammino Date: Mon, 2 Feb 2026 19:17:21 +0100 Subject: [PATCH 7/7] chore: other improvements --- .claude/content-calendar.md | 36 +++++++++++++++++-- .../nodejs-path-traversal-security/index.md | 18 ++++++---- src/lib/analytics.ts | 32 ++++++++++++++--- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/.claude/content-calendar.md b/.claude/content-calendar.md index f2cd725..727a5c9 100644 --- a/.claude/content-calendar.md +++ b/.claude/content-calendar.md @@ -11,6 +11,7 @@ 1. **Node.js Core APIs & Built-ins** - High-volume foundational topics 2. **Modern Node.js Features** - New capabilities (22+, 23+) with early-mover SEO advantage 3. **Node.js Patterns & Architecture** - Advanced patterns (light connection to book) +4. **Node.js Security** - Security best practices and vulnerability prevention --- @@ -52,12 +53,21 @@ | 15 | NOT STARTED | Import maps in Node.js | "node js import maps" | 1,000+ | Modern | | 16 | NOT STARTED | Encrypting files with Node.js | "node js encrypt file" | 1,500+ | Core APIs | -### Month 9-12: Advanced & Experimental +### Month 9-10: Security + +| # | Status | Article | Primary Keyword | Est. Volume | Pillar | +| --- | -------- | --------------------------------- | --------------------------------- | ----------- | -------- | +| 17 | COMPLETE | **Path Traversal Security Guide** | "node js path traversal" | 1,000+ | Security | +| 18 | NOT STARTED | Input Validation in Node.js | "node js input validation" | 2,000+ | Security | +| 19 | NOT STARTED | Secure File Uploads | "node js secure file upload" | 1,500+ | Security | +| 20 | NOT STARTED | OWASP Top 10 for Node.js | "node js security best practices" | 3,000+ | Security | + +### Month 11-12: Advanced & Experimental | # | Status | Article | Primary Keyword | Notes | | --- | ----------- | ------------------------------ | --------------- | --------------------- | -| 17+ | NOT STARTED | Rust/Zig + Node.js integration | niche | Thought leadership | -| 18+ | NOT STARTED | TBD based on analytics | TBD | Iterate based on data | +| 21+ | NOT STARTED | Rust/Zig + Node.js integration | niche | Thought leadership | +| 22+ | NOT STARTED | TBD based on analytics | TBD | Iterate based on data | --- @@ -104,6 +114,20 @@ Event Emitter Installing Node.js └── links to → Checking Node.js version (existing) └── links to → Docker development (existing) + +Path Traversal Security (NEW) + └── links to → Reading/Writing Files (existing) + └── links to → Race Conditions (existing) + └── links to → 5 Ways to Install Node.js (existing) + └── links to → Input Validation (future) + └── links to → Secure File Uploads (future) + └── links to → OWASP Top 10 for Node.js (future) + +Security Cluster (future) + └── Path Traversal Security (hub for file security) + └── Input Validation + └── Secure File Uploads + └── OWASP Top 10 for Node.js (potential hub) ``` --- @@ -114,8 +138,11 @@ Installing Node.js - [x] Update "5 Ways to Install Node.js" with fnm, Volta, Docker - [x] Cross-link existing articles - [x] Add CTAs for free chapter download to all posts +- [x] Publish path traversal security article (new security pillar) +- [x] Add link to path traversal article from Reading/Writing Files guide - [ ] Monitor keyword rankings for existing content - [ ] A/B test CTA placements +- [ ] Promote path traversal article on security forums (r/netsec, HN) --- @@ -125,3 +152,6 @@ Installing Node.js - Most competing articles still recommend axios/got first - opportunity to differentiate - New Node.js features (22+, 23+) have early-mover advantage - Light book excerpts where relevant (Ch 6 streams, Ch 10 testing) +- **Security content** differentiates us from typical tutorial sites and builds trust/authority +- Path traversal article targets developers building file servers, image handlers, and upload systems +- Security topics have strong CVE/news hooks for promotion and backlinks diff --git a/src/content/blog/nodejs-path-traversal-security/index.md b/src/content/blog/nodejs-path-traversal-security/index.md index cacfeed..e9eff57 100644 --- a/src/content/blog/nodejs-path-traversal-security/index.md +++ b/src/content/blog/nodejs-path-traversal-security/index.md @@ -15,6 +15,10 @@ faq: answer: No, path.join() does not prevent path traversal. It simply concatenates paths without security validation. An input like "../../etc/passwd" will be joined as-is, allowing directory escape. Always validate paths after joining. - question: How do I secure file uploads in Node.js? answer: Secure file uploads by (1) validating filenames with strict patterns, (2) storing files with generated names rather than user-provided ones, (3) serving files through a validated path resolution function, and (4) using file handles to minimize TOCTOU race conditions. + - question: Does Express.js protect against path traversal? + answer: Express.js provides some built-in protections through res.sendFile(), but you should never rely solely on framework behavior. Always validate paths yourself using techniques like path.resolve(), fs.realpath(), and boundary checking with startsWith() before passing them to any file-serving function. + - question: How do I test for path traversal vulnerabilities? + answer: Test with attack payloads including basic traversal (../), URL encoding (%2e%2e%2f), double encoding (%252e%252e%252f), null bytes (%00), and absolute paths. Use automated tools like OWASP ZAP or Burp Suite, and write unit tests that verify your validation rejects all these patterns. --- Building on our extensive [Node.js File Operations Guide](/blog/reading-writing-files-nodejs/), let's explore one of the most critical security vulnerabilities related to handling files and paths in web applications: **path traversal attacks**. @@ -210,7 +214,7 @@ The attack described above allows an attacker to read `/etc/passwd`, which on Un - Reading private SSH keys from `~/.ssh/id_rsa` - Examining application source code to discover additional vulnerabilities - Reading configuration files to understand the system architecture -::: + ::: ## The Attack: Common Exploitation Techniques @@ -601,42 +605,42 @@ describe('safeResolve', () => { it('should block basic traversal', async () => { await assert.rejects( safeResolve(root, '../../etc/passwd'), - /Path traversal detected/ + /Path traversal detected/, ) }) it('should reject absolute paths', async () => { await assert.rejects( safeResolve(root, '/etc/passwd'), - /Absolute paths not allowed/ + /Absolute paths not allowed/, ) }) it('should block URL-encoded traversal', async () => { await assert.rejects( safeResolve(root, '..%2F..%2Fetc%2Fpasswd'), - /Path traversal detected/ + /Path traversal detected/, ) }) it('should block double-encoded traversal', async () => { await assert.rejects( safeResolve(root, '..%252F..%252Fetc%252Fpasswd'), - /Path traversal detected/ + /Path traversal detected/, ) }) it('should reject null bytes', async () => { await assert.rejects( safeResolve(root, 'valid.jpg\0../../etc/passwd'), - /Null bytes not allowed/ + /Null bytes not allowed/, ) }) it('should reject UNC paths', async () => { await assert.rejects( safeResolve(root, '//server/share/sensitive.txt'), - /UNC paths not allowed|Absolute paths not allowed/ + /UNC paths not allowed|Absolute paths not allowed/, ) }) }) diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 36bf800..9f34c06 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -155,6 +155,18 @@ export type AnalyticsEventName = // Debug Mode // ============================================================================ +/** + * Check if running on localhost (development environment) + */ +export function isLocalhost(): boolean { + if (typeof window === 'undefined') return false + + return ( + window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1' + ) +} + /** * Check if analytics debug mode is enabled. * Enable via URL param: ?debug_analytics=true @@ -165,11 +177,8 @@ export function isDebugMode(): boolean { const urlParams = new URLSearchParams(window.location.search) const debugParam = urlParams.get('debug_analytics') === 'true' - const isDev = - window.location.hostname === 'localhost' || - window.location.hostname === '127.0.0.1' - return debugParam || isDev + return debugParam || isLocalhost() } /** @@ -206,9 +215,19 @@ function getGtag(): ((...args: unknown[]) => void) | null { return window.gtag ?? null } +/** + * Check if debug analytics param is enabled via URL + */ +function hasDebugParam(): boolean { + if (typeof window === 'undefined') return false + const urlParams = new URLSearchParams(window.location.search) + return urlParams.get('debug_analytics') === 'true' +} + /** * Generic event tracking function * Uses the global gtag() function directly for event tracking. + * Events are not sent when running on localhost (unless ?debug_analytics=true is set). */ export function trackEvent>( eventName: string, @@ -216,6 +235,11 @@ export function trackEvent>( ): void { debugLog(eventName, params) + // Skip sending events on localhost unless debug param is set + if (isLocalhost() && !hasDebugParam()) { + return + } + const gtag = getGtag() if (gtag) { gtag('event', eventName, params)