fix(sdk): webhook-verify (py) + WS listener (ts) no longer crash on crafted/transient input#467
Conversation
verify_webhook_signature documents a "never raises" contract, but two
attacker-reachable inputs broke it, turning any webhook receiver's clean
401 into an unhandled 500 (nuisance DoS):
- A `t=` of non-ASCII Unicode digits (e.g. fullwidth "1750…") passes
float()/isfinite() and the replay-window check, then raises
UnicodeEncodeError at `t.encode("ascii")`.
- A `v1=` of exactly 64 non-ASCII characters clears the length gate and
raises TypeError in `hmac.compare_digest` ("comparing strings with
non-ASCII characters is not supported").
A legitimate timestamp is ASCII and a legitimate signature is lowercase
hex, so both malformed inputs can only ever be non-matching. Guard them
to return/skip to a clean False. The TS SDK already rejects both.
Adds regression tests for each vector.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WSStream re-emitted "error" unconditionally. Node's EventEmitter throws when "error" is emitted with no registered listener, and the documented usage — `for await (const e of client.listen(addr))` — registers async-iterator waiters, not an EventEmitter "error" listener. So a routine transient disconnect (ECONNREFUSED, etc.) crashed the whole process, and the crash fired before waiters were drained, so even the iterator never saw the error. Two coupled fixes, aligning TS with the Python SDK's contract: - Emit "error" only when a listener is registered (no crash). - Drain waiters / end the stream only for typed (fatal) E2AErrors. Transient errors ride alongside WSListener's automatic reconnect, so they are swallowed and the async iterator keeps waiting for the reconnected stream instead of throwing on every network blip. Adds regression tests: a transient error under a for-await-only consumer neither throws nor ends iteration (streams again after reconnect), and a fatal 4000 "replaced" close surfaces the typed error to for-await. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jiashuoz
left a comment
There was a problem hiding this comment.
Two verified WSStream state-handling findings.
| // failures to `async for`. | ||
| if (err instanceof E2AError) { | ||
| this.closed = true; | ||
| this.drainWaitersWithError(err); |
There was a problem hiding this comment.
[P1] Preserve fatal errors until the iterator observes them
This only rejects waiters that exist at the instant the fatal error arrives. During normal for await usage there is no waiter while the loop body processes an event, so a 4000/1008 terminal close in that window sets closed and the next call returns clean { done: true }. The typed error is silently lost, contrary to the documented contract. This also bypasses the CLI's expected E2AConnectionReplacedError catch and exit-5 handling. Please retain the terminal error and have a subsequent next() reject it (after any intended buffered-event ordering). I reproduced this with both the test fake and a real ws server sending close code 4000 while the loop body was paused.
| // alongside an automatic reconnect in WSListener — swallow them here so | ||
| // the async iterator keeps waiting for the reconnected stream, matching | ||
| // the Python SDK, which logs-and-reconnects and never surfaces transient | ||
| // failures to `async for`. |
There was a problem hiding this comment.
[P2] Settle iteration when reconnect is disabled
Transient errors are swallowed on the assumption that WSListener will reconnect, but WSStream publicly accepts reconnect: false. In that configuration the subsequent close schedules no redial and the stream is never marked closed, leaving a pending next() unresolved indefinitely. A real localhost ECONNREFUSED reproduced the hang: the close event fired while next() remained pending. Please propagate/end the iterator when reconnection is disabled; this would also match the Python SDK, which returns from iteration after the first disconnect when reconnect=False.
jiashuoz
left a comment
There was a problem hiding this comment.
Approved. The two inline WSStream comments remain as follow-up feedback.
…ct is disabled Addresses the two WSStream review findings on Mnexa-AI#467. P1 — Preserve fatal errors until the iterator observes them. Previously a terminal close (4000/1008/4xxx) that arrived while the `for await` body was processing an event (no waiter in flight) set `closed` and dropped the typed error, so the next `next()` returned a clean `{ done: true }` — silently losing the error and bypassing the CLI's E2AConnectionReplacedError / exit-5 path. The error is now parked in `pendingError` and surfaced by the next pull, after any buffered events drain, exactly once. P2 — Settle iteration when reconnect is disabled. With `reconnect: false` a transient close scheduled no redial and never marked the stream closed, leaving a pending `next()` hung forever (reproduced with localhost ECONNREFUSED). The "close" handler now ends iteration cleanly when reconnect is disabled, matching the Python SDK's reconnect=False behavior. Deferred via queueMicrotask so a fatal close's synchronously-following typed error still wins. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@jiashuoz thanks for the thorough review — both WSStream findings are fixed in 759ee43. [P1] Preserve fatal errors until the iterator observes them — the terminal error is now parked in [P2] Settle iteration when reconnect is disabled — the Added three tests covering: fatal error preserved with no waiter in flight, buffered-event-before-error ordering, and the reconnect-disabled transient-close no-hang case (previously a 5s timeout). Full SDK suite (155) and CLI suite (109) green. |
Summary
Two verified, low-risk robustness fixes on the client SDK edges, each written test-first with a regression test. No behavior change for well-formed input; no Go changes. Both came out of a broader quality/security audit of the API, SDK, and MCP surfaces (see Follow-ups below).
1. Python SDK —
verify_webhook_signatureno longer raises on crafted inputverify_webhook_signaturedocuments a "never raises" contract (returnsTrue/Falseonly), but two attacker-reachable inputs broke it — turning a receiver's clean 401 into an unhandled 500 (nuisance DoS), and diverging from the TS SDK:t=of non-ASCII Unicode digits (e.g. fullwidth1750…) passesfloat()/isfinite()/ the replay-window check, then raisesUnicodeEncodeErroratt.encode("ascii").v1=of exactly 64 non-ASCII characters clears thelen()gate, then raisesTypeErrorinhmac.compare_digest("comparing strings with non-ASCII characters is not supported").A legitimate timestamp is ASCII and a legitimate signature is lowercase hex, so both malformed inputs can only ever be non-matching. Two precise guards (
t.isascii()→False,candidate.isascii()→ skip) return the cleanFalsethe contract promises. The TS SDK already rejected both, so this is also a parity fix.2. TypeScript SDK —
WSStreamno longer crashes the process on a transient WS errorWSStreamre-emitted"error"unconditionally. Node'sEventEmitterthrows when"error"is emitted with no registered listener, and the documented usage —— registers async-iterator waiters, not an EventEmitter
"error"listener. So a routine transient disconnect (ECONNREFUSED, etc.) crashed the whole process, and because the throw fired beforedrainWaitersWithError, the iterator never even saw the error.The fix aligns TS with the Python SDK's contract:
"error"only when a listener is registered (this.listenerCount("error") > 0) — no crash;E2AErrors. Transient errors ride alongsideWSListener's automatic reconnect, so they're swallowed and the async iterator keeps waiting for the reconnected stream instead of throwing on every network blip.Behavior-change note
Async-iterator (
for await) consumers now receive only fatal errors (thrown); transient blips are handled transparently by reconnect — matching the Python SDK's async-generator behavior. EventEmitter consumers that register.on("error")still receive both transient and fatal errors. A single malformed frame no longer ends the stream.Testing
pytest— 269 unit tests pass (2 new regression tests: non-ASCIIv1=, fullwidth-digitt=).vitest— 152 unit tests pass (2 newWSStreamregression tests),tscbuild clean.Follow-ups (intentionally out of scope here to keep this minimal)
The audit surfaced further verified items, triaged out of this PR:
express.json({ limit: "1mb" })breaks the advertised 10/25 MB attachment contract (the only transport); no Express error-middleware → stack-trace leak + non-JSON-RPC errors.close()doesn't cancel a pending reconnect (zombie connection); unbounded event buffer; no ping/keepalive (half-open sockets undetected).Retry-After(TS honors it).algnot pinned/tested; custom-domain agent-create doesn't validate the email local-part.🤖 Generated with Claude Code