Skip to content

Commit d40446c

Browse files
authored
test(server): cover DNS rebinding deployment guidance (#105)
* [verified] test(server): cover DNS rebinding deployment guidance * test(server): address DNS rebinding review feedback * test(server): make DNS rebinding tests explicit * test(server): simplify DNS rebinding request helper
1 parent a8675b2 commit d40446c

2 files changed

Lines changed: 169 additions & 6 deletions

File tree

doc/transports.md

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,38 +237,101 @@ This helper handles:
237237

238238
Use this for remote/browser-exposed deployments.
239239

240+
#### Deployment recipes
241+
242+
**Safe local development**
243+
244+
Bind only to loopback and allow the exact browser development origin that needs
245+
to call the MCP endpoint:
246+
247+
```dart
248+
final server = StreamableMcpServer(
249+
serverFactory: (sessionId) => McpServer(
250+
const Implementation(name: 'local-dev-server', version: '1.0.0'),
251+
),
252+
host: '127.0.0.1',
253+
port: 3000,
254+
path: '/mcp',
255+
enableDnsRebindingProtection: true,
256+
allowedHosts: {'localhost', '127.0.0.1'},
257+
allowedOrigins: {'http://localhost:5173'},
258+
);
259+
```
260+
261+
Keep DNS rebinding protection enabled even on localhost. If your browser app runs
262+
on a different dev-server port, add that exact origin instead of using a wildcard.
263+
264+
**Production browser or remote deployment**
265+
266+
Terminate TLS at your reverse proxy or load balancer, expose only the public MCP
267+
hostname, and allow only the trusted web origins that should reach it. If your
268+
deployment needs the Dart process itself to accept HTTPS, provide a custom secure
269+
`HttpServer` setup; `StreamableMcpServer` binds its listener with plain HTTP:
270+
271+
```dart
272+
final server = StreamableMcpServer(
273+
serverFactory: (sessionId) => McpServer(
274+
const Implementation(name: 'production-server', version: '1.0.0'),
275+
),
276+
host: '0.0.0.0',
277+
port: 3000,
278+
path: '/mcp',
279+
enableDnsRebindingProtection: true,
280+
allowedHosts: {'mcp.example.com'},
281+
allowedOrigins: {'https://app.example.com'},
282+
);
283+
```
284+
285+
For authenticated deployments, pair these transport checks with your OAuth or
286+
bearer-token layer. The examples in `example/authentication/` show the MCP OAuth
287+
flow and PKCE shape; production clients should use PKCE S256 with cryptographic
288+
randomness and keep redirect URIs/origins explicit.
289+
240290
### Streamable HTTP Strict Defaults
241291

242292
By default, Streamable HTTP server transports also enforce:
243293

244294
- Strict `MCP-Protocol-Version` request header validation
245295
- Rejection of JSON-RPC batch POST payloads
246296

247-
If you need a temporary compatibility mode during migration, disable strict checks explicitly:
297+
These defaults make compatibility failures visible instead of accepting requests
298+
that the Streamable HTTP spec no longer allows. If you need a temporary migration
299+
mode, disable only the specific check that blocks a known legacy client:
248300

249301
```dart
250302
final server = StreamableMcpServer(
251303
serverFactory: (sessionId) => McpServer(
252304
const Implementation(name: 'server', version: '1.0.0'),
253305
),
306+
// Only if a legacy client still sends older or experimental versions.
254307
strictProtocolVersionHeaderValidation: false,
255-
rejectBatchJsonRpcPayloads: false,
256-
enableDnsRebindingProtection: false,
308+
// Keep DNS rebinding protection enabled and explicit.
309+
enableDnsRebindingProtection: true,
310+
allowedHosts: {'mcp.example.com'},
311+
allowedOrigins: {'https://app.example.com'},
257312
);
258313
```
259314

260-
Or with low-level transport options:
315+
Or with low-level transport options for a legacy client that temporarily still
316+
sends JSON-RPC batch payloads:
261317

262318
```dart
263319
final transport = StreamableHTTPServerTransport(
264320
options: StreamableHTTPServerTransportOptions(
265-
strictProtocolVersionHeaderValidation: false,
321+
// Prefer migrating clients to non-batch Streamable HTTP requests.
266322
rejectBatchJsonRpcPayloads: false,
267-
enableDnsRebindingProtection: false,
323+
enableDnsRebindingProtection: true,
324+
allowedHosts: {'mcp.example.com'},
325+
allowedOrigins: {'https://app.example.com'},
268326
),
269327
);
270328
```
271329

330+
Avoid disabling `enableDnsRebindingProtection` on browser-exposed or remote
331+
servers. If you must disable it for an internal compatibility test, bind to
332+
loopback/private networking and put the exception behind a short-lived migration
333+
plan.
334+
272335
### Server Setup (Streamable HTTP)
273336

274337
```dart

test/server/streamable_https_test.dart

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:async';
2+
import 'dart:convert';
23
import 'dart:io';
34

45
import 'package:mcp_dart/src/server/streamable_https.dart';
@@ -303,6 +304,105 @@ void main() {
303304
expect(true, isTrue);
304305
});
305306

307+
group('DNS rebinding protection', () {
308+
Future<HttpClientResponse> postWithHeaders({
309+
required String host,
310+
String? origin,
311+
String body = '{}',
312+
}) async {
313+
final client = HttpClient();
314+
addTearDown(client.close);
315+
316+
final request = await client.postUrl(Uri.parse('$serverUrlBase/mcp'));
317+
request.headers
318+
..set(HttpHeaders.hostHeader, host)
319+
..set(HttpHeaders.acceptHeader, 'application/json, text/event-stream')
320+
..contentType = ContentType.json;
321+
if (origin != null) {
322+
request.headers.set('Origin', origin);
323+
}
324+
request.write(body);
325+
return request.close();
326+
}
327+
328+
test('allows allowlisted headers to reach session validation', () async {
329+
final transport = StreamableHTTPServerTransport(
330+
options: StreamableHTTPServerTransportOptions(
331+
sessionIdGenerator: () => 'test-session-id',
332+
enableDnsRebindingProtection: true,
333+
allowedHosts: {'localhost'},
334+
allowedOrigins: {'http://localhost:$serverPort'},
335+
),
336+
);
337+
addTearDown(transport.close);
338+
await transport.start();
339+
transports['/mcp'] = transport;
340+
341+
final response = await postWithHeaders(
342+
host: 'localhost:$serverPort',
343+
origin: 'http://localhost:$serverPort',
344+
body: jsonEncode({
345+
'jsonrpc': '2.0',
346+
'method': 'notifications/initialized',
347+
}),
348+
);
349+
final body = await utf8.decodeStream(response);
350+
final decodedBody = jsonDecode(body) as Map<String, dynamic>;
351+
final error = decodedBody['error'] as Map<String, dynamic>;
352+
353+
expect(response.statusCode, equals(HttpStatus.badRequest));
354+
expect(error['code'], equals(ErrorCode.connectionClosed.value));
355+
expect(error['message'], equals('Bad Request: Server not initialized'));
356+
expect(body, isNot(contains('DNS rebinding protection')));
357+
});
358+
359+
test('rejects requests with hosts outside the allowlist', () async {
360+
final transport = StreamableHTTPServerTransport(
361+
options: StreamableHTTPServerTransportOptions(
362+
sessionIdGenerator: () => 'test-session-id',
363+
enableDnsRebindingProtection: true,
364+
allowedHosts: {'localhost'},
365+
allowedOrigins: {'http://localhost:$serverPort'},
366+
),
367+
);
368+
addTearDown(transport.close);
369+
await transport.start();
370+
transports['/mcp'] = transport;
371+
372+
final response = await postWithHeaders(
373+
host: 'evil.example',
374+
origin: 'http://localhost:$serverPort',
375+
);
376+
final body = await utf8.decodeStream(response);
377+
378+
expect(response.statusCode, equals(HttpStatus.forbidden));
379+
expect(body, contains('DNS rebinding protection'));
380+
});
381+
382+
test('rejects requests with origins outside the allowlist', () async {
383+
final transport = StreamableHTTPServerTransport(
384+
options: StreamableHTTPServerTransportOptions(
385+
sessionIdGenerator: () => 'test-session-id',
386+
enableDnsRebindingProtection: true,
387+
allowedHosts: {'localhost'},
388+
allowedOrigins: {'http://localhost:$serverPort'},
389+
),
390+
);
391+
addTearDown(transport.close);
392+
await transport.start();
393+
transports['/mcp'] = transport;
394+
395+
final response = await postWithHeaders(
396+
host: 'localhost:$serverPort',
397+
origin: 'http://evil.example',
398+
);
399+
final body = await utf8.decodeStream(response);
400+
401+
expect(response.statusCode, equals(HttpStatus.forbidden));
402+
expect(body, contains('DNS rebinding protection'));
403+
});
404+
});
405+
306406
test('session validation works correctly', () async {
307407
// Create a transport with session management
308408
final transport = StreamableHTTPServerTransport(

0 commit comments

Comments
 (0)