[Server] Compose StreamableHttpTransport security middleware via defaultMiddleware() factory#307
Open
sveneld wants to merge 1 commit into
Open
Conversation
b087c0f to
32ed494
Compare
Introduce three PSR-15 middleware for `StreamableHttpTransport` exposed through a public `StreamableHttpTransport::defaultMiddleware()` factory composed automatically when no middleware is passed. - `CorsMiddleware`: secure-by-default (no `Access-Control-Allow-Origin`), configurable allowlist, reflects matching origin with `Vary: Origin` to protect shared caches. - `DnsRebindingProtectionMiddleware`: validates `Origin`/`Host` against a hostname allowlist (localhost-only by default). - `ProtocolVersionMiddleware`: rejects requests carrying an unsupported `Mcp-Protocol-Version` header with `400 Bad Request`. The transport no longer applies CORS via an `instanceof + array_unshift` post-hook; the middleware parameter is nullable — `null` installs the secure defaults, `[]` disables them, and users compose by spreading `StreamableHttpTransport::defaultMiddleware()`. `SESSION_HEADER` and `PROTOCOL_VERSION_HEADER` are promoted to public constants so middleware can reuse them. BC breaks: - The `corsHeaders` constructor parameter is removed; the `middleware` parameter shifts one position. Positional callers passing the old `corsHeaders` argument must switch to named arguments or drop it. - Default `Access-Control-Allow-Origin` is no longer `*`. Addresses modelcontextprotocol#260 (DNS rebinding), modelcontextprotocol#277 (CORS extraction) and modelcontextprotocol#306 (protocol version validation).
32ed494 to
87484a5
Compare
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.
Summary
Replaces the implicit
instanceof + array_unshiftinjection of CORS handling inStreamableHttpTransportwith an explicit, composable PSR-15 stack exposed through a publicStreamableHttpTransport::defaultMiddleware()factory. Adds three middleware that together cover the recommended HTTP-level hardening for MCP Streamable HTTP:CorsMiddleware— secure-by-default (noAccess-Control-Allow-Originheader is set; cross-origin browser requests are blocked). ConfigurableallowedOriginsreflect a matching origin and automatically emitVary: Originso shared caches don't poison.DnsRebindingProtectionMiddleware— validatesOrigin/Hostagainst an allowlist of hostnames (localhost variants by default).ProtocolVersionMiddleware— rejects requests carrying an unsupportedMcp-Protocol-Versionheader with400 Bad Request(spec compliance).The transport itself becomes oblivious to the middleware list. The
$middlewareconstructor argument is nullable:nullStreamableHttpTransport::defaultMiddleware()[]...StreamableHttpTransport::defaultMiddleware()SESSION_HEADERand a newPROTOCOL_VERSION_HEADERare promoted to public constants on the transport so middleware can reuse them without duplicating string literals.Why this design
The three open items below all reach for the same shape — a piece of mandatory security logic that the transport should apply unless the user opts out. PRs #260 and #277 each implement this with their own
instanceof MyMiddlewareClasscheck +array_unshiftinside the transport constructor. That pattern scales linearly with the number of "mandatory" middleware and breaks once a user supplies a custom implementation of the same concern under a different class name. AddingProtocolVersionMiddlewarefor #306 would mean a third copy of the same pattern.This PR collapses all three into a single composition model: the transport is a thin PSR-15 host, and the recommended stack lives in a factory the user can spread, filter, or replace. No
instanceofmagic, no surprise prepending, no position arguments to bypass.Relation to other work
CorsMiddleware) — same goal of replacing the inlinewithCorsHeaders()post-hook with a PSR-15 middleware. This PR additionally drops theinstanceof CorsMiddlewareauto-prepend in favour ofdefaultMiddleware()composition (which is what @CodeWithKyrian suggested in #277 review and @chr-hertel agreed with). Also addresses Hardcoded Wildcard CORS (Access-Control-Allow-Origin: *) #280 (wildcard CORS default) the same way [Server] Extract CORS handling into CorsMiddleware #277 does —Access-Control-Allow-Originis no longer set by default. AddsVary: Originwhen reflecting a specific origin, which [Server] Extract CORS handling into CorsMiddleware #277 doesn't.localhost,127.0.0.1,[::1],::1); avoids theinstanceof DnsRebindingProtectionMiddlewareauto-prepend pattern for the same scalability reason.MCP-Protocol-Versionaccepted) — newProtocolVersionMiddlewarerejects with400 Bad Requestper spec. Tolerates a missing header (initialize round-trip / legacy clients don't send it); strict per-session version-match validation is out of scope for an HTTP-layer middleware and can be layered on if maintainers want it.If maintainers prefer to land #260 / #277 separately first, this PR can be rebased onto them and reduced to the composition refactor +
ProtocolVersionMiddleware. Happy to do that.BC breaks
StreamableHttpTransport::__construct— the$corsHeadersparameter is removed. The$middlewareparameter consequently shifts one position. Positional callers passing the old$corsHeadersargument will get a TypeError at construction; switch to named arguments or drop the argument.logger/middlewareare now positions 4/5 instead of 5/6.Access-Control-Allow-Originis no longer*. Browser clients on a different origin will be blocked unlessCorsMiddlewareis configured with explicitallowedOrigins.CHANGELOG.mdentry added under 0.6.0.Test plan
vendor/bin/phpunit --testsuite=unit— 38 new/updated tests pass (3 middleware × ~10 tests each + transport integration). Full suite: 1 error + 2 failures are pre-existing onupstream/main(DocBlockParserTestmissingComposer\Semver\VersionParserdev-dep, twoDiscoveryTestfixtures) — verified by running the same tests against vanillamainwith the branch stashed.vendor/bin/php-cs-fixer fix --dry-run— cleanvendor/bin/phpstan analyse— clean for changed code; the one pre-existing error inDocBlockParserTest(missing dev-depComposer\Semver\VersionParser) is unchangedoauth-keycloak,oauth-microsoft) updated to spread the default stack so their behaviour is preserved