DEV Community

NET_DARK_BOI
NET_DARK_BOI

Posted on AI-assisted

OWASP A10:2025 Mishandling of Exceptional Conditions: Three Failures, Tested

The OWASP Top 10:2025 added a category that most teams will recognise from their own code: A10, Mishandling of Exceptional Conditions. It covers what happens when something goes wrong: a dependency is down, a value is missing, a step in the middle of a transaction fails. The risk is not the failure itself but what the application does next.

What the category says

OWASP describes A10 as a new category covering improper error handling, logical errors and failing open. Its description says programs "fail to prevent, detect, and respond to unusual and unpredictable situations", which can lead to crashes, unexpected behavior and vulnerabilities.

The page maps 24 CWEs to it. A few of them read like code-review findings:

  • CWE-636, Not Failing Securely ('Failing Open')
  • CWE-209, Generation of Error Message Containing Sensitive Information
  • CWE-390, Detection of Error Condition Without Action
  • CWE-396, Declaration of Catch for Generic Exception
  • CWE-252, Unchecked Return Value

The data table lists a maximum incidence rate of 20.67% and an average of 2.95%, with 769,581 occurrences and 3,416 CVEs across the mapped CWEs. Its prevention guidance, paraphrased, is: handle errors where they occur, keep a global exception handler as a safety net, fail closed, and when a multi-step transaction fails partway "roll back every part of the transaction and start again".

The three examples below turn three of those sentences into code that can be run.

1. The check that fails open

Authorization usually depends on something that can fail: a policy engine, a database, a remote API. The first version catches the error, logs it, and carries on with a permissive default:

async function guardFailOpen(req) {
  let allowed = true;                                   // permissive default
  try { allowed = await permissionService.canRead(req.user, req.docId); }
  catch (e) { console.error('permission check failed', e.message); }   // logged, then ignored
  return allowed ? { status: 200 } : { status: 403 };
}
Enter fullscreen mode Exit fullscreen mode

The second version makes the decision explicit. Any error, and any answer that is not exactly true, is a denial:

async function guardFailClosed(req) {
  try {
    const answer = await permissionService.canRead(req.user, req.docId);
    return answer === true ? { status: 200 } : { status: 403 };
  } catch (e) {
    console.error('permission check failed', e.message);
    return { status: 503, body: 'temporarily unavailable' };            // not a grant
  }
}
Enter fullscreen mode Exit fullscreen mode

Run for a user who has no access to the document (bob asking for doc-1):

Policy service Fail-open guard Fail-closed guard
Up 403 403
Down 200 503

While the dependency is healthy, both versions behave the same, so a normal test suite passes. The difference only exists during the failure. The error was logged in both versions, which is the point of CWE-390: noticing an error condition without acting on it is not handling it.

2. The transfer that fails halfway

OWASP's third example scenario is an interrupted multi-step transfer: debit, credit, log. Three statements, with the third failing (here, an audit insert that violates a NOT NULL constraint):

// Version 1: three statements, no transaction
function transferNaive(db, amount, breakAudit) {
  db.prepare("UPDATE accounts SET balance = balance - ? WHERE id='A'").run(amount);
  db.prepare("UPDATE accounts SET balance = balance + ? WHERE id='B'").run(amount);
  db.prepare('INSERT INTO audit (entry) VALUES (?)').run(breakAudit ? null : `A->B ${amount}`);
}

// Version 2: one transaction; any error rolls every part back
function transferAtomic(db, amount, breakAudit) {
  db.exec('BEGIN');
  try {
    db.prepare("UPDATE accounts SET balance = balance - ? WHERE id='A'").run(amount);
    db.prepare("UPDATE accounts SET balance = balance + ? WHERE id='B'").run(amount);
    db.prepare('INSERT INTO audit (entry) VALUES (?)').run(breakAudit ? null : `A->B ${amount}`);
    db.exec('COMMIT');
  } catch (e) { db.exec('ROLLBACK'); throw e; }
}
Enter fullscreen mode Exit fullscreen mode

State before: account A has 100, B has 0, the audit table is empty. After a transfer of 40 where the audit step fails:

Version A B Audit rows Result
Naive 60 40 0 Money moved, nothing recorded
Atomic 100 0 0 Nothing changed

Both versions raise the same error. Only one of them leaves a consistent system behind. In the naive version, a retry by the caller would debit the account a second time.

3. The error message that explains too much

CWE-209 and OWASP's second scenario describe database errors that reveal system details, which an attacker can use as reconnaissance. A handler that returns the exception's stack to the client does exactly that:

function handleLeaky(req, run) { try { return { status: 200, body: run() }; } catch (e) { return { status: 500, body: e.stack }; } }

function handleSafe(req, run) {
  try { return { status: 200, body: run() }; }
  catch (e) {
    const id = randomUUID();
    console.error(JSON.stringify({ id, msg: e.message }));          // details stay on the server
    return { status: 500, body: `Something went wrong. Reference: ${id}` };
  }
}
Enter fullscreen mode Exit fullscreen mode

With a simulated database error, the leaky handler returned a four-line body that began Error: SQLITE_ERROR: no such column: users.pw_hash at /srv/app/db/orders.js:88: a table, a column name and a file path. The safe handler returned Something went wrong. Reference: e074ed12-…, and the same identifier appears in the server log, so a support request can still be traced.

What the three have in common

Each failure was handled in the sense that no exception escaped. The defect was in the decision made after the failure: a permissive default, a half-applied state, a verbose reply. That is why none of them is found by a test where everything works. They need a test where something breaks.

A test list for your own service

Each row is something an authorised tester arranges in an environment they own, with the expected result.

Arrange Expected
Make the authorization or policy dependency unreachable, then request a protected resource as a user without access Denied (or unavailable), never granted
Make the dependency return garbage (null, an empty object, a timeout) Denied
Fail the last step of a multi-step operation No partial state; a retry does not repeat completed steps
Send a request with a missing required parameter and with an extra parameter 4xx with a generic message, no stack, path or query text
Force an internal error A generic body with a reference id; the detail is only in the server log
Repeat the same failing request many times Rate limited or summarised in logs, without exhausting resources

OWASP's first scenario is the last row's concern: an upload handler that catches an exception but does not release its resources, so repeated failures exhaust them.

Practise the review

Breachloom has an exercise on the information-disclosure side, SQL error disclosure in search, with a matching chapter, A database error message is a disclosure channel of its own. For the half-applied state, there is a fix-the-code mission, Atomic Coupon, where the repair must pass a regression test, and a general article, Secure Coding Exercises: Verify the Fix, Not Just the Attack. The chapters and the free IDOR case need no account; the full set of challenges is part of a paid plan.

Which dependency of your own service, if it went down right now, would turn a check into a pass? Answering that by switching it off in a test environment is faster than reading the code for it.

Sources

Top comments (0)