From d4c3c7aa96a5f3d5c5edc69a679de5555cfe91f1 Mon Sep 17 00:00:00 2001 From: zereight Date: Mon, 22 Jun 2026 23:49:31 +0900 Subject: [PATCH 1/2] fix: guard Streamable HTTP host headers --- README.md | 5 +- docs/configuration/environment-variables.md | 16 +++ index.ts | 112 +++++++++++++++ package.json | 2 +- test/streamable-http-dns-rebinding.test.ts | 146 ++++++++++++++++++++ 5 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 test/streamable-http-dns-rebinding.test.ts diff --git a/README.md b/README.md index a3d14016..833d96bd 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,7 @@ the token to GitLab on behalf of the caller. | `ENABLE_DYNAMIC_API_URL` | optional | Allow per-request GitLab URL via `X-GitLab-API-URL` header | | `GITLAB_ALLOWED_HOSTS` | optional | Comma-separated allowed `X-GitLab-API-URL` hosts; `GITLAB_API_URL` hosts are always allowed | | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | optional | Allow unauthenticated `initialize`, `notifications/initialized`, and `tools/list` only (tool calls still require auth) | +| `MCP_SERVER_URL` / `MCP_ALLOWED_HOSTS` / `MCP_ALLOWED_ORIGINS` | optional | Allowed public `/mcp` host/origin values for DNS rebinding protection | | `MCP_TRUST_PROXY` | optional | Trust `Forwarded` / `X-Forwarded-*` headers behind a reverse proxy (download URLs, Express `req.ip`, OAuth rate limits) | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY=true` is intended for MCP gateways @@ -323,6 +324,8 @@ Commonly referenced variables: - `GITLAB_USE_OAUTH` - `REMOTE_AUTHORIZATION` - `MCP_TRUST_PROXY` +- `MCP_ALLOWED_HOSTS` +- `MCP_ALLOWED_ORIGINS` - `GITLAB_MCP_OAUTH` - `GITLAB_OAUTH_CALLBACK_PROXY` - `OAUTH_STATELESS_MODE` @@ -475,7 +478,7 @@ No `headers` field is needed — Claude.ai obtains the token via OAuth automatic | ------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GITLAB_MCP_OAUTH` | Yes | Set to `true` to enable | | `GITLAB_OAUTH_APP_ID` | Yes | Client ID of the pre-registered GitLab OAuth application | -| `MCP_SERVER_URL` | Yes | Public HTTPS URL of your MCP server | +| `MCP_SERVER_URL` | Yes | Public HTTPS URL of your MCP server; also allowed for `/mcp` Host/Origin checks | | `GITLAB_API_URL` | Yes | Your GitLab instance API URL (e.g. `https://gitlab.com/api/v4`) | | `STREAMABLE_HTTP` | Yes | Must be `true` (SSE is not supported) | | `GITLAB_OAUTH_SCOPES` | No | Comma-separated GitLab scopes to request (e.g. `api,read_user`). Defaults to `api` (or `read_api` when `GITLAB_READ_ONLY_MODE=true`). The pre-registered application must be configured with at least these scopes. | diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 4ce94725..4ed4d1cf 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -390,6 +390,22 @@ Legacy additive flag for pipeline-related tools. Set to `true` to run the Streamable HTTP transport. +Streamable HTTP rejects `/mcp` requests whose `Host` header is not local +(`127.0.0.1`, `localhost`, `[::1]`), not `MCP_SERVER_URL`, and not listed in +`MCP_ALLOWED_HOSTS`. Requests with an `Origin` header must use a local origin, +`MCP_SERVER_URL`, or an origin listed in `MCP_ALLOWED_ORIGINS`. + +### `MCP_ALLOWED_HOSTS` + +Comma-separated extra allowed `Host` header values for `/mcp`. +Use this when the public host clients send differs from `MCP_SERVER_URL`. +Values may be bare hosts (`mcp.example.com`) or host:port pairs. + +### `MCP_ALLOWED_ORIGINS` + +Comma-separated extra allowed browser origins for `/mcp`, for example +`https://mcp.example.com`. + ### `SSE` Set to `true` to run the legacy SSE transport. diff --git a/index.ts b/index.ts index 1b30cf5c..ef451969 100644 --- a/index.ts +++ b/index.ts @@ -955,6 +955,100 @@ function isLoopbackBindHost(host: string): boolean { ); } +function formatHostWithPort(host: string, port: number): string | null { + const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, ""); + if (!normalized || normalized === "0.0.0.0" || normalized === "::") return null; + if (normalized.includes(":")) return `[${normalized}]:${port}`; + return `${normalized}:${port}`; +} + +function toAllowedMcpHost(value: string): string | null { + const trimmed = value.trim().toLowerCase(); + if (!trimmed || trimmed.includes(" ")) return null; + + try { + if (trimmed.includes("://")) return new URL(trimmed).host.toLowerCase(); + if (trimmed.includes("/")) return null; + return new URL(`http://${trimmed}`).host.toLowerCase(); + } catch { + try { + return new URL(`http://[${trimmed}]`).host.toLowerCase(); + } catch { + return null; + } + } +} + +function toAllowedMcpOrigin(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + try { + const origin = new URL(trimmed).origin; + return origin === "null" ? null : origin.toLowerCase(); + } catch { + return null; + } +} + +function getMcpDnsRebindingProtection() { + const allowedHosts = new Set(); + const allowedOrigins = new Set(); + const addHost = (value: string | null | undefined) => { + const host = value ? toAllowedMcpHost(value) : null; + if (host) allowedHosts.add(host); + }; + const addOrigin = (value: string | null | undefined) => { + const origin = value ? toAllowedMcpOrigin(value) : null; + if (origin) allowedOrigins.add(origin); + }; + + for (const host of ["127.0.0.1", "localhost", "::1", HOST]) { + const withPort = formatHostWithPort(host, PORT); + addHost(withPort); + if (withPort) addOrigin(`http://${withPort}`); + } + + if (MCP_SERVER_URL) { + addHost(MCP_SERVER_URL); + addOrigin(MCP_SERVER_URL); + } + + for (const host of (getConfig("mcp-allowed-hosts", "MCP_ALLOWED_HOSTS") || "").split(",")) { + addHost(host); + } + for (const origin of (getConfig("mcp-allowed-origins", "MCP_ALLOWED_ORIGINS") || "").split(",")) { + addOrigin(origin); + } + + return { + enableDnsRebindingProtection: true, + allowedHosts: [...allowedHosts], + allowedOrigins: [...allowedOrigins], + }; +} + +const MCP_DNS_REBINDING_PROTECTION = getMcpDnsRebindingProtection(); + +function requireMcpHostAndOrigin(req: Request, res: Response, next: NextFunction) { + const host = toAllowedMcpHost(req.headers.host || ""); + if (!host || !MCP_DNS_REBINDING_PROTECTION.allowedHosts.includes(host)) { + res.status(403).json({ error: "Host header is not allowed" }); + return; + } + + const originHeader = req.headers.origin; + if (originHeader) { + const origin = toAllowedMcpOrigin(Array.isArray(originHeader) ? originHeader[0] : originHeader); + if (!origin || !MCP_DNS_REBINDING_PROTECTION.allowedOrigins.includes(origin)) { + res.status(403).json({ error: "Origin header is not allowed" }); + return; + } + } + + next(); +} + function validateConfiguration(): void { const errors: string[] = []; @@ -1021,6 +1115,20 @@ function validateConfiguration(): void { } } + const mcpAllowedHosts = getConfig("mcp-allowed-hosts", "MCP_ALLOWED_HOSTS")?.split(",") || []; + for (const host of mcpAllowedHosts) { + if (host.trim() && !toAllowedMcpHost(host)) { + errors.push(`MCP_ALLOWED_HOSTS contains an invalid host or URL: ${host.trim()}`); + } + } + + const mcpAllowedOrigins = getConfig("mcp-allowed-origins", "MCP_ALLOWED_ORIGINS")?.split(",") || []; + for (const origin of mcpAllowedOrigins) { + if (origin.trim() && !toAllowedMcpOrigin(origin)) { + errors.push(`MCP_ALLOWED_ORIGINS contains an invalid origin URL: ${origin.trim()}`); + } + } + // Validate auth configuration const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true"; const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true"; @@ -12684,9 +12792,11 @@ async function startStreamableHTTPServer(): Promise { // Step 4: create a fresh transport per request. const transport = isInit ? new StreamableHTTPServerTransport({ + ...MCP_DNS_REBINDING_PROTECTION, sessionIdGenerator: () => freshSid, }) : new StreamableHTTPServerTransport({ + ...MCP_DNS_REBINDING_PROTECTION, sessionIdGenerator: undefined, // SDK stateless mode for non-init }); @@ -12734,6 +12844,7 @@ async function startStreamableHTTPServer(): Promise { app.set("trust proxy", 1); } + app.use("/mcp", requireMcpHostAndOrigin); app.use(express.json()); registerDownloadProxy(app); @@ -13051,6 +13162,7 @@ async function startStreamableHTTPServer(): Promise { } else { // Create new transport for new session transport = new StreamableHTTPServerTransport({ + ...MCP_DNS_REBINDING_PROTECTION, sessionIdGenerator: () => randomUUID(), onsessioninitialized: (newSessionId: string) => { streamableTransports[newSessionId] = transport; diff --git a/package.json b/package.json index 8304d7bb..94e23466 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "changelog": "auto-changelog -p", "test": "npm run test:all", "test:all": "npm run build && npm run test:mock && npm run test:live", - "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/dynamic-api-url-allowlist.test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/sse-auth-guard.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts", + "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/dynamic-api-url-allowlist.test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/streamable-http-dns-rebinding.test.ts && node --import tsx/esm --test test/sse-auth-guard.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts", "test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts", "test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts", "test:live": "node test/validate-api.js", diff --git a/test/streamable-http-dns-rebinding.test.ts b/test/streamable-http-dns-rebinding.test.ts new file mode 100644 index 00000000..281fd6e8 --- /dev/null +++ b/test/streamable-http-dns-rebinding.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import http from "node:http"; +import { afterEach, describe, test } from "node:test"; +import * as path from "node:path"; +import { findAvailablePort } from "./utils/server-launcher.js"; + +const HOST = process.env.HOST || "127.0.0.1"; +const SERVER_PATH = path.resolve(process.cwd(), "build/index.js"); +const TEST_TOKEN = "glpat-12345678901234567890"; + +const running = new Set>(); + +function startServer(env: Record, port: number) { + const child = spawn("node", [SERVER_PATH], { + env: { + ...process.env, + GITLAB_API_URL: "https://gitlab.example.com", + HOST, + PORT: String(port), + STREAMABLE_HTTP: "true", + REMOTE_AUTHORIZATION: "true", + GITLAB_MCP_OAUTH: "false", + GITLAB_USE_OAUTH: "false", + GITLAB_PERSONAL_ACCESS_TOKEN: "", + GITLAB_JOB_TOKEN: "", + GITLAB_AUTH_COOKIE_PATH: "", + MCP_SERVER_URL: "", + GITLAB_OAUTH_APP_ID: "", + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + running.add(child); + child.once("exit", () => running.delete(child)); + return child; +} + +async function waitForHealth(port: number, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const response = await fetch(`http://${HOST}:${port}/health`); + if (response.ok) return; + } catch (error) { + lastError = error; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + + throw new Error(`server did not become healthy: ${String(lastError)}`); +} + +function postMcp( + port: number, + headers: Record +): Promise<{ status: number; body: string }> { + const body = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "dns-rebinding-test", version: "1.0.0" }, + }, + }); + + return new Promise((resolve, reject) => { + const req = http.request( + { + host: HOST, + port, + path: "/mcp", + method: "POST", + headers: { + Host: `${HOST}:${port}`, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "Private-Token": TEST_TOKEN, + "Content-Length": Buffer.byteLength(body).toString(), + ...headers, + }, + }, + res => { + let responseBody = ""; + res.setEncoding("utf8"); + res.on("data", chunk => { + responseBody += chunk; + }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body: responseBody })); + } + ); + req.on("error", reject); + req.end(body); + }); +} + +afterEach(() => { + for (const child of running) { + if (!child.killed) child.kill("SIGTERM"); + } + running.clear(); +}); + +describe("Streamable HTTP DNS rebinding protection", () => { + test("rejects forged Host and Origin headers before handling /mcp", async () => { + const port = await findAvailablePort(4700); + startServer({}, port); + await waitForHealth(port); + + const validHost = `${HOST}:${port}`; + + const badHost = await postMcp(port, { + Host: "attacker.example.test", + Origin: `http://${validHost}`, + }); + assert.equal(badHost.status, 403); + assert.match(badHost.body, /Host header is not allowed/); + + const badOrigin = await postMcp(port, { + Host: validHost, + Origin: "https://attacker.example.test", + }); + assert.equal(badOrigin.status, 403); + assert.match(badOrigin.body, /Origin header is not allowed/); + + const ok = await postMcp(port, { Host: validHost }); + assert.equal(ok.status, 200); + }); + + test("allows the configured MCP_SERVER_URL host and origin", async () => { + const port = await findAvailablePort(4710); + startServer({ MCP_SERVER_URL: "https://mcp.example.test" }, port); + await waitForHealth(port); + + const ok = await postMcp(port, { + Host: "mcp.example.test", + Origin: "https://mcp.example.test", + }); + assert.equal(ok.status, 200); + }); +}); From 6adb624b9298ee7676266474ca05a0c695bdda9f Mon Sep 17 00:00:00 2001 From: zereight Date: Tue, 23 Jun 2026 00:00:47 +0900 Subject: [PATCH 2/2] test: cover MCP DNS rebinding guard edge cases --- docs/configuration/environment-variables.md | 8 ++--- test/streamable-http-dns-rebinding.test.ts | 34 +++++++++++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 4ed4d1cf..5529bdc9 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -390,10 +390,10 @@ Legacy additive flag for pipeline-related tools. Set to `true` to run the Streamable HTTP transport. -Streamable HTTP rejects `/mcp` requests whose `Host` header is not local -(`127.0.0.1`, `localhost`, `[::1]`), not `MCP_SERVER_URL`, and not listed in -`MCP_ALLOWED_HOSTS`. Requests with an `Origin` header must use a local origin, -`MCP_SERVER_URL`, or an origin listed in `MCP_ALLOWED_ORIGINS`. +Streamable HTTP allows `/mcp` requests when the `Host` header is local +(`127.0.0.1`, `localhost`, `[::1]`), matches `MCP_SERVER_URL`, or is listed in +`MCP_ALLOWED_HOSTS`. When an `Origin` header is present, it must be a local +origin, match `MCP_SERVER_URL`, or be listed in `MCP_ALLOWED_ORIGINS`. ### `MCP_ALLOWED_HOSTS` diff --git a/test/streamable-http-dns-rebinding.test.ts b/test/streamable-http-dns-rebinding.test.ts index 281fd6e8..97653a08 100644 --- a/test/streamable-http-dns-rebinding.test.ts +++ b/test/streamable-http-dns-rebinding.test.ts @@ -7,10 +7,16 @@ import { findAvailablePort } from "./utils/server-launcher.js"; const HOST = process.env.HOST || "127.0.0.1"; const SERVER_PATH = path.resolve(process.cwd(), "build/index.js"); -const TEST_TOKEN = "glpat-12345678901234567890"; +const TEST_TOKEN = "test-token-123456789012345"; const running = new Set>(); +function hostWithPort(host: string, port: number) { + const normalized = host.trim(); + if (normalized.includes(":") && !normalized.startsWith("[")) return `[${normalized}]:${port}`; + return `${normalized}:${port}`; +} + function startServer(env: Record, port: number) { const child = spawn("node", [SERVER_PATH], { env: { @@ -43,7 +49,7 @@ async function waitForHealth(port: number, timeoutMs = 5000) { while (Date.now() < deadline) { try { - const response = await fetch(`http://${HOST}:${port}/health`); + const response = await fetch(`http://${hostWithPort(HOST, port)}/health`); if (response.ok) return; } catch (error) { lastError = error; @@ -56,9 +62,8 @@ async function waitForHealth(port: number, timeoutMs = 5000) { function postMcp( port: number, - headers: Record -): Promise<{ status: number; body: string }> { - const body = JSON.stringify({ + headers: Record, + body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", @@ -67,8 +72,8 @@ function postMcp( capabilities: {}, clientInfo: { name: "dns-rebinding-test", version: "1.0.0" }, }, - }); - + }) +): Promise<{ status: number; body: string }> { return new Promise((resolve, reject) => { const req = http.request( { @@ -77,7 +82,7 @@ function postMcp( path: "/mcp", method: "POST", headers: { - Host: `${HOST}:${port}`, + Host: hostWithPort(HOST, port), "Content-Type": "application/json", Accept: "application/json, text/event-stream", "Private-Token": TEST_TOKEN, @@ -112,7 +117,18 @@ describe("Streamable HTTP DNS rebinding protection", () => { startServer({}, port); await waitForHealth(port); - const validHost = `${HOST}:${port}`; + const validHost = hostWithPort(HOST, port); + + const badJson = await postMcp( + port, + { + Host: "attacker.example.test", + Origin: `http://${validHost}`, + }, + "{" + ); + assert.equal(badJson.status, 403); + assert.match(badJson.body, /Host header is not allowed/); const badHost = await postMcp(port, { Host: "attacker.example.test",