# Hardening Patterns Code patterns and decision aids for the `security-and-hardening` skill. The skill states each rule; this file shows one concrete implementation of it. Open the section you need when you reach that code — it is not meant to be read top to bottom. Shared checklists (threat modeling, pre-commit checks, security headers, CORS, the package-manager matrix, install-script gate, and the OWASP quick-reference tables) live in `../../../references/security-checklist.md`. ## OWASP Top 10 Prevention Patterns These are prevention patterns, not a ranking. For the 2021 ordering, see the quick-reference table in `../../../references/security-checklist.md#owasp-top-10-quick-reference`. ### Injection ```typescript // BAD: SQL injection via string concatenation const query = `SELECT * FROM users WHERE id = '${userId}'`; // GOOD: Parameterized query const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]); // GOOD: ORM with parameterized input const user = await prisma.user.findUnique({ where: { id: userId } }); ``` ### Broken Authentication ```typescript // Password hashing import { hash, compare } from 'bcrypt'; const SALT_ROUNDS = 12; const hashedPassword = await hash(plaintext, SALT_ROUNDS); const isValid = await compare(plaintext, hashedPassword); // Session management app.use(session({ secret: process.env.SESSION_SECRET, // From environment, not code resave: false, saveUninitialized: false, cookie: { httpOnly: true, // Not accessible via JavaScript secure: true, // HTTPS only sameSite: 'lax', // CSRF protection maxAge: 24 * 60 * 60 * 1000, // 24 hours }, })); ``` ### Cross-Site Scripting (XSS) ```typescript // BAD: Rendering user input as HTML element.innerHTML = userInput; // GOOD: Use framework auto-escaping (React does this by default) return
{userInput}
; // If you MUST render HTML, sanitize first import DOMPurify from 'dompurify'; const clean = DOMPurify.sanitize(userInput); ``` ### Broken Access Control ```typescript // Always check authorization, not just authentication app.patch('/api/tasks/:id', authenticate, async (req, res) => { const task = await taskService.findById(req.params.id); // Check that the authenticated user owns this resource if (task.ownerId !== req.user.id) { return res.status(403).json({ error: { code: 'FORBIDDEN', message: 'Not authorized to modify this task' } }); } // Proceed with update const updated = await taskService.update(req.params.id, req.body); return res.json(updated); }); ``` ### Security Misconfiguration ```typescript // Security headers (use helmet for Express) import helmet from 'helmet'; app.use(helmet()); // Content Security Policy app.use(helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], // Tighten if possible imgSrc: ["'self'", 'data:', 'https:'], connectSrc: ["'self'"], }, })); // CORS — restrict to known origins app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || 'http://localhost:3000', credentials: true, })); ``` ### Sensitive Data Exposure ```typescript // Never return sensitive fields in API responses function sanitizeUser(user: UserRecord): PublicUser { const { passwordHash, resetToken, ...publicFields } = user; return publicFields; } // Use environment variables for secrets const API_KEY = process.env.STRIPE_API_KEY; if (!API_KEY) throw new Error('STRIPE_API_KEY not configured'); ``` ### Server-Side Request Forgery (SSRF) Any time the server fetches a URL the user influenced — webhooks, "import from URL", image proxies, link previews — an attacker can aim it at internal services (cloud metadata, `localhost`, private IPs). ```typescript // BAD: fetch whatever the user gives you await fetch(req.body.webhookUrl); // GOOD: allowlist scheme + host, reject if ANY resolved IP is private, forbid redirects import { lookup } from 'node:dns/promises'; import ipaddr from 'ipaddr.js'; const ALLOWED_HOSTS = new Set(['hooks.example.com']); async function assertSafeUrl(raw: string): Promise { const url = new URL(raw); if (url.protocol !== 'https:') throw new Error('https only'); if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('host not allowed'); // Resolve ALL records; a single private/reserved address fails the check. const addrs = await lookup(url.hostname, { all: true }); if (addrs.some((a) => ipaddr.parse(a.address).range() !== 'unicast')) { throw new Error('private/reserved IP'); } return url; } await fetch(await assertSafeUrl(req.body.webhookUrl), { redirect: 'error' }); ``` The `range() !== 'unicast'` check covers loopback, link-local `169.254.169.254` (cloud metadata, the #1 SSRF target), private, and unique-local ranges across IPv4 and IPv6. **Caveat — this still has a TOCTOU gap.** `fetch` resolves DNS again after the check, so an attacker using a short-TTL record can rebind to an internal IP between validation and connection. For high-risk surfaces, resolve once and connect to the pinned IP, or put a filtering agent in front (`request-filtering-agent` / `ssrf-req-filter`). ## Input Validation Patterns ### Schema Validation at Boundaries ```typescript import { z } from 'zod'; const CreateTaskSchema = z.object({ title: z.string().max(200).trim().min(1), description: z.string().max(2000).optional(), priority: z.enum(['low', 'medium', 'high']).default('medium'), dueDate: z.string().datetime().optional(), }); // Validate at the route handler app.post('/api/tasks', async (req, res) => { const result = CreateTaskSchema.safeParse(req.body); if (!result.success) { return res.status(422).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: result.error.flatten(), }, }); } // result.data is now typed and validated const task = await taskService.create(result.data); return res.status(201).json(task); }); ``` ### File Upload Safety ```typescript // Restrict file types and sizes const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']; const MAX_SIZE = 5 * 1024 * 1024; // 5MB function validateUpload(file: UploadedFile) { if (!ALLOWED_TYPES.includes(file.mimetype)) { throw new ValidationError('File type not allowed'); } if (file.size > MAX_SIZE) { throw new ValidationError('File too large (max 5MB)'); } // Don't trust the file extension — check magic bytes if critical } ``` ### Destructive Operations on Derived Paths A delete, move, or overwrite is only as safe as the value that names its target. Reading that value from the kernel, a job payload, or a sibling service proves where it *arrived from*, not who *wrote* it — another process's command line is as attacker-controlled as a form field. A shape check ("absolute path, at least one directory deep") proves well-formedness and gets mistaken for authorization; that is how a cleanup routine deletes the root instead of the leaf. Before a destructive call, require all three: the resolved target sits under an **allowlisted root** (compare after resolving symlinks, never on the raw string); it is at least one level **below** that root, so a root is never itself the target; and it carries **evidence that it is yours**, read *before* the operation and before any teardown that removes it — otherwise "absent" and "not mine" are indistinguishable. On refusal, log the rejected target and stop: a cleanup that falls back to a broader default path is the failure this guards against. Worked example in `../../../references/security-checklist.md#destructive-path-operations`. Two limits, because the check reads stronger than it is. A marker inside the tree is self-attestation — anything that can write there can write the marker — so the expected owner has to come from authenticated state, and the marker needs integrity protection (restrictive ownership, or a MAC) before it counts as authorization. And resolving a path and then operating on the *name* is a check/use race wherever an untrusted process can swap an ancestor: on a shared volume, hold the target by descriptor and use no-follow, beneath-the-root operations, or make sure the hierarchy cannot change for the duration. ## Dependency Audit Triage Package-manager audits report known advisories; they do not prove a package is trustworthy or that vulnerable code is reachable. Use this decision tree: ``` The native package-manager audit reports a vulnerability ├── Severity: critical or high │ ├── Is the vulnerable code reachable in runtime, build, test, or deployment paths? │ │ ├── YES --> Fix immediately (update, patch, or replace the dependency) │ │ └── NO (confirmed unused across those paths) --> Fix soon, but not a blocker │ └── Is a fix available? │ ├── YES --> Update to the patched version │ └── NO --> Check for workarounds, consider replacing the dependency, or add to allowlist with a review date ├── Severity: moderate │ ├── Reachable in production? --> Fix in the next release cycle │ └── Dev-only? --> Fix when convenient, track in backlog └── Severity: low └── Track and fix during regular dependency updates ``` **Key questions:** - Is the vulnerable function actually called in your code path? - Is the dependency a runtime dependency or dev-only? - Is the vulnerability exploitable given your deployment context (e.g., a server-side vulnerability in a client-only app)? When you defer a fix, document the reason and set a review date. ## Rate Limiting ```typescript import rateLimit from 'express-rate-limit'; // General API rate limit app.use('/api/', rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // 100 requests per window standardHeaders: true, legacyHeaders: false, })); // Stricter limit for auth endpoints app.use('/api/auth/', rateLimit({ windowMs: 15 * 60 * 1000, max: 10, // 10 attempts per 15 minutes })); ``` **Count in a shared store once there is more than one process.** `express-rate-limit` keeps its counters in process memory by default. Behind a load balancer each instance holds its own count, so the effective limit is `max × instances`; on serverless or edge runtimes a fresh invocation starts from zero, so the auth limit above may never fire. Pass a shared `store` (Redis via `rate-limit-redis`), or use an HTTP-based limiter that works where a long-lived TCP connection does not (for example `@upstash/ratelimit`): ```typescript import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; const authLimiter = new Ratelimit({ redis: Redis.fromEnv(), // UPSTASH_REDIS_REST_URL + _TOKEN limiter: Ratelimit.slidingWindow(10, '15 m'), // 10 attempts per 15 minutes, across all instances }); const { success } = await authLimiter.limit(`login:${req.ip}`); if (!success) return res.status(429).end(); ``` ## Secrets Management ``` .env files: ├── .env.example → Committed (template with placeholder values) ├── .env → NOT committed (contains real secrets) └── .env.local → NOT committed (local overrides) .gitignore must include: .env .env.local .env.*.local *.pem *.key ``` **Always check before committing:** ```bash # Check for accidentally staged secrets git diff --cached | grep -i "password\|secret\|api_key\|token" ``` **If a secret is ever committed, rotate it.** Deleting the line or rewriting history is not enough — assume it's compromised the moment it reaches a remote. Revoke and reissue the key first, then purge it from history. ## Data Classification Classify personal data as you add fields, so export and deletion requests can find it later: | Class | Examples | Handling | |---|---|---| | **Non-personal** | Aggregates, anonymized counts | Normal handling | | **Personal (PII)** | Name, email, IP, device/user IDs | Minimize, access-control, include in export/delete | | **Sensitive** | Health, finance, location, biometrics, gov IDs, anything about minors | Extra basis to collect, stricter access, often encryption + audit logging | ## LLM Output Handling ```typescript // BAD: trusting model output as a command or as markup const sql = await llm.generate(`Write SQL for: ${userQuestion}`); await db.query(sql); // arbitrary query execution container.innerHTML = await llm.reply(userMessage); // stored XSS, via the model // GOOD: model output is data — parse defensively, then validate, then encode let intent; try { intent = CommandSchema.parse(JSON.parse(await llm.replyJson(userMessage))); } catch { throw new ValidationError('unexpected model output'); // JSON.parse or schema failed } await runAllowlistedAction(intent.action, intent.params); container.textContent = await llm.reply(userMessage); ```