Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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. |
Expand Down
16 changes: 16 additions & 0 deletions docs/configuration/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### `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.
Expand Down
112 changes: 112 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
const allowedOrigins = new Set<string>();
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[] = [];

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -12684,9 +12792,11 @@ async function startStreamableHTTPServer(): Promise<void> {
// 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
});

Expand Down Expand Up @@ -12734,6 +12844,7 @@ async function startStreamableHTTPServer(): Promise<void> {
app.set("trust proxy", 1);
}

app.use("/mcp", requireMcpHostAndOrigin);
app.use(express.json());

registerDownloadProxy(app);
Expand Down Expand Up @@ -13051,6 +13162,7 @@ async function startStreamableHTTPServer(): Promise<void> {
} else {
// Create new transport for new session
transport = new StreamableHTTPServerTransport({
...MCP_DNS_REBINDING_PROTECTION,
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId: string) => {
streamableTransports[newSessionId] = transport;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
146 changes: 146 additions & 0 deletions test/streamable-http-dns-rebinding.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const TEST_TOKEN = "glpat-12345678901234567890";

const running = new Set<ReturnType<typeof spawn>>();

function startServer(env: Record<string, string>, 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<string, string>
): 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" },
},
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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);
});
});
Loading