Resolve endpoint verification and add Security threat model

- Replace Open Question 6 with normative endpoint-verification rules:
  a mandatory intent challenge (or allowlist) before activating webhook
  delivery, cached per (principal, url); add -32014 EndpointVerificationFailed
  and fill the verification control-envelope row.
- Add optional asymmetric server signing (Standard Webhooks v1a,) for
  known-server allowlists. The webhook-signing key is discovered from an
  origin the client already authenticates: inline webhooks.signingKeys on
  the SEP-2127 server card if it lands first, otherwise a standalone
  /.well-known/mcp-webhook-jwks.json document (one chosen at SEP-review time).
- Correct the webhook-secret rationale (HMAC stops deception, not flooding)
  and add Key Design Decisions rows for endpoint verification and server identity.
- Add a Threat Model summary table to Security Considerations.
This commit is contained in:
Peter Alexander 2026-05-22 13:07:23 +01:00
commit 9f9cdce8b1
No known key found for this signature in database

View file

@ -111,11 +111,12 @@ There are three delivery modes with different subscription mechanisms:
| `-32011` | `EventNotFound` | Unknown event name |
| `-32012` | `Unauthorized` | User lacks permission for this event/params combination |
| `-32013` | `TooManySubscriptions` | Server-imposed subscription limit reached |
| `-32014` | `EndpointVerificationFailed` | Callback endpoint was reachable but failed the verification handshake — did not echo the challenge (webhook mode only) |
| `-32015` | `InvalidCallbackUrl` | Webhook URL is unreachable or rejected by the server (webhook mode only) |
| `-32016` | `SubscriptionNotFound` | No subscription matches the supplied key for `events/unsubscribe` (webhook mode only) |
| `-32017` | `DeliveryModeUnsupported` | Event type does not support the requested delivery mode |
These codes are drawn from `-32011..-32017` (`-32014` is reserved/unused) to avoid collision with base MCP error codes (e.g., `-32002 ResourceNotFound`).
These codes are drawn from `-32011..-32017` to avoid collision with base MCP error codes (e.g., `-32002 ResourceNotFound`).
### Poll-Based Delivery
@ -423,7 +424,7 @@ In addition to `EventOccurrence` bodies, the server POSTs control envelopes to t
|---|---|---|
| `gap` | `{"type":"gap","cursor":"<fresh>"}` | A gap is detected between refreshes (see *Gaps and `truncated`*). The client persists `cursor` and treats it as `truncated: true`. |
| `terminated` | `{"type":"terminated","error":{"code":...,"message":...,"data":...}}` | The subscription has ended (e.g., authorization revoked). The subscription no longer exists server-side. |
| `verification` | `{"type":"verification","challenge":"<nonce>"}` | *(Pending — see Open Question 6.)* |
| `verification` | `{"type":"verification","challenge":"<nonce>"}` | Sent before activating delivery to an unverified `(principal, url)` (see *Webhook Security → Endpoint verification*). The endpoint echoes `challenge` in a `2xx` body to prove intent; the server activates delivery only on a constant-time match. |
#### Webhook Delivery Status
@ -466,7 +467,11 @@ 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.** *(Open — see Open Question 6.)* Whether the server performs an ownership challenge before activating delivery, what that challenge carries, and whether it is MUST or SHOULD given client-supplied HMAC, are not yet settled. The interaction with optional asymmetric server signing (sender identity for gateway allowlists) is part of the same decision.
**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 **either**: (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":"<nonce>"}`), which the server compares in constant time; or (b) the URL matching a server-configured **allowlist**. A reachable endpoint that fails to echo yields `-32014 EndpointVerificationFailed`; an unreachable one yields `-32015 InvalidCallbackUrl`.
Verification is cached per `(principal, url)`: a successful handshake or allowlist hit covers that principal's subscriptions to that URL across refreshes and `params`, so varying `params` 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. 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,`.
**TLS requirement.** Callback URLs MUST use `https://`. Servers MUST reject `events/subscribe` with a non-`https` `delivery.url` (`-32015 InvalidCallbackUrl`). Event payloads and the `delivery.secret` (sent in the subscribe request) would otherwise transit in cleartext.
@ -759,6 +764,27 @@ When an event arrives (via any delivery mode), the client SDK surfaces it to the
## Security Considerations
### Threat Model
The threats this extension is designed to defend against, and the mechanism addressing each. Payload and authorization threats apply to all delivery modes; the rest are specific to webhook mode, the only mode that makes outbound requests to client-supplied URLs. Details are in the referenced sections.
| Threat | How it's addressed |
|---|---|
| Prompt injection via event payloads | Payloads are untrusted data — clients sanitize/sandbox before the LLM (as with tool results); servers keep payloads minimal to shrink the injection surface. *(Event Payloads Are Untrusted Data; Payload Minimality)* |
| PII / sensitive-data exposure in event infrastructure (logs, queues, buffers) | Payload minimality — deliver only triage fields (e.g. sender, subject, id) and fetch full content via tools on demand. *(Payload Minimality)* |
| Unauthorized subscription, or continued delivery after access is revoked | Subscribe-time permission check (`-32012 Unauthorized`); 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)* |
| 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)* |
| Signing-secret leakage | Per-subscription secrets limit blast radius; client-driven rotation with a dual-sign grace window; optional asymmetric signing as defence-in-depth. *(Secret rotation; Server identity)* |
| Eavesdropping in transit (payloads and `delivery.secret`) | TLS required — `https` callback URLs only; non-`https` subscribe is rejected. *(TLS requirement)* |
| Cross-tenant subscription tampering (guessing the tuple to unsubscribe or rotate another tenant's secret) | Webhook requires an authenticated principal; subscription identity includes `principal`; the derived `id` is a non-capability routing handle. *(Subscription Identity)* |
| Response oracle / internal-service probing via error detail | `deliveryStatus.lastError` is a fixed category string only — never raw endpoint bodies, headers, or status lines. *(Webhook Delivery Status)* |
| Server-side resource exhaustion (subscription flooding) | `-32013 TooManySubscriptions` limits; webhook subscriptions are TTL'd soft state that expire without refresh. *(Error Codes)* |
### Event Payloads Are Untrusted Data
Event payloads MUST be treated with the same caution as tool results. They originate from external systems and may contain:
@ -839,11 +865,6 @@ The spec does not mandate specific governance mechanisms but is designed to enab
5. **Should MCP task state changes be exposed as events?** Tasks are MCP's long-running-operation primitive; clients currently learn of state transitions via push notifications on a live connection. Exposing a reserved `mcp.task.updated` event type (params `{taskId}`, payload `{status, progress?, result?}`) would let a client that started a task and disconnected receive the completion via poll or webhook — useful for "kick off a long job, close the laptop, get notified when done." Same considerations as question 2: reserved `mcp.*` prefix, migration path, and whether one mechanism subsuming another is worth the coupling.
6. **Endpoint verification and server-identity signing — what is required, and how do they relate?** Two intertwined sub-questions:
- *Ownership challenge.* With client-supplied HMAC secrets, a delivery to an endpoint that didn't originate the secret already fails verification, so a nonce-echo challenge is arguably defence-in-depth (fail-fast on bad URLs; bound spam to one POST) rather than the load-bearing control. Options: MUST (prior text), SHOULD, or omit. If kept, the challenge is an HMAC-signed `{"type":"verification","id":...,"challenge":...}` POST; the endpoint echoes the token.
- *Asymmetric server signing.* Servers MAY additionally sign deliveries with an ed25519 key (Standard Webhooks `v1a,`), with the public key published via JWKS at the server's origin (potentially via SEP-2127 server cards). This lets a gateway verify *which server* sent a delivery and enforce a server allowlist, and provides defence-in-depth if an HMAC secret leaks. Key distribution must be anchored to an origin the gateway already trusts — a key or JWKS URL carried in the challenge itself is trust-on-first-use and can be attacker-supplied.
These need to be resolved together: the answer to "is the challenge MUST" depends on whether asymmetric signing is expected to be common, and both depend on what threat the gateway is defending against (third-party-target abuse vs server impersonation vs leaked secret).
## Note on Consistency and Ordering
This design intentionally does not provide protocol-level guarantees around event ordering, exactly-once delivery, or transactional consistency. There are no logical clocks, sequence numbers, or cross-subscription ordering constraints.
@ -885,7 +906,9 @@ These are the choices where reasonable alternatives existed; recorded so reviewe
| Mandatory delivery mode | None — servers advertise any non-empty subset of poll/push/webhook | Universal compatibility would be nice, but mandating any single mode places an unacceptable burden on implementers (poll needs a history or buffer; push needs a held connection; webhook needs outbound HTTP + SSRF hardening). The extension is already opt-in. May revisit if deployments converge on a common subset in practice. |
| 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. Crucially, HMAC then proves "this delivery is for a subscription *this endpoint* originated," not merely "this came from the server" — closing third-party-target abuse without a mandatory challenge. |
| 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. |
| 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. |
| Webhook subscription identity | `(principal, delivery.url, name, params)`; webhook requires auth; server-derived `id` for routing only | The tuple is everything that semantically defines a subscription, so subscribe is naturally idempotent and there is nothing for the client to generate or persist. The derived `id` is a non-capability routing handle. Webhook requires an authenticated principal so the tuple is not guessable by other tenants; without it, anyone could unsubscribe or rotate another tenant's secret. Server-generated and client-generated `id` schemes were considered; both add state or entropy obligations the tuple makes unnecessary. |