GUIDE · OWASP Top 10:2025

A10:2025 Mishandling of Exceptional Conditions

Error and edge paths are where security controls silently stop applying — the system fails open, leaks, hangs, or corrupts state.

lesson6 sectionsruns in browser2021: — NEW category24 asserts159 lines

At a glance

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."

The idea#

Picture a vending machine whose coin reader jams. A sensible machine stops and lights the lamp that says to try again. If the spiral sticks halfway, it lets go of the bag. Tomorrow the machine still has snacks and a working reader.

That careful stop is how you handle exceptional conditions. Mishandling of Exceptional Conditions is the other ending: an error, a timeout, or a missing value turns a control off. The system fails open, tells the visitor too much, hangs, or leaves a mess. Design the failure with the same care as the success. If the policy lookup throws, or the value is the wrong shape, deny. One handler chooses the words a client may see, and the log keeps the detail with a request id. Return anything you borrowed inside finally. Time out calls to other machines. Retry a few times, with backoff and jitter, and only for errors you marked retryable. Log an unhandled rejection and shut the process down.

Failure looks like the machine emptying the rack because it got confused. The catch returns allowed when policy is down, so an outage makes every caller an admin. An empty catch hides the error and continues. The client receives a stack or a filesystem path. A connection returns to the pool only on success, so errors drain it. A fetch with no deadline waits forever. A retry with no ceiling hammers the next service. A missing permission list becomes allow.

Why it matters#

A bug on the happy path annoys one person. A bug on the exceptional path changes a security decision, draws a map for the client, or takes the service down. The 2025 name is Mishandling of Exceptional Conditions, and the incidents live in the catch.

Fail-open turns a broken check into a yes. A leaky error teaches hostnames and queries to anyone who can trigger it. A connection that never returns becomes an outage once the pool hits zero. A retry with no ceiling supplies the traffic that knocks the next service over.

Put the deny in the catch, and check that the user and the action are present. Build one response: a status, a safe sentence, a request id. Log the stack on the server. Release every borrow in finally. Abort a stalled call. Retry a handful of times with backoff and jitter, and skip the retry when the error is the caller's fault. Leave the runtime's crash on an unhandled rejection in place.

A small example#

JS
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;    return perms.includes(action);  } catch {    return false;  }} async function demo() {  const allowed = await authorize({ id: 1 }, 'read', async () => ['read']);  const denied = await authorize({ id: 1 }, 'read', async () => {    throw new Error('policy down');  });  return { allowed, denied };} demo();

When the lookup returns ['read'], the answer is true. When the lookup throws, the catch answers false. A missing user, a bad action, or a policy value that is not a list takes the same deny.

What the code below is doing#

Six sections, then a driver that runs them.

Fail closed is authorize. A missing user, a non-string action, a thrown lookup, or a value that is not an array returns false. A real list is tested with includes. The driver expects true only for a granted read.

Safe responses are AppError and toResponse. An AppError may show its own message. Every other error becomes status 500 and "Internal Server Error." The logger keeps the request id and the stack. A connection address stays in the log and stays out of the JSON. A 404 returns "Order not found."

Cleanup is withConn. It acquires, runs your function, and releases in finally. After a success and a throw, pool.inUse is 0.

Timeouts are withTimeout. An AbortController rejects when the timer fires, and finally clears the timer. A fast promise resolves. A promise that never settles rejects.

Retries are withRetry. The loop stops at attempts, and sooner when isRetryable says no. The pause grows, with jitter, under a cap. The driver shows a flaky call that succeeds on the third try, a failure that stops at the cap, and a client error that runs once.

The process note is for the entrypoint: log an unhandled rejection or uncaught exception and shut down. Keep the crash-on-unhandled-rejection behavior. Set request and header timeouts. Put a circuit breaker in front of a dependency that is already failing.

When you talk about it, say this: on the error path, deny, clean up, cap the retries, time out, and tell the client nothing useful.

Everything below is the demonstration. Same idea, written as code you can run.

JSowasp/a10-mishandling-exceptional-conditions.js · setup
'use strict';const assert = require('node:assert/strict');
01

Fail open vs fail closed#

JS
// 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  }}
02

Centralized error handler: safe message out, detail to logs#

JS
// 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));const r2 = toResponse(new AppError(404, 'Order not found', dbErr), 'req-2', () => {});
Verified by 4 asserts
JS
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 itassert.deepEqual(r2.body, { error: 'Order not found', requestId: 'req-2' });
03

Cleanup with finally (resource exhaustion on error paths)#

JS
// 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); }}
04

Timeouts via AbortController#

JS
// 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));}
05

Bounded retries with exponential backoff + jitter, only for retryable errors#

JS
// 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;}
06

Process-level: never ignore unhandled rejections (register once in the entrypoint)#

JS
//   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   // finally cleanup  await withConn(async () => 'ok');   // timeout  let sawAbort = false;   // retry  let calls = 0;  const flaky = async () => { calls++; if (calls < 3) throw Object.assign(new Error('503'), { retryable: true }); return 'done'; };  const noSleep = async () => {};  calls = 0;  calls = 0;  const sleeps = [];   console.log('ok: owasp/a10-mishandling-exceptional-conditions.js');})();
Verified by 20 asserts
JS
  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);  await assert.rejects(withConn(async () => { throw new Error('query failed'); }), /query failed/);  assert.equal(pool.inUse, 0);                                   // no leaked connections on the error path  assert.equal(await withTimeout(() => Promise.resolve('fast'), 15), 'fast');  await assert.rejects(withTimeout(() => new Promise(() => {}), 5), /timeout after 5ms/);   // never-settling promise  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  assert.equal(await withRetry(flaky, { attempts: 3, sleep: noSleep }), 'done');  assert.equal(calls, 3);  await assert.rejects(withRetry(async () => { calls++; throw new Error('always'); }, { attempts: 4, sleep: noSleep }), /always/);  assert.equal(calls, 4);                                        // bounded, not infinite  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  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

See also