Back

Mishandling of Exceptional Conditions - Examples

Each pair below shows a vulnerable implementation and the secure version in the same language. The themes are the ones that dominate real error-path findings: failing open, leaking internals, swallowing exceptions, building oracles, and leaking resources.

Java — Fail-Open vs. Fail-Closed Authorization

Vulnerable

@GetMapping("/admin/report")
public ResponseEntity<Report> adminReport(Principal principal) {
    boolean isAdmin;
    try {
        isAdmin = roleService.hasRole(principal.getName(), "ADMIN");
    } catch (Exception e) {
        // "Be resilient if the role service is down."
        isAdmin = true;               // FAIL-OPEN: an error grants admin
    }
    if (isAdmin) {
        return ResponseEntity.ok(reportService.build());
    }
    return ResponseEntity.status(403).build();
}

An attacker who can make roleService.hasRole throw — by exhausting its connection pool or feeding input that trips a downstream error — is handed the admin report. The exception is caught, but the recovery decision is backwards.

Secure

private static final Logger log = LoggerFactory.getLogger(AdminController.class);

@GetMapping("/admin/report")
public ResponseEntity<?> adminReport(Principal principal) {
    boolean isAdmin;
    try {
        isAdmin = roleService.hasRole(principal.getName(), "ADMIN");
    } catch (Exception e) {
        String id = UUID.randomUUID().toString();
        log.error("role check failed, denying by default id={}", id, e);
        // FAIL-CLOSED: cannot verify -> deny, and return a generic error
        return ResponseEntity.status(503)
            .body(Map.of("error", "Service temporarily unavailable", "errorId", id));
    }
    if (!isAdmin) {
        return ResponseEntity.status(403).build();
    }
    return ResponseEntity.ok(reportService.build());
}

The allow branch is reachable only on an explicit successful true. Any error denies, logs full detail under an ID, and returns a generic message.

Python — Verbose Errors & Leaked Resources

Vulnerable

from flask import Flask, request, jsonify

app = Flask(__name__)
app.config["DEBUG"] = True            # tracebacks rendered to the client

@app.route("/orders")
def orders():
    conn = pool.getconn()             # acquire connection
    cur = conn.cursor()
    # user-controlled input straight into a query; may raise
    cur.execute("SELECT * FROM orders WHERE id = " + request.args["id"])
    rows = cur.fetchall()
    conn.close()                      # NEVER reached if execute() raises
    return jsonify(rows)
# On error: full stack trace + SQL + DSN returned, and the connection leaks.

Secure

import logging, uuid
from flask import Flask, request, jsonify

app = Flask(__name__)
app.config["DEBUG"] = False           # no tracebacks to clients
log = logging.getLogger("app")

@app.route("/orders")
def orders():
    order_id = request.args.get("id", "")
    if not order_id.isdigit():                     # validate the edge case
        return jsonify(error="Invalid order id"), 400
    # context managers release the connection AND the cursor on every path
    with pool.connection() as conn, conn.cursor() as cur:
        cur.execute("SELECT * FROM orders WHERE id = %s", (order_id,))
        return jsonify(cur.fetchall())

@app.errorhandler(Exception)
def on_error(e):
    error_id = uuid.uuid4().hex
    log.exception("error_id=%s", error_id)         # full detail -> logs only
    return jsonify(error="Internal server error", error_id=error_id), 500

Parameterised query, validated input, guaranteed cleanup via with, generic client message, and full detail confined to server logs behind an error ID.

Node.js — Swallowed Async Errors & Enumeration Oracle

Vulnerable

app.post('/login', async (req, res) => {
    const { email, password } = req.body;
    const user = await db.findByEmail(email);
    if (!user) {
        // Distinct message + fast return = user-enumeration oracle
        return res.status(404).json({ error: 'No account with that email' });
    }
    try {
        const ok = await bcrypt.compare(password, user.hash);  // only for real users
        if (!ok) return res.status(401).json({ error: 'Wrong password' });
        return res.json({ token: issue(user) });
    } catch (e) {
        // swallowed — a bcrypt/internal error silently falls through
    }
    res.json({ token: issue(user) });   // FAIL-OPEN on the swallowed error
});

Two flaws compound: the response reveals whether an account exists (in both text and timing), and the empty catch lets a comparison error fall through to issuing a token.

Secure

const DUMMY_HASH = '$2b$12$'.padEnd(60, 'x');   // constant-work placeholder

app.post('/login', async (req, res, next) => {
    try {
        const { email, password } = req.body;
        const user = await db.findByEmail(email);
        // Always run a comparison so timing does not leak existence
        const hash = user ? user.hash : DUMMY_HASH;
        const ok = await bcrypt.compare(password, hash);
        if (!user || !ok) {
            // Identical status + body for every failure reason
            return res.status(401).json({ error: 'Invalid email or password' });
        }
        return res.json({ token: issue(user) });
    } catch (err) {
        next(err);                      // to the central handler — never swallow
    }
});

// Central handler: generic body, detail to logs
app.use((err, req, res, next) => {
    const errorId = crypto.randomUUID();
    logger.error({ errorId, err });
    res.status(500).json({ error: 'Internal server error', errorId });
});

Uniform response and timing remove the oracle; errors propagate to one handler instead of being swallowed into a fail-open path.

Go — Ignored Errors & Missing Cleanup

Vulnerable

func Transfer(db *sql.DB, from, to string, amount int) {
    tx, _ := db.Begin()                        // error ignored
    tx.Exec("UPDATE acct SET bal = bal - ? WHERE id = ?", amount, from)
    tx.Exec("UPDATE acct SET bal = bal + ? WHERE id = ?", amount, to)
    tx.Commit()                                // if step 2 failed, we still commit
    // No error checks: a mid-transaction failure leaves inconsistent balances,
    // and a failed Begin() leads to a nil-deref panic that crashes the worker.
}

Secure

func Transfer(ctx context.Context, db *sql.DB, from, to string, amount int) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin: %w", err)    // check every error
    }
    defer tx.Rollback()                        // guaranteed cleanup; no-op after Commit

    if _, err := tx.ExecContext(ctx,
        "UPDATE acct SET bal = bal - ? WHERE id = ? AND bal >= ?",
        amount, from, amount); err != nil {
        return fmt.Errorf("debit: %w", err)    // deferred Rollback runs
    }
    if _, err := tx.ExecContext(ctx,
        "UPDATE acct SET bal = bal + ? WHERE id = ?", amount, to); err != nil {
        return fmt.Errorf("credit: %w", err)   // deferred Rollback runs
    }
    return tx.Commit()                         // atomic: both steps or neither
}

Every error is checked and wrapped, defer tx.Rollback() guarantees the transaction is never left half-committed, and the operation is atomic under failure.

What Changed, and Why

WeaknessVulnerableSecure
Fail-open controlException in role check grants adminError denies by default (fail closed)
Verbose errorsDebug on; trace, SQL, DSN to clientGeneric message + error ID; detail to logs
Resource leakConnection closed only on successContext manager / defer releases on every path
Enumeration oracleDifferent body/timing for unknown userUniform response + constant-work comparison
Swallowed exceptionEmpty catch falls through to allowPropagate to one central handler
Half-committed stateCommit regardless of step failuresAtomic transaction with guaranteed rollback
Ignored errors_ discards error; later panicEvery error checked, wrapped, and returned

Next Steps