Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
0.9.0
-----

* [BC Break] `DnsRebindingProtectionMiddleware` always validates `Host` (a missing one is rejected) and checks `Origin` separately against the new `allowedOrigins`, including scheme and port and empty by default. Same-origin requests are always allowed and both lists accept `['*']`. `allowedOrigins` is the new second constructor argument; pass the factories by name.
* Fix concurrent requests of one session overwriting each other's session changes: `Session::save()` now writes only the keys it changed, onto what the store holds at that point.
* [BC Break] `SchemaValidator` takes an optional `Opis\JsonSchema\Validator` as its first constructor argument, moving `$logger` to second. Pass `logger:` by name.
* [BC Break] `SchemaValidator::validateAgainstJsonSchema()` no longer validates an empty array as an object. Pass `new \stdClass()` for an empty object.
Expand Down
42 changes: 31 additions & 11 deletions docs/run/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ When the `middleware` argument is omitted (or set to `null`), the transport inst
| Order | Middleware | Purpose |
|-------|------------|---------|
| 1 | `CorsMiddleware` | Applies CORS headers to every response. By default does **not** set `Access-Control-Allow-Origin` (cross-origin requests are blocked). |
| 2 | `DnsRebindingProtectionMiddleware` | Validates `Origin`/`Host` against an allowlist. Defaults to localhost variants only. |
| 2 | `DnsRebindingProtectionMiddleware` | Validates `Host` and `Origin` independently. Defaults to localhost hosts and same-origin requests only (requests without Origin are also allowed). |

```php
// Zero-config, secure-by-default — local servers get full protection automatically.
Expand Down Expand Up @@ -87,7 +87,7 @@ $transport = new StreamableHttpTransport(
$request,
middleware: [
new CorsMiddleware(allowedOrigins: ['https://myapp.com']),
new DnsRebindingProtectionMiddleware(),
new DnsRebindingProtectionMiddleware(allowedOrigins: ['https://myapp.com']),
],
);

Expand All @@ -96,7 +96,7 @@ $transport = new StreamableHttpTransport(
$request,
middleware: [
new CorsMiddleware(allowedOrigins: ['*']),
new DnsRebindingProtectionMiddleware(),
new DnsRebindingProtectionMiddleware(allowedOrigins: ['*']),
],
);
```
Expand Down Expand Up @@ -137,17 +137,34 @@ defaults when they are absent.

## DNS Rebinding Protection

`DnsRebindingProtectionMiddleware` validates the `Origin` header against an allowlist (falling back to `Host`
when `Origin` is absent). The default allowlist is localhost-only:
`DnsRebindingProtectionMiddleware` validates `Host` on every request and additionally validates `Origin`
whenever that header is present. An invalid or missing `Host`, or a cross-origin request with an unlisted
`Origin`, receives `403 Forbidden`.

`allowedHosts` contains server hostnames, compared case-insensitively without the port. It defaults to
`localhost`, `127.0.0.1` and `[::1]`. An empty list allows no hosts. Use `allowedHosts: ['*']` to allow any
non-empty Host header, but only when a reverse proxy already validates it: the same-origin check below trusts
the Host header, so wildcard hosts alone give no protection against DNS rebinding.

`allowedOrigins` contains the full browser origins, including scheme and port. Entries are normalized
(lowercase, no trailing slash, default port omitted) and compared against the `Origin` header as browsers send
it. It defaults to an empty list. Same-origin requests are always permitted: the Origin scheme, hostname and
effective port must match the request URI (omitted ports mean 80 for HTTP and 443 for HTTPS).
Requests without `Origin` only need an allowed `Host`. Behind a reverse proxy, ensure the request URI reflects
the public scheme, hostname and port.

```php
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;

new DnsRebindingProtectionMiddleware(allowedHosts: ['myapp.local', 'mcp.internal']);
new DnsRebindingProtectionMiddleware(
allowedHosts: ['myapp.local', 'mcp.internal'],
allowedOrigins: ['https://myapp.com', 'http://localhost:3000'],
);
```

If the server is fronted by a reverse proxy that already validates `Host`, drop this middleware from the chain
or supply a permissive allowlist.
Configure cross-origin browser clients in both this middleware and `CorsMiddleware`. Neither list is inferred
from the other. Use `allowedOrigins: ['*']` to allow any Origin header while continuing to validate Host.
A reverse proxy validating `Host` alone does not replace Origin validation.

## Protocol Version Validation

Expand Down Expand Up @@ -242,7 +259,8 @@ $transport = new StreamableHttpTransport(
);
```

To selectively drop one default (for example DNS rebinding when running behind a proxy), filter the default list:
When a reverse proxy already validates Host, replace the default DNS rebinding middleware with
`allowedHosts: ['*']` to keep validating cross-origin requests:

```php
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
Expand All @@ -251,9 +269,11 @@ use Mcp\Server\Transport\StreamableHttpTransport;
$transport = new StreamableHttpTransport(
$request,
middleware: [
...array_filter(
...array_map(
fn ($m) => $m instanceof DnsRebindingProtectionMiddleware
? new DnsRebindingProtectionMiddleware(allowedHosts: ['*'])
: $m,
StreamableHttpTransport::defaultMiddleware(),
fn ($m) => !$m instanceof DnsRebindingProtectionMiddleware,
),
new AuthMiddleware($responseFactory),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,11 @@
/**
* Protects local MCP servers against DNS rebinding attacks.
*
* When the request carries an `Origin` header it is validated against the
* allowlist; otherwise the `Host` header is validated. Both checks are
* case-insensitive and ignore port. Default allowlist contains localhost
* variants only — for non-local deployments either pass a tailored list of
* hostnames or omit this middleware entirely (e.g. when fronted by a reverse
* proxy that enforces Host validation).
* The Host header is always validated against allowedHosts, ignoring case and
* port. If present, Origin must match the request URI origin or an entry in
* allowedOrigins, including scheme and port, unless allowedOrigins contains '*'.
* By default, only localhost hosts with same-origin requests or requests
* without an Origin header are allowed.
*
* @see https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning
*
Expand All @@ -41,53 +40,76 @@ final class DnsRebindingProtectionMiddleware implements MiddlewareInterface
/** @var list<string> */
private readonly array $allowedHosts;

/** @var list<string> */
private readonly array $allowedOrigins;

/**
* @param list<string> $allowedHosts Hostnames (without port) that are permitted. Defaults to localhost variants.
* IPv6 addresses must be bracketed (e.g. `[::1]`) — that is the canonical form returned by `parse_url`.
* IPv6 addresses must be bracketed (e.g. `[::1]`). Use `['*']` to allow any non-empty Host header.
* @param list<string> $allowedOrigins Origins (scheme, host and optional port) that are permitted, compared case-insensitively. Same-origin requests are always permitted. Use `['*']` to allow any Origin header.
* @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null)
* @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null)
*/
public function __construct(
array $allowedHosts = ['localhost', '127.0.0.1', '[::1]'],
array $allowedOrigins = [],
?ResponseFactoryInterface $responseFactory = null,
?StreamFactoryInterface $streamFactory = null,
) {
$this->allowedHosts = array_values(array_map('strtolower', $allowedHosts));
$this->allowedOrigins = array_values(array_map($this->normalizeOrigin(...), $allowedOrigins));
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
}

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$origin = $request->getHeaderLine('Origin');
if ('' !== $origin) {
if (!$this->isAllowedOrigin($origin)) {
return $this->createForbiddenResponse('Forbidden: Invalid Origin header.');
}

return $handler->handle($request);
if (!$this->isAllowedHost($request->getHeaderLine('Host'))) {
return $this->createForbiddenResponse('Forbidden: Invalid Host header.');
}

$host = $request->getHeaderLine('Host');
if ('' !== $host && !$this->isAllowedHost($host)) {
return $this->createForbiddenResponse('Forbidden: Invalid Host header.');
if ($request->hasHeader('Origin')
&& !\in_array('*', $this->allowedOrigins, true)
&& !$this->isSameOrigin($request)
&& !\in_array(strtolower($request->getHeaderLine('Origin')), $this->allowedOrigins, true)
) {
return $this->createForbiddenResponse('Forbidden: Invalid Origin header.');
}

return $handler->handle($request);
}

private function isAllowedOrigin(string $origin): bool
private function normalizeOrigin(string $origin): string
{
// Browsers send origins lowercase, without trailing slash or default port.
$origin = rtrim(strtolower($origin), '/');

return preg_replace(['#^(https://.+):443$#', '#^(http://.+):80$#'], '$1', $origin) ?? $origin;
}

private function isSameOrigin(ServerRequestInterface $request): bool
{
$host = parse_url($origin, \PHP_URL_HOST);
if (!\is_string($host) || '' === $host) {
$uri = $request->getUri()->withUserInfo('')->withPath('')->withQuery('')->withFragment('');
if ('' === $uri->getScheme() || '' === $uri->getHost()) {
return false;
}

return \in_array(strtolower($host), $this->allowedHosts, true);
$origin = strtolower($request->getHeaderLine('Origin'));
if ($origin === (string) $uri) {
return true;
}

// PSR-7 omits default ports, but an Origin may include one explicitly.
return null === $uri->getPort()
&& $origin === $uri.('https' === $uri->getScheme() ? ':443' : ':80');
}

private function isAllowedHost(string $host): bool
{
if (\in_array('*', $this->allowedHosts, true)) {
return '' !== $host;
}

if (str_starts_with($host, '[')) {
$closingBracket = strpos($host, ']');
if (false === $closingBracket) {
Expand Down
Loading
Loading