From baef671982c30e117ac7b119436a60153ea6405d Mon Sep 17 00:00:00 2001 From: Peter Alexander Date: Mon, 15 Jun 2026 12:43:10 +0100 Subject: [PATCH] Add receiver-published well-known document as a fourth endpoint verification path A callback URL's origin can serve /.well-known/mcp-webhook-receiver.json listing the path prefixes that accept MCP webhook deliveries; control of the origin stands in as proof of intent, acting as a standing self-serve allowlist for gateways and integration platforms that can publish same-origin content. The document is fetched over the same SSRF-hardened path as deliveries and may be cached per origin. The threat model and decisions tables now list all four verification paths. delivery.url remains required on every subscription. --- docs/design-sketch-proposal.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/design-sketch-proposal.md b/docs/design-sketch-proposal.md index c75c359..f43fe19 100644 --- a/docs/design-sketch-proposal.md +++ b/docs/design-sketch-proposal.md @@ -503,9 +503,9 @@ The `events/subscribe` response MAY include a `deliveryStatus` object when refre **SSRF prevention.** The server MUST validate callback URLs. Servers SHOULD reject URLs whose resolved IP is not globally routable per the [IANA IPv4](https://www.iana.org/assignments/iana-ipv4-special-registry/) and [IPv6](https://www.iana.org/assignments/iana-ipv6-special-registry/) Special-Purpose Address Registries (illustrative examples: `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `::1`, `fc00::/7`, `fe80::/10`) unless explicitly configured to allow them. To prevent DNS rebinding, this validation MUST be performed at delivery time, not only at subscribe time: the server resolves the hostname, checks the resolved IP against the blocklist, and connects directly to that validated IP (sending the original hostname in the `Host` header / TLS SNI) so the address cannot change between check and connect. Subscribe-time validation alone is insufficient — a rebinding attacker returns a public IP at subscribe time and a private IP at delivery time. Webhook delivery requests MUST NOT follow HTTP redirects, since a redirect can target an internal address that bypasses the blocklist. Servers MAY additionally maintain an allowlist of permitted callback URL patterns. -**Endpoint verification (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **one of**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**; or (c) prior **out-of-band verification** of the URL by the server through its own mechanism (e.g., registration and confirmation via the server's dashboard). A reachable endpoint that fails to echo yields `-32015 CallbackEndpointError` with `data.reason: "challenge_failed"`; an unreachable one yields the same code with the relevant connection-failure category (`connection_refused`, `timeout`, or `tls_error`). +**Endpoint verification (anti-flooding).** Client-supplied HMAC stops a third party from being *deceived* (forged deliveries fail verification) but not from being *flooded* — an attacker can subscribe a victim's URL with an attacker-chosen secret and the victim still receives every POST. So a server MUST NOT begin delivering to a callback URL until the endpoint's intent to receive deliveries is confirmed, by **one of**: (a) a **verification handshake** — before activating, the server POSTs a `verification` control envelope carrying a single-use, short-lived `challenge` nonce (signed and headed like any delivery), and the endpoint proves intent by echoing the nonce in a `2xx` body (`{"challenge":""}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**; or (c) prior **out-of-band verification** of the URL by the server through its own mechanism (e.g., registration and confirmation via the server's dashboard); or (d) a **receiver-published well-known document** — the callback URL's `https` origin serves `/.well-known/mcp-webhook-receiver.json` declaring the path prefixes under that origin that accept MCP webhook deliveries (e.g. `{"receivers": ["/hooks/"]}`), and a callback URL covered by it is verified, with control of the origin standing in as the proof of intent. The server fetches the document over the same SSRF-hardened path used for deliveries and MAY cache it per origin for a bounded period (respecting HTTP caching headers). This is a standing, self-serve allowlist for receivers that can publish same-origin static content — typically a gateway or integration platform fronting many subscriptions; receivers whose delivery origin is not something they can publish documents on use (a)–(c). A reachable endpoint that fails to echo yields `-32015 CallbackEndpointError` with `data.reason: "challenge_failed"`; an unreachable one yields the same code with the relevant connection-failure category (`connection_refused`, `timeout`, or `tls_error`). -Verification is cached per `(principal, url)`: a successful handshake or allowlist hit covers that principal's subscriptions to that URL across refreshes and `arguments`, so varying `arguments` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. A server that persists no-expiry subscriptions across restarts (see *Subscription TTL*) MUST persist their verification status alongside them — a persisted subscription resumes delivery without a re-subscribe, so there is no later handshake to lean on, and the stored record only exists because verification succeeded. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. +Verification is cached per `(principal, url)`: a successful handshake, allowlist hit, or well-known match covers that principal's subscriptions to that URL across refreshes and `arguments`, so varying `arguments` cannot multiply verification POSTs at a victim and one principal's verification never waives the challenge for another. The cache is in-memory TTL-scoped soft state like the subscription itself; after a restart the server re-verifies on the next subscribe. A server that persists no-expiry subscriptions across restarts (see *Subscription TTL*) MUST persist their verification status alongside them — a persisted subscription resumes delivery without a re-subscribe, so there is no later handshake to lean on, and the stored record only exists because verification succeeded. The verification POST MUST use the same SSRF-hardened path as deliveries (delivery-time IP validation, no redirects) and SHOULD be rate-limited per destination host; failures surface only via the `lastError` category `challenge_failed`, never raw endpoint responses. **Server identity (optional).** A server MAY additionally sign deliveries and verification POSTs with an asymmetric key — Standard Webhooks `v1a,` (ed25519), appended alongside the `v1,` HMAC — so a gateway can verify *which* server sent a delivery, enforce a known-server allowlist, and retain protection if an HMAC secret leaks. The verifying public key MUST be discovered from an origin the client already authenticates, and never from the challenge or delivery body, which the attacker controls (that would be trust-on-first-use). The discovery mechanism is a SEP-review-time decision contingent on [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) (server cards), still draft: if server cards are published before this extension, the key is carried inline on the server's card (provisionally a `webhooks.signingKeys` member holding an ed25519 JWK set); otherwise it is published as a standalone JWKS document at a well-known path on the server's origin, `/.well-known/mcp-webhook-jwks.json`. Exactly one of these will be normative in the final SEP — they are not two options offered to implementers. Either way the key is fetched from the same origin the gateway connects to for MCP, and is distinct from the OAuth `jwks_uri` ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414)), which carries the authorization server's token-signing keys, not the MCP server's webhook-signing key. Keys carry a `kid`; rotation publishes the new key alongside the old. Endpoints that do not enforce server identity ignore `v1a,`. @@ -813,7 +813,7 @@ The threats this extension is designed to defend against, and the mechanism addr | Unauthorized subscription, or continued delivery after access is revoked | Subscribe-time permission check (`-32012 Forbidden`); periodic delivery-time re-verification; a `terminated` envelope ends the subscription when access is lost. *(Authorization)* | | Treating event receipt as authority to act | Action-time authorization — the agent's response to an event goes through normal MCP authorization; receipt grants no privilege. *(Authorization)* | | SSRF / DNS rebinding (server coerced into requesting internal hosts) | Delivery-time IP validation against the IANA special-purpose registries, connecting to the validated IP with the original Host/SNI, and no redirects; optional callback-URL allowlist. *(Webhook Security → SSRF prevention)* | -| Reflection / amplification DDoS (attacker subscribes a victim's URL) | Mandatory intent challenge (or allowlist) before activating delivery, cached per `(principal, url)`; subscribe and per-destination rate limits. *(Endpoint verification)* | +| Reflection / amplification DDoS (attacker subscribes a victim's URL) | Mandatory endpoint verification (challenge handshake, allowlist, out-of-band confirmation, or receiver-published well-known document) before activating delivery, cached per `(principal, url)`; subscribe and per-destination rate limits. *(Endpoint verification)* | | Forged / spoofed deliveries (endpoint deceived into processing fake events) | Per-subscription HMAC (Standard Webhooks `v1,`); receiver verifies the signature before processing. *(Signature scheme)* | | Server impersonation (endpoint cannot tell which server sent a delivery) | Optional asymmetric signing (`v1a,`) with the public key anchored to the server's trusted origin; gateways allowlist known servers. *(Server identity)* | | Replay of captured deliveries | `webhook-timestamp` freshness window (reject > 5 min) and `eventId` deduplication; single-use, short-lived challenge nonce. *(Signature scheme; Endpoint verification)* | @@ -946,7 +946,7 @@ These are the choices where reasonable alternatives existed; recorded so reviewe | Webhook signature scheme | Adopt Standard Webhooks as a profile (`webhook-id`/`-timestamp`/`-signature`, `v1,` HMAC, `whsec_` secrets, multi-sig rotation) | Gets off-the-shelf verifier libraries in most languages and a defined asymmetric (`v1a,`) path for free. Trade-off is a dependency on an external community spec, but it's small, stable, pinnable, and not a one-way door. | | Batched poll/stream | One subscription per `events/poll`/`events/stream` request | Batching is a transport-level concern; HTTP/2 multiplexing makes per-subscription requests cheap. Avoids the protocol complexity batching introduces (coalescing heterogeneous `nextPollMs`, partial-failure result shapes, per-entry error routing, duplicate-id handling). Clients with many subscriptions on HTTP/1.1 should expect to coalesce at the transport, not the protocol. | | Webhook secret generation | Client-supplied, REQUIRED, per subscription (`whsec_` + 24–64 B) | Avoids the post-subscribe write-back race of server-generated per-sub secrets and the out-of-band registration step of per-endpoint secrets. HMAC proves "this delivery is for a subscription *this endpoint* originated" (not merely "this came from the server"), which stops third-party *deception*; third-party *flooding* is closed separately by the mandatory intent challenge (see *Endpoint verification*). | -| Endpoint verification | Mandatory intent challenge (echo a signed nonce) or allowlist before activating delivery; cached per `(principal, url)` | HMAC stops deception but not flooding: an attacker can point a subscription at a victim's URL and the victim still receives every POST. A consent handshake (the WebSub/SNS/EventGrid pattern) bounds attacker-induced traffic to one POST per `(principal, victim-url)`; per-`(principal, url)` scoping stops one principal's verification waiving the challenge for another. | +| Endpoint verification | Mandatory proof of the endpoint's intent to receive before activating delivery — challenge handshake (echo a signed nonce), server-configured allowlist, prior out-of-band verification, or a receiver-published `/.well-known/mcp-webhook-receiver.json`; cached per `(principal, url)` | HMAC stops deception but not flooding: an attacker can point a subscription at a victim's URL and the victim still receives every POST. A consent handshake (the WebSub/SNS/EventGrid pattern) bounds attacker-induced traffic to one POST per `(principal, victim-url)`; per-`(principal, url)` scoping stops one principal's verification waiving the challenge for another. The allowlist, out-of-band, and well-known paths cover receivers for which a runtime handshake is awkward (dashboard-registered apps, gateways that can prove origin control). | | Server identity to the endpoint | Optional asymmetric signing (Standard Webhooks `v1a,`); verifying key discovered from an origin the client already authenticates — the SEP-2127 server card (inline `webhooks.signingKeys` JWK set) if it lands before this extension, otherwise a standalone `/.well-known/mcp-webhook-jwks.json` document (one chosen at SEP-review time, not both offered) | Symmetric HMAC can't prove *which* server sent a delivery (the secret is shared with the endpoint), so gateways that allowlist known servers need asymmetric signatures. Optional to avoid imposing key management on simple/serverless servers. The key must come from an origin the client already authenticates, not the delivery, else the allowlist is trust-on-first-use. | | Multimedia / high-throughput streams | Out of scope | Streaming media has fundamentally different transport, framing, and back-pressure requirements. Designing for human/automation-scale event rates keeps the SDK surface tractable; a media primitive would be a separate proposal. | | Reliable / ordered delivery | Supported (via opaque cursors + `eventId` dedup), not mandated | Genuine use cases need it, so the cursor abstraction makes at-least-once-with-replay achievable when the upstream is durable. But many upstreams are push-only with no history; mandating reliability would force them to fake it. Servers may return `cursor: null` to opt out. |