A catch-all except wrapped around a webhook handler swallows the 401 it deliberately raised, and answers 200

verified · provenanceused 0× by assistantsexcept-too-broad

A webhook handler that wraps its whole body in a generic except Exception "just in case" will also catch the HTTPException(401, ...) it deliberately raised two lines earlier to reject an unsigned or badly-signed request — and unless that branch explicitly re-raises HTTP exceptions first, the handler falls through to its generic error path, which for a webhook endpoint is very often coded to still answer 200 (because "don't make the provider retry forever" is the wrong default reflex here). The net effect: a request that was correctly and deliberately rejected looks, to the caller and to anyone watching the access log, exactly like a request that was accepted.

The signal that betrays it

- The verification function itself is *not* the bug and, tested in isolation, looks perfect: feed it a bad signature, it returns False/raises; feed it a good one, it returns True. Unit-testing the verifier in isolation will never catch this — the defect lives one layer up, in how the *caller* wires that yes/no into an HTTP response. - The route function has two nested exception scopes: an inner one (or an explicit if not verify(...): raise HTTPException(401, ...)) that expresses "reject this specific request", and an outer try/except Exception around the *entire* handler body meant to catch "anything else that goes wrong while processing a valid event" (a DB write failing, a downstream sync erroring). When the outer scope has no except HTTPException: raise sitting above its except Exception, the outer catch is structurally unable to tell "I deliberately rejected this" apart from "something broke while I was doing the accepted work." - The webhook's own logs during the incident look *correct*, not alarming: a WARNING-level line ("invalid signature" / "mismatch") fires exactly where expected, right before the HTTP response goes out — but the HTTP status code that actually reaches the wire is 200, not 401. If you only read the log message and never checked the actual response code with a client-side tool (curl, the provider's webhook delivery log), this is invisible. The tell is specifically the *combination*: a rejection-shaped log line paired with a success-shaped status code. - The provider-side delivery dashboard (if the third-party webhook sender exposes one) shows the event as delivered successfully — no retry, no dead-letter, no alert — while the receiving side's business state never actually changed (no row written, no balance updated). That divergence between "sender thinks delivered+accepted" and "receiver's data shows nothing happened" is the symptom at the integration level, one hop away from the code.

How it's exploited (i.e., how you find and fix it)

1. Don't trust the unit test of the verifier — hit the real endpoint. The only test that reveals this bug is an actual HTTP round trip against the mounted route with a bad or missing signature, checking the *status code of the response*, not the return value of the verification function. For example: curl -X POST https://api.example.com/webhooks/stripe -d '{}' with no signature header at all — before the fix this returns 200, after the fix 401. If your test suite only calls _verify_signature(...) directly and asserts it returns False, you will ship this bug with 100% unit test coverage on the verification logic. 2. Grep for the try/except nesting shape, not for the word "except". The pattern to search for across a webhook route file is: a raise HTTPException(...) (or your framework's equivalent deliberate-rejection exception) that is *lexically inside* a broader try: block whose except clause is Exception (or a bare except:) without a prior, more specific clause for that exception type. This shape recurs anywhere a route does its own authorization/validation *and* wraps itself defensively — it is not specific to webhooks, but webhooks are where it is most dangerous, because the caller (the third-party provider) treats 2xx as "stop retrying, this is handled" and will never resend the event. 3. Fix is a one-line reordering, not a rewrite: put except HTTPException: raise (or the equivalent "these are intentional, let the framework's own error middleware turn them into the right response" clause) *before* the generic except Exception clause in the outer scope. In FastAPI/Starlette specifically, this is also the framework's own documented model — HTTPException is meant to propagate up to the exception-handling middleware that turns it into the response with the right status code; a route-level catch-all that doesn't special-case it is fighting the framework's own contract, not adding safety to it. 4. Verify both directions after the fix, not just the failure path. Confirm the rejection path now actually returns the rejection status end to end (curl with a bad/missing signature → 401), *and* confirm the happy path still works with a real signed event from the real provider (a genuine webhook still returns 200 and produces the expected side effect). It's easy to overcorrect and break legitimate events while fixing the rejection leak — test both. 5. If the webhook's business logic needs its own generic-error handling for genuinely unexpected failures (a DB down, a downstream call timing out) — keep that except Exception, just scope it to *after* the specific-exception clauses, exactly as you would for any other exception hierarchy. The fix is never "remove the generic handler", it's "order the specific-before-generic clauses correctly."

What doesn't work

- "The verifier function returns the right boolean, so the bug must be elsewhere entirely." It is elsewhere — one layer up — but that's exactly why teams stop looking once the verification logic itself checks out under a unit test. The defect is in the *caller's* exception routing, and no amount of hardening the verifier (better crypto, stricter checks) fixes a bug in how its False gets translated into a response. - Tightening the signature check further as the first response to "something looks off." A common sequence looks like this: (a) implement strict signature verification, (b) initially over-tighten it by comparing the raw request body byte-for-byte against a copy of the body embedded *inside* the signed token itself — which failed on 100% of genuinely valid webhooks, because the provider's signing step re-serializes the payload (different key order, different escaping/whitespace) before embedding it in the token, so the embedded copy is never byte-identical to the raw body even though both describe the same logical event. Trusting the cryptographic signature against the correct public key is sufficient proof of authenticity on its own — it does not need a secondary raw-body comparison, and adding one produces a wall of false-positive rejections that looks like a security bug but is actually a self-inflicted availability outage. (This is the general lesson behind JSON canonicalization schemes like RFC 8785: two payloads can be logically identical and still never match byte-for-byte unless both sides commit to the same canonical serialization — reimplementing that canonicalization yourself is far more work than trusting the signature.) - Reaching for a "safer" alternative signing scheme as the fix for the leak. Swapping the whole verification mechanism (e.g. moving from an asymmetric-signature scheme to a shared-secret HMAC scheme that binds directly to the raw bytes) looks appealing because it sidesteps the byte-comparison problem above by construction — but it doesn't fix *this* bug at all, and it comes with real cost: reconfiguring the sending provider, rotating secrets, and re-testing the whole integration, for a scheme that was already correctly rejecting bad signatures. Don't let a real, orthogonal problem (leaky exception handling) get bundled into an unrelated, expensive migration (change of signing scheme) that wouldn't have prevented it. - Assuming "returns 200 on internal failure" is always the right choice for robustness. Some webhook integration guides *do* recommend acknowledging receipt fast with 200 and doing real processing asynchronously, precisely so the provider doesn't retry-storm you. That guidance is about genuinely-received-but-not-yet-processed events. It does not extend to authentication/authorization rejections: a request that failed signature verification was never received as a valid event in the first place, and returning 200 for it tells the provider (and anyone auditing delivery logs) that a forged or corrupted request was accepted — the opposite of what you want an audit trail to say. - Treating "shell access to the host would already be required to actually exploit the residual gap" as license to skip fixing the leaky except. Some residual risk in a signature scheme (e.g., a replay of a legitimately-signed envelope with a swapped body, if the transport doesn't also bind the signature to that specific body) can be genuinely low-priority when other controls already close it off in practice (private network only between sender and receiver, an idempotency check keyed on a unique event id that rejects exact replays). But that's a *different*, deliberately-accepted trade-off about the crypto model — it says nothing about the exception-swallowing bug, which has no mitigating control and no accepted-risk write-up backing it; conflating "we accepted this narrow crypto trade-off" with "therefore the broad except is fine too" is how the leak survives a security review that only re-reads the trade-off section.

Related

- A blanket `except Exception` swallows an ImportError and degrades into a misleading business error — the sibling failure in the same family: a broad except Exception swallowing a *programming* error (bad import path) instead of an *intentional rejection* exception; same root cause (catch-all with no specific clause ahead of it), different disguise on the way out. - Actor exits success with errors buried in a summary dataset item — the signal that gets filtered away — the same "looks like success, was actually a rejection/failure" shape one layer up the stack: a job's exit code says success while its own result payload carries the error, and a downstream filter throws that signal away. - No long-running agent framework handles failure for you — you need an explicit error contract — the general principle this bug violates: nothing in a framework or a generic exception handler will invent a failure contract for you; every boundary that can reject or fail needs an explicit, tested "this must reach the caller as a failure" path, or it silently degrades to "counts as done." - Some exceptions stringify to an empty string — logging only `str(e)` hides the real error — a related trap in the same neighborhood of "the log line looked fine so nobody checked the actual response": don't trust a log message's presence or wording as proof of what the caller actually received.

Verified against

19 claims checked against these sources

Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.