Skip to content
Open
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
24 changes: 23 additions & 1 deletion examples/servers/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,26 @@ npx @modelcontextprotocol/conformance server \
--scenario dns-rebinding-protection
```

**DO NOT** use this pattern in production servers. Always use `createMcpExpressApp()` or the `localhostHostValidation()` middleware for localhost servers.
**DO NOT** use this pattern in production servers. Always use `createMcpExpressApp()` or the `localhostHostValidation()` middleware for localhost servers, and return 403 for a present, non-localhost `Origin` header.

### dns-rebinding-host-only.ts

Validates the `Host` header (via `createMcpExpressApp()`) but not the `Origin` header. It passes `localhost-host-rebinding-rejected` and `localhost-host-only-rebinding-rejected`, and is expected to **FAIL** `localhost-origin-only-rebinding-rejected`.

```bash
npx tsx dns-rebinding-host-only.ts
npx @modelcontextprotocol/conformance server \
--url http://localhost:3010/mcp \
--scenario dns-rebinding-protection
```

### dns-rebinding-origin-only.ts

Returns 403 for a non-localhost `Origin` header but accepts any `Host`. It passes `localhost-host-rebinding-rejected` and `localhost-origin-only-rebinding-rejected`, and is expected to get a **WARNING** on `localhost-host-only-rebinding-rejected`.

```bash
npx tsx dns-rebinding-origin-only.ts
npx @modelcontextprotocol/conformance server \
--url http://localhost:3012/mcp \
--scenario dns-rebinding-protection
```
70 changes: 70 additions & 0 deletions examples/servers/typescript/dns-rebinding-host-only.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env node

/**
* MCP Server that validates the Host header but not the Origin header -
* Negative Test Case
*
* Uses createMcpExpressApp(), whose localhost default in SDK 1.x validates
* Host only. A request with a valid Host and a foreign Origin is accepted.
*
* Expected dns-rebinding-protection results:
* - localhost-host-rebinding-rejected: SUCCESS (Host is forged too)
* - localhost-host-only-rebinding-rejected: SUCCESS
* - localhost-origin-only-rebinding-rejected: FAILURE (no 403)
*/

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import { z } from 'zod';

function createMcpServer() {
const server = new McpServer({
name: 'dns-rebinding-host-only-server',
version: '1.0.0'
});

server.registerTool(
'echo',
{
description: 'Echo the input back',
inputSchema: { message: z.string() }
},
async ({ message }) => ({
content: [{ type: 'text', text: `Echo: ${message}` }]
})
);

return server;
}

// Host validation only; no Origin check.
const app = createMcpExpressApp();

app.post('/mcp', async (req, res) => {
try {
const server = createMcpServer();
// Stateless: no session ID
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: `Internal error: ${error instanceof Error ? error.message : String(error)}`
},
id: null
});
}
}
});

const PORT = parseInt(process.env.PORT || '3010', 10);
app.listen(PORT, '127.0.0.1', () => {
console.log(`Host-only server running on http://localhost:${PORT}/mcp`);
});
95 changes: 95 additions & 0 deletions examples/servers/typescript/dns-rebinding-origin-only.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env node

/**
* MCP Server that validates the Origin header but not the Host header -
* Negative Test Case
*
* Returns 403 for a present, non-localhost Origin, as the Streamable HTTP
* transport requires, but accepts any Host. A request with a forged Host and
* no Origin is accepted.
*
* Expected dns-rebinding-protection results:
* - localhost-host-rebinding-rejected: SUCCESS (Origin is forged too)
* - localhost-host-only-rebinding-rejected: WARNING (no 4xx)
* - localhost-origin-only-rebinding-rejected: SUCCESS
*/

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { z } from 'zod';

function createMcpServer() {
const server = new McpServer({
name: 'dns-rebinding-origin-only-server',
version: '1.0.0'
});

server.registerTool(
'echo',
{
description: 'Echo the input back',
inputSchema: { message: z.string() }
},
async ({ message }) => ({
content: [{ type: 'text', text: `Echo: ${message}` }]
})
);

return server;
}

function isLocalhostOrigin(origin: string): boolean {
try {
const { hostname } = new URL(origin);
return ['localhost', '127.0.0.1', '[::1]'].includes(hostname);
} catch {
return false;
}
}

// Plain express app: no Host validation.
const app = express();
app.use(express.json());

// Origin validation only.
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin === undefined || isLocalhostOrigin(origin)) {
next();
return;
}
res.status(403).json({
jsonrpc: '2.0',
error: { code: -32000, message: `Invalid Origin: ${origin}` },
id: null
});
});

app.post('/mcp', async (req, res) => {
try {
const server = createMcpServer();
// Stateless: no session ID
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: `Internal error: ${error instanceof Error ? error.message : String(error)}`
},
id: null
});
}
}
});

const PORT = parseInt(process.env.PORT || '3012', 10);
app.listen(PORT, '127.0.0.1', () => {
console.log(`Origin-only server running on http://localhost:${PORT}/mcp`);
});
26 changes: 26 additions & 0 deletions examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,32 @@ function isInitializeRequest(body: any): boolean {
// Use createMcpExpressApp for DNS rebinding protection on localhost
const app = createMcpExpressApp();

// createMcpExpressApp validates Host only. The Streamable HTTP transport also
// requires HTTP 403 when the Origin header is present and invalid; here a
// valid Origin is a localhost one. A missing Origin (non-browser client) is
// allowed.
function isLocalhostOrigin(origin: string): boolean {
try {
const { hostname } = new URL(origin);
return ['localhost', '127.0.0.1', '[::1]'].includes(hostname);
} catch {
return false;
}
}

app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin === undefined || isLocalhostOrigin(origin)) {
next();
return;
}
res.status(403).json({
jsonrpc: '2.0',
error: { code: -32000, message: `Invalid Origin: ${origin}` },
id: null
});
});

// Open subscriptions/listen streams (SEP-2575). Notifications are delivered
// to whichever streams are open *at the time of the change* and whose filter
// requested that notification type.
Expand Down
3 changes: 2 additions & 1 deletion examples/servers/typescript/no-dns-rebinding-protection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { z } from 'zod';

// Create a fresh MCP server per request to avoid "Already connected" errors
// after the v1.26.0 security fix (GHSA-345p-7cg4-v4c7)
Expand All @@ -25,7 +26,7 @@ function createMcpServer() {
'echo',
{
description: 'Echo the input back',
inputSchema: { message: { type: 'string' } }
inputSchema: { message: z.string() }
},
async ({ message }) => ({
content: [{ type: 'text', text: `Echo: ${message}` }]
Expand Down
Loading