// ───────────────────────────────────────────────────────────────────────────── // A10:2025 MISHANDLING OF EXCEPTIONAL CONDITIONS (2021: — NEW category) // ───────────────────────────────────────────────────────────────────────────── // WHAT : Error and edge paths are where security controls silently stop // applying — the system fails open, leaks, hangs, or corrupts state. // HAPPENS : fail-open on error (catch returns 'allowed' / skips the check); // unhandled promise rejections crash the process or leave half-done // state; swallowed errors (empty catch); internals leaked in error // messages (stack, SQL, paths); resource leaks on error paths (no // finally => fd/connection/lock exhaustion); improper null/undefined // handling (optional chaining that hides a missing policy); race // conditions / TOCTOU; infinite retries hammering a dependency; // missing timeouts (slowloris, hung upstream => thread starvation). // SPOT : catch (e) {} ; catch (e) { return true } ; `|| defaultAllow`; // await without try in handlers; no `finally` near acquire(); // while(true) retry; fetch() without AbortSignal; res.send(err); // `?.` on a security decision; no process-level rejection handler. // FIX : fail closed (deny on any error); centralized error handler that // maps to safe messages + logs full detail with request id; finally // for cleanup; bounded retries with exponential backoff + jitter and // only on retryable errors; timeouts everywhere (AbortController, // server.requestTimeout); circuit breaker; validate presence of // required inputs explicitly; process.on('unhandledRejection') -> // log + graceful shutdown (not ignore). // SAY : CWE-636 Not Failing Securely (Failing Open), CWE-755 Improper // Handling of Exceptional Conditions, CWE-390 Detection of Error // Condition Without Action, CWE-209 Error Message Info Exposure, // CWE-400 Uncontrolled Resource Consumption, CWE-772 Missing Release // of Resource, CWE-476 NULL Pointer Dereference. Pitch: "Design the // error path like it's an attack path: deny, clean up, bound, time // out, and tell the client nothing useful." // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); // ── 1. Fail open vs fail closed // VULNERABLE: // async function authorize(user, action) { // try { return (await policy.lookup(user)).includes(action); } // catch (e) { console.warn('policy unavailable'); return true; } // outage => everyone is admin // } // const can = user?.perms?.includes(action) ?? true; // missing perms => allowed // FIXED: async function authorize(user, action, lookup) { try { if (!user || typeof action !== 'string') return false; const perms = await lookup(user); if (!Array.isArray(perms)) return false; // malformed policy => deny return perms.includes(action); } catch { return false; // any error => deny } } // ── 2. Centralized error handler: safe message out, detail to logs // VULNERABLE: res.status(500).json({ error: err.message, stack: err.stack }); // FIXED: class AppError extends Error { constructor(status, publicMessage, cause) { super(publicMessage, { cause }); this.status = status; this.expose = true; } } function toResponse(err, requestId, logger) { const status = err instanceof AppError ? err.status : 500; const message = err instanceof AppError && err.expose ? err.message : 'Internal Server Error'; logger({ requestId, status, name: err?.name, message: err?.message, stack: err?.stack }); // full detail stays server-side return { status, body: { error: message, requestId } }; } const logged = []; const dbErr = new Error('connect ECONNREFUSED 10.0.0.7:5432 /srv/app/db.js:42'); const r1 = toResponse(dbErr, 'req-1', (e) => logged.push(e)); assert.deepEqual(r1, { status: 500, body: { error: 'Internal Server Error', requestId: 'req-1' } }); assert.equal(JSON.stringify(r1).includes('10.0.0.7'), false); assert.match(logged[0].message, /ECONNREFUSED/); // but ops can still see it const r2 = toResponse(new AppError(404, 'Order not found', dbErr), 'req-2', () => {}); assert.deepEqual(r2.body, { error: 'Order not found', requestId: 'req-2' }); // ── 3. Cleanup with finally (resource exhaustion on error paths) // VULNERABLE: // const conn = await pool.acquire(); const rows = await conn.query(sql); pool.release(conn); // throw => leak // FIXED: const pool = { inUse: 0, acquire() { this.inUse++; return { id: this.inUse }; }, release() { this.inUse--; } }; async function withConn(fn) { const conn = pool.acquire(); try { return await fn(conn); } finally { pool.release(conn); } } // ── 4. Timeouts via AbortController // VULNERABLE: const r = await fetch(upstream); // hangs forever if upstream stalls; threads/sockets pile up // FIXED: function withTimeout(promiseFactory, ms) { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(new Error(`timeout after ${ms}ms`)), ms); return new Promise((resolve, reject) => { ac.signal.addEventListener('abort', () => reject(ac.signal.reason), { once: true }); Promise.resolve().then(() => promiseFactory(ac.signal)).then(resolve, reject); }).finally(() => clearTimeout(timer)); } // ── 5. Bounded retries with exponential backoff + jitter, only for retryable errors // VULNERABLE: while (true) { try { return await call(); } catch {} } // DDoS your own dependency; never surfaces // FIXED: async function withRetry(fn, { attempts = 3, baseMs = 1, maxMs = 4, isRetryable = () => true, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), rand = Math.random } = {}) { let lastErr; for (let i = 0; i < attempts; i++) { try { return await fn(i); } catch (e) { lastErr = e; if (i === attempts - 1 || !isRetryable(e)) break; const backoff = Math.min(maxMs, baseMs * 2 ** i); await sleep(backoff / 2 + rand() * (backoff / 2)); // "equal jitter" } } throw lastErr; } // ── 6. Process-level: never ignore unhandled rejections (register once in the entrypoint) // process.on('unhandledRejection', (reason) => { logger.fatal({ reason }); shutdown(1); }); // process.on('uncaughtException', (err) => { logger.fatal({ err }); shutdown(1); }); // Node >= 15 crashes on unhandled rejection by default — keep that; don't set --unhandled-rejections=warn in prod. // server.requestTimeout / headersTimeout set; circuit breaker around flaky upstreams. (async () => { // fail closed assert.equal(await authorize({ id: 1 }, 'read', async () => ['read']), true); assert.equal(await authorize({ id: 1 }, 'write', async () => ['read']), false); assert.equal(await authorize({ id: 1 }, 'read', async () => { throw new Error('policy db down'); }), false); assert.equal(await authorize({ id: 1 }, 'read', async () => Promise.reject(new Error('timeout'))), false); assert.equal(await authorize({ id: 1 }, 'read', async () => undefined), false); // malformed => deny assert.equal(await authorize(null, 'read', async () => ['read']), false); // finally cleanup await withConn(async () => 'ok'); await assert.rejects(withConn(async () => { throw new Error('query failed'); }), /query failed/); assert.equal(pool.inUse, 0); // no leaked connections on the error path // timeout assert.equal(await withTimeout(() => Promise.resolve('fast'), 15), 'fast'); await assert.rejects(withTimeout(() => new Promise(() => {}), 5), /timeout after 5ms/); // never-settling promise let sawAbort = false; await assert.rejects(withTimeout((signal) => new Promise((_, rej) => signal.addEventListener('abort', () => { sawAbort = true; rej(signal.reason); })), 5)); assert.equal(sawAbort, true); // signal propagated to the work // retry let calls = 0; const flaky = async () => { calls++; if (calls < 3) throw Object.assign(new Error('503'), { retryable: true }); return 'done'; }; const noSleep = async () => {}; assert.equal(await withRetry(flaky, { attempts: 3, sleep: noSleep }), 'done'); assert.equal(calls, 3); calls = 0; await assert.rejects(withRetry(async () => { calls++; throw new Error('always'); }, { attempts: 4, sleep: noSleep }), /always/); assert.equal(calls, 4); // bounded, not infinite calls = 0; await assert.rejects(withRetry(async () => { calls++; throw new Error('400 bad request'); }, { attempts: 5, sleep: noSleep, isRetryable: (e) => !/^4/.test(e.message) }), /400/); assert.equal(calls, 1); // non-retryable => no retry const sleeps = []; await assert.rejects(withRetry(() => Promise.reject(new Error('x')), { attempts: 4, baseMs: 100, maxMs: 1000, sleep: async (ms) => sleeps.push(ms), rand: () => 0.5 })); assert.deepEqual(sleeps, [75, 150, 300]); // exponential, jittered, capped console.log('ok: owasp/a10-mishandling-exceptional-conditions.js'); })();