Serving Static Files - Assets Without Server Logic¶
Table of Contents¶
- The Static File Problem
- Manual Static File Server
- Caching Headers : Cache-Control , ETag , Last-Modified
- Directory Listing Risks
- Security : Path Traversal and Config File Exposure
The Static File Problem¶
Browsers need CSS , JS , images , and fonts. You could embed everything in template strings - but your server.js would balloon to 50K lines and every deploy would re-send the same bytes
Static file serving reads assets from disk and sends them with the right Content-Type and caching headers. Modern browsers cache aggressively - your job is to tell them how long
Manual Static File Server¶
const http = require('node:http')
const fs = require('node:fs')
const path = require('node:path')
const PUBLIC_DIR = path.resolve('public')
const MIME_TYPES = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.pdf': 'application/pdf',
'.txt': 'text/plain',
}
async function serveStatic(req, res) {
// resolve the requested file path
const urlPath = new URL(req.url, 'http://localhost').pathname
const filePath = path.join(PUBLIC_DIR, urlPath)
// path traversal check - resolves first
const resolved = path.resolve(filePath)
if (!resolved.startsWith(PUBLIC_DIR)) {
res.writeHead(403)
return res.end('Forbidden')
}
try {
const stat = await fs.promises.stat(resolved)
if (!stat.isFile()) {
res.writeHead(404)
return res.end('Not found')
}
const ext = path.extname(resolved) || '.html'
const contentType = MIME_TYPES[ext] || 'application/octet-stream'
res.writeHead(200, {
'Content-Type': contentType,
'Content-Length': stat.size,
'Cache-Control': 'public, max-age=3600'
})
// stream the file - avoids loading entire file into memory
const readStream = fs.createReadStream(resolved)
readStream.pipe(res)
readStream.on('error', () => {
res.writeHead(500)
res.end()
})
} catch (err) {
if (err.code === 'ENOENT') {
res.writeHead(404)
return res.end('Not found')
}
res.writeHead(500)
res.end('Server error')
}
}
// integration into a server
const server = http.createServer((req, res) => {
if (req.url.startsWith('/api/')) {
// API routes handled separately
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'api' }))
} else {
serveStatic(req, res)
}
})
server.listen(3000)
express.static in one line (if you use Express):
app.use(express.static('public', {
maxAge: '1d',
etag: true,
dotfiles: 'ignore'
}))
Caching Headers : Cache-Control , ETag , Last-Modified¶
The browser cache saves you bandwidth. These headers tell the browser how long to cache and when to revalidate
Cache-Control - the king of caching:
// aggressive caching for versioned assets
// (e.g. /js/app.v123.js)
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
// moderate caching for regular content
res.setHeader('Cache-Control', 'public, max-age=86400')
// no caching for dynamic content
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
| Directive | Meaning |
|---|---|
public | Any cache can store (CDN , browser) |
private | Only browser cache (not CDN) |
max-age=N | Cache for N seconds |
immutable | Content never changes between refreshes |
no-cache | Must revalidate with server before using cached copy |
no-store | Never cache anything |
must-revalidate | Strict revalidation required |
ETag - content-based fingerprint:
const crypto = require('node:crypto')
function generateETag(content) {
return crypto.createHash('md5').update(content).digest('hex')
}
async function serveWithETag(req, res, filePath) {
const content = await fs.promises.readFile(filePath)
const etag = generateETag(content)
// client sends If-None-Match with previous ETag
if (req.headers['if-none-match'] === etag) {
res.writeHead(304) // Not Modified - no body sent
return res.end()
}
res.setHeader('ETag', etag)
res.setHeader('Cache-Control', 'public, max-age=86400')
res.setHeader('Content-Type', MIME_TYPES[path.extname(filePath)] || 'text/plain')
res.end(content)
}
Last-Modified - timestamp-based revalidation:
async function serveWithLastModified(req, res, filePath) {
const stat = await fs.promises.stat(filePath)
const lastModified = stat.mtime.toUTCString()
if (req.headers['if-modified-since'] === lastModified) {
res.writeHead(304)
return res.end()
}
res.setHeader('Last-Modified', lastModified)
res.setHeader('Cache-Control', 'public, max-age=3600')
// serve file...
}
ETag vs Last-Modified: Use both. ETag handles byte-for-byte comparison , Last-Modified provides fallback. Most CDNs and browsers handle them correctly
Directory Listing Risks¶
NEVER serve directory listings in production:
// DANGEROUS - this lists everything
const entries = await fs.promises.readdir(resolved)
res.end(entries.map(e => `<a href="${e}">${e}</a>`).join('<br>'))
// attacker now sees the full file tree
A directory listing exposes: * File names that reveal application structure (config.js, routes/, db/) * Backup files (config.js.bak, app.old.js) * Hidden files (.env, .git/config, .npmrc) * Upload directories with user files
Always reject directory requests:
async function serveStatic(req, res) {
const filePath = path.resolve(path.join(PUBLIC_DIR, urlPath))
const stat = await fs.promises.stat(filePath)
if (stat.isDirectory()) {
res.writeHead(403)
return res.end('Directory listing denied')
}
// serve file...
}
Better - serve a default file (index.html) for directory paths:
if (stat.isDirectory()) {
const indexPath = path.join(filePath, 'index.html')
try {
await fs.promises.access(indexPath)
// redirect to /directory/
res.writeHead(302, { 'Location': ensureTrailingSlash(urlPath) + 'index.html' })
return res.end()
} catch {
res.writeHead(403)
res.end('Forbidden')
}
}
Security : Path Traversal and Config File Exposure¶
Path traversal in static servers is the classic web vulnerability:
// VULNERABLE - direct string concatenation
const filePath = '/var/www/' + req.url
// /var/www/../../../etc/passwd -> reads password file
The correct protection - resolve and verify:
const PUBLIC_DIR = path.resolve('/var/www/public')
function safePath(userPath) {
// normalize and resolve
const resolved = path.resolve(PUBLIC_DIR, '.' + userPath)
// ensure it stays inside PUBLIC_DIR
if (!resolved.startsWith(PUBLIC_DIR)) {
return null
}
return resolved
}
// usage
const requestedPath = safePath(req.url)
if (!requestedPath) {
res.writeHead(403)
return res.end('Nice try , but no')
}
Blocking sensitive files:
const BLOCKED_PATTERNS = [
/\.env$/,
/\.git\//,
/\.npmrc$/,
/\.htaccess$/,
/config\.(json|js|yml|yaml)$/,
/composer\.json$/,
/package\.json$/,
/node_modules\//,
/Dockerfile/,
/docker-compose/
]
function isBlocked(filePath) {
return BLOCKED_PATTERNS.some(pattern => pattern.test(filePath))
}
// in your server:
if (isBlocked(resolved)) {
res.writeHead(403)
return res.end('Access denied')
}
Symbolic link attacks - even if the path resolves inside PUBLIC_DIR , a symlink inside PUBLIC_DIR could point elsewhere:
async function checkSymlink(filePath) {
const realPath = await fs.promises.realpath(filePath)
return realPath.startsWith(path.resolve(PUBLIC_DIR))
}
// in your handler:
if (!(await checkSymlink(resolved))) {
res.writeHead(403)
return res.end('Symlink abuse detected')
}
dotfiles protection - files starting with . should be invisible:
function isDotFile(filePath) {
const basename = path.basename(filePath)
return basename.startsWith('.')
}
if (isDotFile(resolved)) {
res.writeHead(404) // 404 , not 403 - dont reveal existence
return res.end()
}
Complete secure static file handler:
async function secureStatic(req, res, publicDir = 'public') {
const root = path.resolve(publicDir)
const urlPath = new URL(req.url, 'http://localhost').pathname
// resolve symlink-safe path
let resolved
try {
resolved = await fs.promises.realpath(path.join(root, urlPath))
} catch {
res.writeHead(404)
return res.end()
}
// containment check
if (!resolved.startsWith(root)) {
res.writeHead(403)
return res.end()
}
// block hidden files
if (path.basename(resolved).startsWith('.')) {
res.writeHead(404)
return res.end()
}
// reject directories
const stat = await fs.promises.stat(resolved)
if (!stat.isFile()) {
res.writeHead(404)
return res.end()
}
// serve
const ext = path.extname(resolved)
res.writeHead(200, {
'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
'Content-Length': stat.size,
'Cache-Control': 'public, max-age=86400',
'Last-Modified': stat.mtime.toUTCString()
})
fs.createReadStream(resolved).pipe(res)
}
prerequisites¶
web_06_templating.md - Template rendering , includes for hybrid static/dynamic pages
next => web_08_restful.md