Repository navigation
feat: sign and deliver MCP events safely - #133
Merged
Merged
Conversation
🟢 Tier S · Ready to mergeAdds a webhook delivery layer with Standard Webhooks signing, verification handshakes, bounded retries, and an SSRF-conscious HTTP egress transport. It also declares
📂 Walkthrough · 5
✅ Fixed since the last review · 1
🔇 Filtered out · 1Findings Hansi considered but did not post.
Reviewed |
ChiragAgg5k
force-pushed
the
feat/events-delivery
branch
from
October 9, 2026 13:53
76310b5 to
8cc957c
Compare
This was referenced Oct 9, 2026
ChiragAgg5k
force-pushed
the
feat/events-delivery
branch
from
October 11, 2026 20:16
8cc957c to
2d9cf2e
Compare
ChiragAgg5k
force-pushed
the
feat/events-delivery
branch
from
October 11, 2026 21:02
2d9cf2e to
147d12d
Compare
Callback URLs are client-chosen, so every webhook POST is an attacker-steerable request. Add an SSRF-safe egress path that pins the connection to a checked global address while keeping SNI and Host, and a Standard Webhooks dispatcher for verification handshakes and bounded, re-signed event delivery retries.
Signing, retries, final statuses, TLS and redirect handling, timeouts and per-host limits are covered through the ingress e2e flows (#134). What stays needs a controlled resolver or address table, runs only from subscribe (PR 5), or waits on the production retry schedule.
allow_loopback widened the allowed schemes to http for every host, so an embedder enabling it could send signed event data unencrypted to a public callback. Plain http now needs a loopback host (localhost or a loopback IP) whose every resolved address is loopback. The per-host slot was acquired before the request deadline started, so a request queued behind slow ones to the same host could wait without bound and go out with a signature timestamped long before it was sent. One deadline now covers the slot wait and the request, and Egress.post takes a headers factory that is called only once the slot is held, right before sending.
A subscription record carries one delivery.secret, so a Callback holds one secret and signs once; the multi-secret path had nothing left to feed it. The secret is no longer part of a Callback's repr.
ChiragAgg5k
force-pushed
the
feat/events-delivery
branch
from
October 11, 2026 21:16
147d12d to
656ac20
Compare
2 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack
feat/events← #131 ← #132 ← #133 ← #134 ← #135 (events/subscribe/events/unsubscribe)Merges into
feat/events;feat/events→mainlands as one feature after appwrite/appwrite#14293. Each PR's diff shows only its own layer, and every layer passes the full checklist on its own.Part of #127 (PR 2 of 4). A self-contained library; nothing is wired into
server.pyorhttp_app.pyyet.Summary
events/egress.py: the only outbound HTTP path for webhook callbacks (verification and delivery). https only, resolves DNS itself, refuses non-global addresses, connects to the checked IP while keeping SNI andHost, never follows redirects, one 10 s deadline per request that also covers the wait for a per-host slot, 16 KiB response cap, 4 concurrent requests per destination host. Headers, including the Standard Webhooks timestamp and signature, are built only once the slot is held.events/delivery.py: Standard Webhooks signing (webhook-id,webhook-timestamp,webhook-signature: v1,..., one signature: a subscription has one secret),whsec_secret validation, the verification handshake, and single-event delivery with bounded retries.httpcoreexplicitly sinceegress.pyimports it (already locked as an httpx dependency; no new packages).Design notes
SSRF.
egress.Backendis anhttpcore.AsyncNetworkBackendplugged into the connection pool of anhttpx.AsyncHTTPTransportsubclass. httpcore callsconnect_tcp(hostname, port); the backend resolves the hostname, rejects the request if any answer is not permitted, and opens the socket to the checked IP string. httpcore then starts TLS withserver_hostnametaken from the request origin, so SNI and certificate validation use the hostname, and httpx buildsHostfrom the URL. The hostname is never resolved a second time, so rebinding between check and connect is impossible, and the check runs on every connection, not only at subscribe time.permitted()combinesipaddress.is_globaland the negative predicates with an explicit blocklist (0/8, 10/8, 100.64/10, 127/8, 169.254/16, 172.16/12, 192.0.0/24, documentation and benchmarking ranges, multicast, 240/4,::/96,100::/64,2001::/23incl. Teredo,2001:db8::/32,2002::/166to4,3fff::/20,fc00::/7,fe80::/10,fec0::/10,ff00::/8, local-use NAT64). IPv4-mapped and well-known NAT64 (64:ff9b::/96) addresses are judged by the embedded IPv4. Proxies andtrust_envare off, because a proxy would resolve the name itself. Responses are read raw withAccept-Encoding: identity, so a compressed reply can't inflate past the cap. The only loosening is the explicitallow_loopback=Trueconstructor flag, which only tests use and nothing reads from the environment. It admits loopback addresses, and plainhttponly for a loopback host (localhost,*.localhostor a loopback IP) whose every resolved address is loopback. Public destinations stay https-only.Deadline and signing time.
Egress.post(url, body, headers)takes a headers factory. Onefail_afterwraps both the per-host slot wait and the request, and the factory runs once the slot is held. A request queued behind slow ones to the same host therefore ends within the timeout, and it is never sent with a signature timestamped back when it was queued.Verification. A signed
{"type":"verification","challenge":<32 random bytes, urlsafe>}withwebhook-id: msg_verification_<random>. The endpoint must return a 2xx JSON object whosechallengematches, compared withhmac.compare_digest. Failures raiseCallbackError(reason): 4xx ishttp_4xx, 5xx ishttp_5xx, 3xx or a missing or wrong echo ischallenge_failed, a timeout istimeout, anssl.SSLErroranywhere in the cause chain istls_error, and a forbidden destination or other connection failure isconnection_refused. Raw endpoint responses are never surfaced.Retry policy. One event per POST, body
{eventId, name, timestamp, data, cursor: null}serialized once as compact JSON, capped at 256 KiB before sending (PayloadTooLargeError).RetryPolicydefaults to 4 attempts with waits of 30 s, 2 min and 8 min, each jittered ±20%. The worst case is about 12.6 min of waiting plus 4 × 10 s timeouts, which is under 15 min. Every attempt is re-signed with a fresh timestamp, andwebhook-idstays the event id. Any non-2xx except410/413is retried, as are timeouts and connection errors (the sketch says retry on non-2xx).410/413and a forbidden destination are final (rejected). Exhausted retries areabandonedwith the last error category. Retries run in-process via an injectablesleep, and nothing is persisted.Tests
Delivery and egress are covered end to end by the ingress flows in #134 (
tests/e2e/test_events_ingress.py). There, the server's realDispatcherandEgressdeliver to a local HTTPS receiver that checks every request with the officialstandardwebhookslibrary:standardwebhooks. Body is exactly{eventId, name, timestamp, data, cursor: null},webhook-idequalseventId,X-MCP-Subscription-IdandContent-Typeare set. - Retries with a short injected policy (0.3 s, 0.3 s, 0.6 s; 1 s timeout):500, then a timeout, then302(theLocationis never requested), then200. That gives four attempts with the samewebhook-id, re-signed with non-decreasing and finally newer timestamps.410and413get exactly one attempt each (rejected/http_4xx), and503is abandoned after 4 attempts (http_5xx).tls_error.127.0.0.1, while the receiver sees SNIlocalhostandHost: localhost:<port>. The certificate has only a DNS SAN, so it validated against the hostname.rejected/connection_refused), and the receiver gets nothing.too_large).Unit tests kept, and why they are not e2e:
tests/unit/test_events_egress.py:LoopbackHttpTest: plainhttpneeds a loopback host even withallow_loopback, andlocalhostresolving to a public address (or a mix) never connects. No test can safely prove the refusal by sending to a public http URL.DeadlineTest: the reviewer's repro. Four 80 ms requests, one slot and a 100 ms timeout give exactly one success in under 200 ms (the old order: ~330 ms, all four succeed). Headers are built within 30 ms of sending, not at queue time. The wire's whole-secondwebhook-timestampis too coarse to show this.tests/unit/test_events_delivery.py:whsec_validation and the verification handshake: success, single-use challenges, mismatch, malformed bodies, 4xx/5xx, a redirect not followed, timeout, connect error and a forbidden destination. Onlyevents/subscribe(PR 5) calls them, and nothing in this stack does yet. Replace them with its e2e flow.Removed: the Standard Webhooks vector test (superseded by verifying every e2e delivery with the official library), plus delivery body/headers, retry and re-signing, 410/413, transport errors, dual signing (since removed, see 656ac20), the payload cap, timeouts, per-host concurrency and the local-TLS tests (SNI/Host, redirect, loopback without the flag, untrusted certificate). The e2e flows cover all of them.
Review changes
2d9cf2e:
Hansi,
allow_loopbackhttp scope: plainhttpis now limited to loopback hosts that resolve only to loopback.Hansi and code review, semaphore wait outside the deadline: the deadline now starts before the per-host slot. Headers and the signature are built after the slot is acquired.
321f72b: rebased onto feat: seal MCP event subscriptions into an envelope #132's switch to the
mcp-event-subscriptionsJWE envelope;pyproject.toml/uv.lockkeephttpcorenext to the new dependency.656ac20 (follows feat: seal MCP event subscriptions into an envelope #132, and Hansi on
Callback'srepr): a subscription record holds onedelivery.secret, soCallbackholds onesecret(kept out ofrepr, like its derivedkey) andsign()produces one signature. The multi-secret path had nothing left to feed it, and its only coverage was a two-secret ingress flow that cannot exist with one secret per record. New unit testCallbackTest.test_repr_hides_the_secret(no wire output shows arepr).Verification
Run locally on Python 3.12.8, lockfile written with uv 0.11.22 (the CI version):
uv run --group dev ruff check src tests: passuv run --group dev black --check src tests: passuv run --group dev pyright: 0 errorsuv run python -m unittest discover -s tests/unit: 309 tests OKuv run --group e2e python -m unittest discover -s tests/e2e: 2 tests OK (about 4 s)docker build -t appwrite-mcp:jwe .: builds