|
| 1 | +/** |
| 2 | + * Chapter 6: Security Best Practices |
| 3 | + * Common Node.js security pitfalls and mitigations |
| 4 | + */ |
| 5 | + |
| 6 | +import { join, resolve, normalize } from 'node:path' |
| 7 | +import { readFile } from 'node:fs/promises' |
| 8 | +import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto' |
| 9 | +import { promisify } from 'node:util' |
| 10 | + |
| 11 | +const scryptAsync = promisify(scrypt) |
| 12 | + |
| 13 | +// ─── 1. Path Traversal Prevention ──────────────────────────────────────────── |
| 14 | +const SAFE_BASE_DIR = '/var/www/public' |
| 15 | + |
| 16 | +/** |
| 17 | + * Safely resolve a user-provided path within a base directory. |
| 18 | + * Prevents path traversal attacks like ../../etc/passwd |
| 19 | + */ |
| 20 | +export function safeResolvePath(userInput: string, baseDir = SAFE_BASE_DIR): string { |
| 21 | + const normalized = normalize(userInput).replace(/^(\.\.(\/|\\|$))+/, '') |
| 22 | + const resolved = resolve(join(baseDir, normalized)) |
| 23 | + |
| 24 | + if (!resolved.startsWith(resolve(baseDir))) { |
| 25 | + throw new Error(`Path traversal detected: ${userInput}`) |
| 26 | + } |
| 27 | + |
| 28 | + return resolved |
| 29 | +} |
| 30 | + |
| 31 | +// ─── 2. Prototype Pollution Prevention ─────────────────────────────────────── |
| 32 | +/** |
| 33 | + * Safely merge objects without allowing prototype pollution. |
| 34 | + * Blocks keys like __proto__, constructor, prototype |
| 35 | + */ |
| 36 | +export function safeMerge<T extends object>(target: T, source: Record<string, unknown>): T { |
| 37 | + const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']) |
| 38 | + |
| 39 | + for (const [key, value] of Object.entries(source)) { |
| 40 | + if (BLOCKED_KEYS.has(key)) { |
| 41 | + console.warn(`[Security] Blocked prototype pollution attempt: key="${key}"`) |
| 42 | + continue |
| 43 | + } |
| 44 | + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { |
| 45 | + const existing = (target as Record<string, unknown>)[key] |
| 46 | + if (existing && typeof existing === 'object') { |
| 47 | + safeMerge(existing as object, value as Record<string, unknown>) |
| 48 | + } else { |
| 49 | + (target as Record<string, unknown>)[key] = safeMerge({}, value as Record<string, unknown>) |
| 50 | + } |
| 51 | + } else { |
| 52 | + (target as Record<string, unknown>)[key] = value |
| 53 | + } |
| 54 | + } |
| 55 | + return target |
| 56 | +} |
| 57 | + |
| 58 | +// ─── 3. Secure Password Hashing ────────────────────────────────────────────── |
| 59 | +const SALT_LENGTH = 32 |
| 60 | +const KEY_LENGTH = 64 |
| 61 | + |
| 62 | +export async function hashPassword(password: string): Promise<string> { |
| 63 | + const salt = randomBytes(SALT_LENGTH).toString('hex') |
| 64 | + const derivedKey = await scryptAsync(password, salt, KEY_LENGTH) as Buffer |
| 65 | + return `${salt}:${derivedKey.toString('hex')}` |
| 66 | +} |
| 67 | + |
| 68 | +export async function verifyPassword(password: string, hash: string): Promise<boolean> { |
| 69 | + const [salt, storedKey] = hash.split(':') |
| 70 | + const derivedKey = await scryptAsync(password, salt, KEY_LENGTH) as Buffer |
| 71 | + const storedKeyBuffer = Buffer.from(storedKey, 'hex') |
| 72 | + // Use timing-safe comparison to prevent timing attacks |
| 73 | + return timingSafeEqual(derivedKey, storedKeyBuffer) |
| 74 | +} |
| 75 | + |
| 76 | +// ─── 4. Input Sanitization ──────────────────────────────────────────────────── |
| 77 | +/** |
| 78 | + * Sanitize user input to prevent XSS when rendering HTML |
| 79 | + */ |
| 80 | +export function escapeHtml(input: string): string { |
| 81 | + return input |
| 82 | + .replace(/&/g, '&') |
| 83 | + .replace(/</g, '<') |
| 84 | + .replace(/>/g, '>') |
| 85 | + .replace(/"/g, '"') |
| 86 | + .replace(/'/g, ''') |
| 87 | +} |
| 88 | + |
| 89 | +/** |
| 90 | + * Validate that a string is a safe SQL identifier (no injection) |
| 91 | + */ |
| 92 | +export function isSafeIdentifier(name: string): boolean { |
| 93 | + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name) |
| 94 | +} |
| 95 | + |
| 96 | +// ─── Demo ───────────────────────────────────────────────────────────────────── |
| 97 | +async function main() { |
| 98 | + console.log('=== Node.js Security Best Practices Demo ===\n') |
| 99 | + |
| 100 | + // Path traversal |
| 101 | + try { |
| 102 | + safeResolvePath('../../etc/passwd') |
| 103 | + } catch (e) { |
| 104 | + console.log(`✅ Path traversal blocked: ${(e as Error).message}`) |
| 105 | + } |
| 106 | + |
| 107 | + // Prototype pollution |
| 108 | + const obj = { a: 1 } |
| 109 | + safeMerge(obj, { '__proto__': { polluted: true }, b: 2 }) |
| 110 | + console.log(`✅ Prototype pollution blocked. obj.b = ${(obj as Record<string, unknown>).b}`) |
| 111 | + console.log(`✅ ({} as any).polluted = ${({} as Record<string, unknown>).polluted}`) |
| 112 | + |
| 113 | + // Password hashing |
| 114 | + const hash = await hashPassword('my-secret-password') |
| 115 | + const valid = await verifyPassword('my-secret-password', hash) |
| 116 | + const invalid = await verifyPassword('wrong-password', hash) |
| 117 | + console.log(`\n✅ Password hash: ${hash.slice(0, 30)}...`) |
| 118 | + console.log(`✅ Correct password: ${valid}`) |
| 119 | + console.log(`✅ Wrong password: ${invalid}`) |
| 120 | + |
| 121 | + // HTML escaping |
| 122 | + const userInput = '<script>alert("xss")</script>' |
| 123 | + console.log(`\n✅ Escaped HTML: ${escapeHtml(userInput)}`) |
| 124 | +} |
| 125 | + |
| 126 | +import { fileURLToPath } from 'node:url' |
| 127 | +if (process.argv[1] === fileURLToPath(import.meta.url)) { |
| 128 | + main().catch(console.error) |
| 129 | +} |
0 commit comments