diff --git a/examples/servers/typescript/README.md b/examples/servers/typescript/README.md index 53c28fdc..4820bc95 100644 --- a/examples/servers/typescript/README.md +++ b/examples/servers/typescript/README.md @@ -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 +``` diff --git a/examples/servers/typescript/dns-rebinding-host-only.ts b/examples/servers/typescript/dns-rebinding-host-only.ts new file mode 100644 index 00000000..f67dcb7a --- /dev/null +++ b/examples/servers/typescript/dns-rebinding-host-only.ts @@ -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`); +}); diff --git a/examples/servers/typescript/dns-rebinding-origin-only.ts b/examples/servers/typescript/dns-rebinding-origin-only.ts new file mode 100644 index 00000000..917fef07 --- /dev/null +++ b/examples/servers/typescript/dns-rebinding-origin-only.ts @@ -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`); +}); diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index c76c1f56..d3b5c90b 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -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. diff --git a/examples/servers/typescript/no-dns-rebinding-protection.ts b/examples/servers/typescript/no-dns-rebinding-protection.ts index 1e0fc3c6..8ccdf70e 100644 --- a/examples/servers/typescript/no-dns-rebinding-protection.ts +++ b/examples/servers/typescript/no-dns-rebinding-protection.ts @@ -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) @@ -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}` }] diff --git a/src/scenarios/server/dns-rebinding.ts b/src/scenarios/server/dns-rebinding.ts index ab4ac8b4..f1d5c6cf 100644 --- a/src/scenarios/server/dns-rebinding.ts +++ b/src/scenarios/server/dns-rebinding.ts @@ -1,7 +1,7 @@ /** * DNS Rebinding Protection test scenarios for MCP servers * - * Tests that localhost MCP servers properly validate Host or Origin headers + * Tests that localhost MCP servers properly validate Host and Origin headers * to prevent DNS rebinding attacks. See GHSA-w48q-cv73-mx4w for details. */ @@ -11,6 +11,7 @@ import { DRAFT_PROTOCOL_VERSION } from '../../types'; import { buildStandardHeaders, type RunContext } from '../../connection'; +import { untestableCheck } from '../untestable'; import { request } from 'undici'; const SPEC_REFERENCES = [ @@ -24,6 +25,49 @@ const SPEC_REFERENCES = [ } ]; +/** + * Expectations for the Host-only and Origin-only checks. They depend on + * spec text that modelcontextprotocol/modelcontextprotocol#3370 proposes to + * change, so they live here and nowhere else. + * + * Origin: the Streamable HTTP transport ("Security Warning" in 2025-11-25, + * "Security & Endpoint" in the draft) says "Servers MUST validate the Origin + * header on all incoming connections to prevent DNS rebinding attacks. If the + * Origin header is present and invalid, servers MUST respond with HTTP 403 + * Forbidden." MUST, so a miss is FAILURE, and the status must be exactly 403. + * + * Host: no spec text requires Host validation. The check follows the SDK + * advisories (GHSA-w48q-cv73-mx4w, GHSA-9h52-p55h-vw2f), whose fix is Host + * validation on localhost, and accepts any 4xx. With no MUST behind it, a + * miss is WARNING. + */ +const ORIGIN_ONLY_EXPECTED_STATUS = 403; +const ORIGIN_ONLY_MISS_STATUS: 'FAILURE' | 'WARNING' = 'FAILURE'; +const HOST_ONLY_MISS_STATUS: 'FAILURE' | 'WARNING' = 'WARNING'; + +const ORIGIN_ONLY_SPEC_REFERENCES = [ + { + id: 'MCP-Transport-Security', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning' + }, + { + id: 'MCP-Transport-Security-Draft', + url: 'https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#security-%26-endpoint' + } +]; + +const HOST_ONLY_SPEC_REFERENCES = [ + ...SPEC_REFERENCES, + { + id: 'GHSA-w48q-cv73-mx4w', + url: 'https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-w48q-cv73-mx4w' + }, + { + id: 'GHSA-9h52-p55h-vw2f', + url: 'https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-9h52-p55h-vw2f' + } +]; + /** * Check if URL is a localhost URL */ @@ -105,6 +149,21 @@ async function sendRequestWithHostAndOrigin( serverUrl: string, hostOrOrigin: string, specVersion: string +): Promise<{ statusCode: number; body: unknown; sessionId?: string }> { + return sendProbe(serverUrl, specVersion, { + host: hostOrOrigin, + origin: `http://${hostOrOrigin}` + }); +} + +/** + * Send the probe request with the given Host header and, when set, the given + * Origin header. With `origin` unset no Origin header is sent. + */ +async function sendProbe( + serverUrl: string, + specVersion: string, + { host, origin }: { host: string; origin?: string } ): Promise<{ statusCode: number; body: unknown; sessionId?: string }> { // Build the SEP-2243 standard headers (Mcp-Method, Accept, ...) for the // probe's JSON-RPC method so a strictly-conformant server only rejects the @@ -115,8 +174,8 @@ async function sendRequestWithHostAndOrigin( method: 'POST', headers: buildStandardHeaders(probe.method, probe.params, { headers: { - Host: hostOrOrigin, - Origin: `http://${hostOrOrigin}`, + Host: host, + ...(origin !== undefined ? { Origin: origin } : {}), 'MCP-Protocol-Version': specVersion } }), @@ -188,10 +247,20 @@ website tricks a user's browser into making requests to the local server. 3. Browser makes request to evil.com which actually goes to localhost 4. Without Host/Origin header validation, the local MCP server processes the request -**Requirements:** -- Server **MUST** validate the Host or Origin header on incoming requests -- Server **MUST** reject requests with non-localhost Host/Origin headers (HTTP 4xx) -- Server **MUST** accept requests with valid localhost Host/Origin headers +**Checks:** +- \`localhost-host-rebinding-rejected\`: Host and Origin both non-localhost. Expects HTTP 4xx. + A server that validates either header passes. +- \`localhost-host-only-rebinding-rejected\`: non-localhost Host, no Origin. Expects HTTP 4xx. + No spec text requires Host validation, so a miss is a WARNING. +- \`localhost-origin-only-rebinding-rejected\`: localhost Host, non-localhost Origin. The + transport spec says: if the Origin header is present and invalid, servers **MUST** respond + with HTTP 403 Forbidden. Expects exactly 403. +- \`localhost-host-valid-accepted\`: localhost Host and Origin. Expects HTTP 2xx. + +Before any forged request, a control request with a localhost Host and no Origin is sent. +If the server does not answer it with 2xx, the Host-only and Origin-only checks are reported +as not testable (at their usual severity), since a rejection would not show which header, +if any, is validated. **Valid localhost values:** \`localhost\`, \`127.0.0.1\`, \`[::1]\` (with optional port) @@ -214,6 +283,23 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ specReferences: SPEC_REFERENCES }; + const hostOnlyCheckBase = { + id: 'localhost-host-only-rebinding-rejected', + name: 'DNSRebindingHostOnlyRejected', + description: + 'Server rejects a request with a non-localhost Host header and no Origin header', + timestamp, + specReferences: HOST_ONLY_SPEC_REFERENCES + }; + + const originOnlyCheckBase = { + id: 'localhost-origin-only-rebinding-rejected', + name: 'DNSRebindingOriginOnlyRejected', + description: `Server responds ${ORIGIN_ONLY_EXPECTED_STATUS} to a request with a localhost Host header and a non-localhost Origin header`, + timestamp, + specReferences: ORIGIN_ONLY_SPEC_REFERENCES + }; + const acceptedCheckBase = { id: 'localhost-host-valid-accepted', name: 'LocalhostHostAccepted', @@ -235,6 +321,18 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ errorMessage, details }); + checks.push({ + ...hostOnlyCheckBase, + status: HOST_ONLY_MISS_STATUS, + errorMessage, + details + }); + checks.push({ + ...originOnlyCheckBase, + status: ORIGIN_ONLY_MISS_STATUS, + errorMessage, + details + }); checks.push({ ...acceptedCheckBase, status: 'FAILURE', @@ -247,6 +345,23 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ const validHost = getHostFromUrl(serverUrl); const attackerHost = 'evil.example.com'; + // Control request: the probe a non-browser client sends, with the + // server's own Host and no Origin. The Host-only and Origin-only checks + // pass on a rejection, so they only show header validation if the server + // accepts this request. A server that rejects it would pass them without + // validating either header, so they are reported as not testable instead. + let controlFailure: string | undefined; + try { + const control = await sendProbe(serverUrl, specVersion, { + host: validHost + }); + if (control.statusCode < 200 || control.statusCode >= 300) { + controlFailure = `the server answered HTTP ${control.statusCode} to a control request with a localhost Host header and no Origin header, so a rejected forged request would not show that either header is validated`; + } + } catch (error) { + controlFailure = `the control request with a localhost Host header and no Origin header failed (${error instanceof Error ? error.message : String(error)})`; + } + // Check 1: Invalid Host/Origin headers should be rejected with a 4xx error try { const response = await sendRequestWithHostAndOrigin( @@ -290,7 +405,104 @@ See: https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/ }); } - // Check 2: Valid localhost Host/Origin headers should be accepted (2xx response) + if (controlFailure) { + checks.push( + untestableCheck( + hostOnlyCheckBase.id, + hostOnlyCheckBase.name, + hostOnlyCheckBase.description, + controlFailure, + hostOnlyCheckBase.specReferences, + HOST_ONLY_MISS_STATUS + ), + untestableCheck( + originOnlyCheckBase.id, + originOnlyCheckBase.name, + originOnlyCheckBase.description, + controlFailure, + originOnlyCheckBase.specReferences, + ORIGIN_ONLY_MISS_STATUS + ) + ); + } else { + // Check 2: A non-localhost Host with no Origin should be rejected with a 4xx + try { + const response = await sendProbe(serverUrl, specVersion, { + host: attackerHost + }); + const isRejected = + response.statusCode >= 400 && response.statusCode < 500; + + const details = { + hostHeader: attackerHost, + originHeader: null, + statusCode: response.statusCode, + body: response.body + }; + + if (isRejected) { + checks.push({ + ...hostOnlyCheckBase, + status: 'SUCCESS', + details + }); + } else { + checks.push({ + ...hostOnlyCheckBase, + status: HOST_ONLY_MISS_STATUS, + errorMessage: `Expected HTTP 4xx for a non-localhost Host header with no Origin header, got ${response.statusCode}`, + details + }); + } + } catch (error) { + checks.push({ + ...hostOnlyCheckBase, + status: HOST_ONLY_MISS_STATUS, + errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, + details: { hostHeader: attackerHost, originHeader: null } + }); + } + + // Check 3: A localhost Host with a non-localhost Origin should get a 403 + const attackerOrigin = `http://${attackerHost}`; + try { + const response = await sendProbe(serverUrl, specVersion, { + host: validHost, + origin: attackerOrigin + }); + + const details = { + hostHeader: validHost, + originHeader: attackerOrigin, + statusCode: response.statusCode, + body: response.body + }; + + if (response.statusCode === ORIGIN_ONLY_EXPECTED_STATUS) { + checks.push({ + ...originOnlyCheckBase, + status: 'SUCCESS', + details + }); + } else { + checks.push({ + ...originOnlyCheckBase, + status: ORIGIN_ONLY_MISS_STATUS, + errorMessage: `Expected HTTP ${ORIGIN_ONLY_EXPECTED_STATUS} for a non-localhost Origin header, got ${response.statusCode}`, + details + }); + } + } catch (error) { + checks.push({ + ...originOnlyCheckBase, + status: ORIGIN_ONLY_MISS_STATUS, + errorMessage: `Request failed: ${error instanceof Error ? error.message : String(error)}`, + details: { hostHeader: validHost, originHeader: attackerOrigin } + }); + } + } + + // Check 4: Valid localhost Host/Origin headers should be accepted (2xx response) try { const response = await sendRequestWithHostAndOrigin( serverUrl, diff --git a/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts index 746cf9ce..a5fabc2e 100644 --- a/src/scenarios/server/negative.test.ts +++ b/src/scenarios/server/negative.test.ts @@ -128,6 +128,16 @@ describe('Server scenario negative tests', () => { (c) => c.id === 'localhost-host-rebinding-rejected' ); expect(rebindingCheck?.status).toBe('FAILURE'); + // The forged request was served, not rejected for another reason. + expect(rebindingCheck?.details).toMatchObject({ statusCode: 200 }); + expect( + checks.find((c) => c.id === 'localhost-host-only-rebinding-rejected') + ?.status + ).toBe('WARNING'); + expect( + checks.find((c) => c.id === 'localhost-origin-only-rebinding-rejected') + ?.status + ).toBe('FAILURE'); }, 10000); it('sends initialized notification after a successful dated initialize', async () => { @@ -214,17 +224,42 @@ describe('Server scenario negative tests', () => { checks.find((c) => c.id === 'localhost-host-rebinding-rejected') ?.status ).toBe('SUCCESS'); + expect( + checks.find((c) => c.id === 'localhost-host-only-rebinding-rejected') + ?.status + ).toBe('SUCCESS'); + expect( + checks.find( + (c) => c.id === 'localhost-origin-only-rebinding-rejected' + )?.status + ).toBe('SUCCESS'); expect( checks.find((c) => c.id === 'localhost-host-valid-accepted')?.status ).toBe('SUCCESS'); const validHost = new URL(serverUrl).host; expect(requests).toEqual([ + // Control request + expect.objectContaining({ + method: 'initialize', + host: validHost, + origin: undefined + }), expect.objectContaining({ method: 'initialize', host: 'evil.example.com', origin: 'http://evil.example.com' }), + expect.objectContaining({ + method: 'initialize', + host: 'evil.example.com', + origin: undefined + }), + expect.objectContaining({ + method: 'initialize', + host: validHost, + origin: 'http://evil.example.com' + }), expect.objectContaining({ method: 'initialize', host: validHost, @@ -292,11 +327,136 @@ describe('Server scenario negative tests', () => { expect( checks.find((c) => c.id === 'localhost-host-valid-accepted')?.status ).toBe('SUCCESS'); - expect(methods).toEqual(['server/discover', 'server/discover']); + expect(methods).toEqual([ + 'server/discover', + 'server/discover', + 'server/discover', + 'server/discover', + 'server/discover' + ]); } finally { await closeHttpServer(server); } }, 10000); + + it('reports the Host-only and Origin-only checks as not testable when the server rejects every request', async () => { + const server = createServer((_req, res) => { + res + .writeHead(403, { 'Content-Type': 'application/json' }) + .end(JSON.stringify({ error: 'forbidden' })); + }); + + const serverUrl = await listenOnRandomPort(server); + try { + const checks = await new DNSRebindingProtectionScenario().run( + testContext(serverUrl, LATEST_SPEC_VERSION) + ); + + const hostOnly = checks.find( + (c) => c.id === 'localhost-host-only-rebinding-rejected' + ); + expect(hostOnly?.status).toBe('WARNING'); + expect(hostOnly?.errorMessage).toMatch(/^Not testable: .*HTTP 403/); + expect(hostOnly?.details).toMatchObject({ untestable: true }); + + const originOnly = checks.find( + (c) => c.id === 'localhost-origin-only-rebinding-rejected' + ); + expect(originOnly?.status).toBe('FAILURE'); + expect(originOnly?.errorMessage).toMatch(/^Not testable: .*HTTP 403/); + expect(originOnly?.details).toMatchObject({ untestable: true }); + + expect( + checks.find((c) => c.id === 'localhost-host-valid-accepted')?.status + ).toBe('FAILURE'); + } finally { + await closeHttpServer(server); + } + }, 10000); + }); + + describe('dns-rebinding-protection (Host validation only)', () => { + let serverProcess: ChildProcess | null = null; + const PORT = 3010; + + beforeAll(async () => { + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/dns-rebinding-host-only.ts' + ), + PORT + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('passes the combined check but emits FAILURE for the Origin-only check', async () => { + const checks = await new DNSRebindingProtectionScenario().run( + testContext(`http://localhost:${PORT}/mcp`) + ); + + expect( + checks.find((c) => c.id === 'localhost-host-rebinding-rejected')?.status + ).toBe('SUCCESS'); + expect( + checks.find((c) => c.id === 'localhost-host-only-rebinding-rejected') + ?.status + ).toBe('SUCCESS'); + const originOnly = checks.find( + (c) => c.id === 'localhost-origin-only-rebinding-rejected' + ); + expect(originOnly?.status).toBe('FAILURE'); + expect(originOnly?.errorMessage).toMatch(/Expected HTTP 403/); + expect(originOnly?.details).toMatchObject({ statusCode: 200 }); + expect( + checks.find((c) => c.id === 'localhost-host-valid-accepted')?.status + ).toBe('SUCCESS'); + }, 10000); + }); + + describe('dns-rebinding-protection (Origin validation only)', () => { + let serverProcess: ChildProcess | null = null; + const PORT = 3012; + + beforeAll(async () => { + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/dns-rebinding-origin-only.ts' + ), + PORT + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('passes the combined check but emits WARNING for the Host-only check', async () => { + const checks = await new DNSRebindingProtectionScenario().run( + testContext(`http://localhost:${PORT}/mcp`) + ); + + expect( + checks.find((c) => c.id === 'localhost-host-rebinding-rejected')?.status + ).toBe('SUCCESS'); + const hostOnly = checks.find( + (c) => c.id === 'localhost-host-only-rebinding-rejected' + ); + expect(hostOnly?.status).toBe('WARNING'); + expect(hostOnly?.errorMessage).toMatch(/Expected HTTP 4xx/); + expect(hostOnly?.details).toMatchObject({ statusCode: 200 }); + expect( + checks.find((c) => c.id === 'localhost-origin-only-rebinding-rejected') + ?.status + ).toBe('SUCCESS'); + expect( + checks.find((c) => c.id === 'localhost-host-valid-accepted')?.status + ).toBe('SUCCESS'); + }, 10000); }); describe('sep-2164-resource-not-found', () => {