Skip to content

fix(sdk): webhook-verify (py) + WS listener (ts) no longer crash on crafted/transient input#467

Open
XueyanZhang wants to merge 3 commits into
Mnexa-AI:mainfrom
XueyanZhang:fix/sdk-ws-crash-and-webhook-verify-raise
Open

fix(sdk): webhook-verify (py) + WS listener (ts) no longer crash on crafted/transient input#467
XueyanZhang wants to merge 3 commits into
Mnexa-AI:mainfrom
XueyanZhang:fix/sdk-ws-crash-and-webhook-verify-raise

Conversation

@XueyanZhang

Copy link
Copy Markdown

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_signature no longer raises on crafted input

verify_webhook_signature documents a "never raises" contract (returns True/False only), 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:

  • A t= of non-ASCII Unicode digits (e.g. fullwidth 1750…) passes float() / isfinite() / the replay-window check, then raises UnicodeEncodeError at t.encode("ascii").
  • A v1= of exactly 64 non-ASCII characters clears the len() gate, then 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. Two precise guards (t.isascii()False, candidate.isascii() → skip) return the clean False the contract promises. The TS SDK already rejected both, so this is also a parity fix.

2. TypeScript SDK — WSStream no longer crashes the process on a transient WS error

WSStream re-emitted "error" unconditionally. Node's EventEmitter throws when "error" is emitted with no registered listener, and the documented usage —

for await (const event of client.listen("bot@acme.dev")) {  }

— 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 before drainWaitersWithError, the iterator never even saw the error.

The fix aligns TS with the Python SDK's contract:

  • emit "error" only when a listener is registered (this.listenerCount("error") > 0) — no crash;
  • drain waiters / end the stream only for typed (fatal) E2AErrors. Transient errors ride alongside WSListener'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

  • Python: pytest269 unit tests pass (2 new regression tests: non-ASCII v1=, fullwidth-digit t=).
  • TypeScript: vitest152 unit tests pass (2 new WSStream regression tests), tsc build clean.
  • Each fix was written test-first (confirmed red reproducing the crash/raise → green after the fix).

Follow-ups (intentionally out of scope here to keep this minimal)

The audit surfaced further verified items, triaged out of this PR:

  • MCP: 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.
  • TS SDK WS: close() doesn't cancel a pending reconnect (zombie connection); unbounded event buffer; no ping/keepalive (half-open sockets undetected).
  • Python SDK: retry ignores HTTP-date Retry-After (TS honors it).
  • Go: cheap high-value test gaps — SSRF blocklist has no IPv6 / v4-mapped coverage; JWT alg not pinned/tested; custom-domain agent-create doesn't validate the email local-part.

🤖 Generated with Claude Code

XueyanZhang and others added 2 commits July 13, 2026 11:41
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 jiashuoz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two verified WSStream state-handling findings.

Comment thread sdks/typescript/src/v1/ws.ts Outdated
// failures to `async for`.
if (err instanceof E2AError) {
this.closed = true;
this.drainWaitersWithError(err);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread sdks/typescript/src/v1/ws.ts Outdated
// 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`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
jiashuoz previously approved these changes Jul 13, 2026

@jiashuoz jiashuoz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@XueyanZhang

Copy link
Copy Markdown
Author

@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 pendingError and surfaced by the next next() (exactly once), after any buffered events drain. A 4000/1008/4xxx close that lands while the for await body is busy (no waiter in flight) is no longer swallowed, so the CLI's E2AConnectionReplacedError / exit-5 path fires as documented.

[P2] Settle iteration when reconnect is disabled — the close handler now ends iteration cleanly when reconnect: false, matching the Python SDK's reconnect=False behavior. It's deferred via queueMicrotask so a fatal close's synchronously-following typed error still wins (finish() then no-ops on the already-closed stream).

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants