Skip to content

Instantly share code, notes, and snippets.

@luan0ap
Last active March 4, 2026 17:14
Show Gist options
  • Select an option

  • Save luan0ap/89b1f40ad3d05e0325417ecf2ab5fa9b to your computer and use it in GitHub Desktop.

Select an option

Save luan0ap/89b1f40ad3d05e0325417ecf2ab5fa9b to your computer and use it in GitHub Desktop.
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
}
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