Last active
March 4, 2026 17:14
-
-
Save luan0ap/89b1f40ad3d05e0325417ecf2ab5fa9b to your computer and use it in GitHub Desktop.
Safe resolve path example. By https://nodejsdesignpatterns.com/blog/nodejs-path-traversal-security
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| // 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 { | |
| // 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) { | |
| // 1. Fully decode any URL-encoded characters (handles double encoding) | |
| const decoded = fullyDecode(userPath) | |
| // 2. Reject null bytes (used to bypass extension checks) | |
| if (decoded.includes('\0')) { | |
| throw new Error('Null bytes not allowed') | |
| } | |
| // 3. Reject absolute paths immediately | |
| if (path.isAbsolute(decoded)) { | |
| throw new Error('Absolute paths not allowed') | |
| } | |
| // 4. Reject Windows drive letters (e.g., C:, D:) | |
| if (/^[a-zA-Z]:/.test(decoded)) { | |
| throw new Error('Drive letters not allowed') | |
| } | |
| // 5. Reject UNC paths (e.g., \\server\share or //server/share) | |
| if (decoded.startsWith('\\\\') || decoded.startsWith('//')) { | |
| throw new Error('UNC paths not allowed') | |
| } | |
| // 6. Resolve to canonical path | |
| const safePath = path.resolve(root, decoded) | |
| // 7. Follow symlinks to get the real path | |
| const realPath = await fs.realpath(safePath) | |
| // 8. Verify the path stays within root | |
| if (!realPath.startsWith(root + path.sep)) { | |
| throw new Error('Path traversal detected') | |
| } | |
| return realPath | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { createServer } from 'node:http' | |
| import { createReadStream } from 'node:fs' | |
| import { realpath } from 'node:fs/promises' | |
| import path from 'node:path' | |
| import { safeResolve } from './safe-resolve.js' | |
| // 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) => { | |
| const url = new URL(req.url, `http://${req.headers.host}`) | |
| const rel = url.pathname.replace(/^\/images\//, '') | |
| // 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 | |
| } | |
| // 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('Secure image server running at http://localhost:3000') | |
| }) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment