Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,10 @@ Opt-in security layer for when you expose the HTTP transport on a network. Defau

`MCP_HTTP_ALLOWED_HOSTS` is compared against the raw `Host` header, which includes the port. The default already covers loopback access on the configured `MCP_HTTP_PORT` (`127.0.0.1:PORT`, `localhost:PORT`, `[::1]:PORT`) plus the bare hostnames, which match a portless `Host` — a client or reverse proxy that omits the port (as on ports 80/443). When you set it explicitly, list the exact `Host` the client (or your reverse proxy) sends — e.g. `app.example.com` if the proxy forwards `Host: app.example.com` on 443, or `app.example.com:8443` if it forwards a port.

### Origin validation and upgrade notice

Every present `Origin` on `/mcp` is validated in all modes; an absent `Origin` remains valid for non-browser clients. In non-hardened mode, an unset `MCP_HTTP_ALLOWED_ORIGINS` defaults to the exact HTTP/HTTPS loopback origins `http://127.0.0.1`, `https://127.0.0.1`, `http://localhost`, `https://localhost`, `http://[::1]`, and `https://[::1]`, both portless and with the configured `MCP_HTTP_PORT`. A non-empty `MCP_HTTP_ALLOWED_ORIGINS` replaces those defaults. Entries are trimmed but otherwise literal; matching is exact, case-sensitive literal matching, including scheme and port. Malformed, scheme-less, path-bearing, trailing slash, or differently-cased values silently do not match and must be corrected. Hardened mode still requires an explicit allowlist and adds authentication plus Host enforcement. An invalid present `Origin` on `/mcp` receives a fixed, non-reflecting 403 before parser, authentication, rate limiting, or transport construction. `/health` is outside the MCP 403 boundary but uses the narrowed global CORS allowlist. Before upgrading, existing non-hardened browser deployments using non-loopback Origins must set `MCP_HTTP_ALLOWED_ORIGINS` or receive a fixed 403. This change addresses the Origin security requirement; legacy transport compatibility remains and is not a claim of full MCP 2026-07-28 transport compliance.

## URL Reader Security

`web_url_read` blocks private/internal URLs by default in all transport modes. This includes localhost, loopback addresses, private IPv4 ranges, link-local addresses, `0.0.0.0/8`, CGNAT (`100.64.0.0/10`), IANA special-purpose IPv4 ranges, IPv6 loopback/ULA/link-local addresses, and IPv4-mapped IPv6 private addresses.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,8 @@ Stateful sessions remain the default. Set `MCP_HTTP_STATELESS=true` when a deplo

Stateless requests are bounded by global and per-client-IP in-flight limits plus a request lifetime. See [CONFIGURATION.md](CONFIGURATION.md#http-transport) for defaults, overload and timeout responses, proxy-aware fairness, and the complete compatibility contract.

**Origin validation and upgrade notice:** Every present `Origin` on `/mcp` is validated in all modes; an absent `Origin` remains valid for non-browser clients. In non-hardened mode, an unset `MCP_HTTP_ALLOWED_ORIGINS` defaults to the exact HTTP/HTTPS loopback origins `http://127.0.0.1`, `https://127.0.0.1`, `http://localhost`, `https://localhost`, `http://[::1]`, and `https://[::1]`, both portless and with the configured `MCP_HTTP_PORT`. A non-empty `MCP_HTTP_ALLOWED_ORIGINS` replaces those defaults. Entries are trimmed but otherwise literal; matching is exact, case-sensitive literal matching, including scheme and port. Malformed, scheme-less, path-bearing, trailing slash, or differently-cased values silently do not match and must be corrected. Hardened mode still requires an explicit allowlist and adds authentication plus Host enforcement. An invalid present `Origin` on `/mcp` receives a fixed, non-reflecting 403 before parser, authentication, rate limiting, or transport construction. `/health` is outside the MCP 403 boundary but uses the narrowed global CORS allowlist. Before upgrading, existing non-hardened browser deployments using non-loopback Origins must set `MCP_HTTP_ALLOWED_ORIGINS` or receive a fixed 403. This change addresses the Origin security requirement; legacy transport compatibility remains and is not a claim of full MCP 2026-07-28 transport compliance.

**Test it:**

```bash
Expand Down
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@

`MCP_HTTP_HARDEN=true` will fail to start if `MCP_HTTP_AUTH_TOKEN` or `MCP_HTTP_ALLOWED_ORIGINS` are missing.

### Origin validation and upgrade notice

When an `/mcp` request includes an `Origin`, the server validates it against the applicable allowlist in hardened and non-hardened modes; an absent `Origin` remains accepted for non-browser clients. In non-hardened mode, an unset `MCP_HTTP_ALLOWED_ORIGINS` defaults to the exact HTTP/HTTPS loopback origins `http://127.0.0.1`, `https://127.0.0.1`, `http://localhost`, `https://localhost`, `http://[::1]`, and `https://[::1]`, both portless and with the configured `MCP_HTTP_PORT`. A non-empty `MCP_HTTP_ALLOWED_ORIGINS` replaces those defaults. Entries are trimmed but otherwise literal; matching is exact, case-sensitive literal matching, including scheme and port. Malformed, scheme-less, path-bearing, trailing slash, or differently-cased values silently do not match and must be corrected. Hardened mode still requires an explicit allowlist and adds authentication plus Host enforcement. An invalid present `Origin` on `/mcp` receives a fixed, non-reflecting 403 before parser, authentication, rate limiting, or transport construction. `/health` is outside the MCP 403 boundary but uses the narrowed global CORS allowlist. Before upgrading, existing non-hardened browser deployments using non-loopback Origins must set `MCP_HTTP_ALLOWED_ORIGINS` or receive a fixed 403. This change addresses the Origin security requirement; legacy transport compatibility remains and is not a claim of full MCP 2026-07-28 transport compliance.

Check warning on line 167 in SECURITY.md

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

SECURITY.md#L167

Absolute rule without escape hatch: "When an `/mcp` request includes an `Origin`, the server validates it a"

`GET /health` intentionally remains unauthenticated so container orchestrators
and load balancers can perform liveness checks. It is independently limited to
60 requests per minute and returns only `status`, `server`, `version`, and
Expand Down
239 changes: 214 additions & 25 deletions __tests__/integration/http-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,23 @@ async function captureConsoleOutput(action: () => Promise<void>): Promise<string
return output;
}

async function assertAllowedMcpPreflight(
app: Awaited<ReturnType<typeof createHttpServer>>,
origin: string,
): Promise<void> {
const options = await request(app).options('/mcp')
.set('Origin', origin)
.set('Access-Control-Request-Method', 'POST')
.set('Access-Control-Request-Headers', 'Content-Type, mcp-session-id, authorization, mcp-protocol-version');
assert.equal(options.status, 204);
assert.equal(options.headers['access-control-allow-origin'], origin);
assert.match(options.headers.vary || '', /Origin/);
const allowHeaders = (options.headers['access-control-allow-headers'] || '').toLowerCase();
for (const header of ['content-type', 'mcp-session-id', 'authorization', 'mcp-protocol-version']) {
assert.ok(allowHeaders.includes(header));
}
}

async function runTests() {
console.log('🧪 Integration Testing: http-server.ts\n');

Expand Down Expand Up @@ -245,14 +262,195 @@ async function runTests() {
assert.equal(res.body.server, 'ihor-sokoliuk/mcp-searxng');
}, results);

await testFunction('GET /health includes CORS headers', async () => {
const app = await createHttpServer(() => createTestMcpServer());
const res = await request(app)
.get('/health')
.set('Origin', 'http://example.com');
await testFunction('origin boundary rejects every present invalid Origin before parsers, CORS, limits, auth, or server construction', async () => {
envManager.delete('MCP_HTTP_HARDEN');
envManager.delete('MCP_HTTP_AUTH_TOKEN');
envManager.delete('MCP_HTTP_ALLOWED_ORIGINS');
envManager.delete('MCP_HTTP_STATELESS');
envManager.set('MCP_RATE_INIT_MAX', '1');
let constructions = 0;

assert.equal(res.status, 200);
assert.ok(res.headers['access-control-allow-origin']);
try {
const app = await createHttpServer(() => {
constructions += 1;
return createTestMcpServer();
}, 43123);
const expected = {
jsonrpc: '2.0',
error: { code: -32000, message: 'Invalid Origin header' },
id: null,
};
const invalidOrigins = [
'null',
'not an origin',
'http://localhost:43123,http://127.0.0.1:43123',
'HTTP://localhost:43123',
'http://localhost:43123/',
'',
];
const responses = await captureConsoleOutput(async () => {
const methodResponses = await Promise.all([
request(app).options('/MCP')
.set('Origin', invalidOrigins[0])
.set('Access-Control-Request-Method', 'POST'),
request(app).post('/mcp/')
.set('Origin', invalidOrigins[1])
.set('Content-Type', 'application/json')
.send('{"jsonrpc":"2.0"'),
request(app).get('/mcp/anything').set('Origin', invalidOrigins[2]),
request(app).delete('/mcp').set('Origin', invalidOrigins[3]),
request(app).patch('/mcp').set('Origin', invalidOrigins[4]),
request(app).post('/mcp').set('Origin', invalidOrigins[5]),
]);
for (const res of methodResponses) {
assert.equal(res.status, 403);
assert.deepEqual(res.body, expected);
assert.equal(res.headers['access-control-allow-origin'], undefined);
assert.equal(res.headers['ratelimit-limit'], undefined);
assert.equal(res.headers['ratelimit-remaining'], undefined);
assert.ok(!Object.keys(res.headers).some(header => /rate-?limit/i.test(header)));
}

for (let attempt = 0; attempt < 3; attempt += 1) {
const res = await request(app).post('/mcp').set('Origin', 'https://attacker.example');
assert.equal(res.status, 403);
assert.deepEqual(res.body, expected);
assert.equal(res.headers['ratelimit-limit'], undefined);
}
});
assert.deepEqual(responses, []);
assert.equal(constructions, 0);

const outside = await request(app).post('/mcp-extra')
.set('Origin', 'https://attacker.example');
assert.equal(outside.status, 404);
} finally {
envManager.restore();
}
}, results);

await testFunction('stateless origin boundary rejects attacker Origins before construction in compatibility mode', async () => {
envManager.delete('MCP_HTTP_HARDEN');
envManager.delete('MCP_HTTP_ALLOWED_ORIGINS');
envManager.set('MCP_HTTP_STATELESS', 'true');
let constructions = 0;

try {
const app = await createHttpServer(() => {
constructions += 1;
return createTestMcpServer();
}, 43123);
const res = await request(app).post('/mcp')
.set('Origin', 'https://attacker.example')
.set('Content-Type', 'application/json')
.send('{"jsonrpc":"2.0"');
assert.equal(res.status, 403);
assert.deepEqual(res.body, {
jsonrpc: '2.0',
error: { code: -32000, message: 'Invalid Origin header' },
id: null,
});
assert.equal(constructions, 0);
} finally {
envManager.restore();
}
}, results);

await testFunction('origin boundary permits absent Origins and reflects only exact allowed Origins', async () => {
envManager.delete('MCP_HTTP_HARDEN');
envManager.delete('MCP_HTTP_ALLOWED_ORIGINS');

try {
const app = await createHttpServer(() => createTestMcpServer(), 43123);
const allowedOrigins = [
'http://127.0.0.1',
'http://localhost',
'http://[::1]',
'https://127.0.0.1',
'https://localhost',
'https://[::1]',
'http://127.0.0.1:43123',
'http://localhost:43123',
'http://[::1]:43123',
'https://127.0.0.1:43123',
'https://localhost:43123',
'https://[::1]:43123',
];
for (const allowedOrigin of allowedOrigins) {
await assertAllowedMcpPreflight(app, allowedOrigin);
}

const post = await request(app).post('/mcp')
.set('Origin', 'http://localhost:43123')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json, text/event-stream')
.send({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'origin-test', version: '1.0.0' } },
});
assert.equal(post.status, 200);
assert.equal(post.headers['access-control-allow-origin'], 'http://localhost:43123');
assert.match(post.headers.vary || '', /Origin/);

const absent = await request(app).post('/mcp')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json, text/event-stream')
.send({
jsonrpc: '2.0', id: 2, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'no-origin-test', version: '1.0.0' } },
});
assert.equal(absent.status, 200);
} finally {
envManager.restore();
}
}, results);

await testFunction('explicit origins replace loopback defaults and invalid Origin wins over hardened authentication', async () => {
envManager.set('MCP_HTTP_ALLOWED_ORIGINS', 'https://app.example.com');

try {
const explicitApp = await createHttpServer(() => createTestMcpServer(), 43123);
const loopback = await request(explicitApp).post('/mcp')
.set('Origin', 'http://localhost:43123');
assert.equal(loopback.status, 403);
const explicitOptions = await request(explicitApp).options('/mcp')
.set('Origin', 'https://app.example.com')
.set('Access-Control-Request-Method', 'POST');
assert.equal(explicitOptions.status, 204);
assert.equal(explicitOptions.headers['access-control-allow-origin'], 'https://app.example.com');
assert.match(explicitOptions.headers.vary || '', /Origin/);

const explicitPost = await request(explicitApp).post('/mcp')
.set('Origin', 'https://app.example.com')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json, text/event-stream')
.send({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'explicit-origin-test', version: '1.0.0' } },
});
assert.equal(explicitPost.status, 200);
assert.equal(explicitPost.headers['access-control-allow-origin'], 'https://app.example.com');
assert.match(explicitPost.headers.vary || '', /Origin/);

envManager.set('MCP_HTTP_HARDEN', 'true');
envManager.set('MCP_HTTP_AUTH_TOKEN', 'secret-token');
const hardenedApp = await createHttpServer(() => createTestMcpServer());
for (const authorization of [undefined, 'Bearer wrong-token']) {
const invalidOrigin = await request(hardenedApp).post('/mcp')
.set('Origin', 'https://attacker.example')
.set('Content-Type', 'application/json')
.set(authorization === undefined ? {} : { Authorization: authorization })
.send('{"jsonrpc":"2.0"');
assert.equal(invalidOrigin.status, 403);
assert.deepEqual(invalidOrigin.body, {
jsonrpc: '2.0',
error: { code: -32000, message: 'Invalid Origin header' },
id: null,
});
}
} finally {
envManager.restore();
}
}, results);

await testFunction('hardened mode keeps GET /health unauthenticated with fixed metadata', async () => {
Expand All @@ -276,29 +474,20 @@ async function runTests() {
assert.equal(res.body.server, 'ihor-sokoliuk/mcp-searxng');
assert.equal(res.body.transport, 'http');
assert.equal(typeof res.body.version, 'string');
assert.equal(res.headers['access-control-allow-origin'], undefined);
assert.match(res.headers.vary || '', /Origin/);

const allowedHealth = await request(app)
.get('/health')
.set('Origin', 'https://app.example.com');
assert.equal(allowedHealth.status, 200);
assert.equal(allowedHealth.headers['access-control-allow-origin'], 'https://app.example.com');
assert.match(allowedHealth.headers.vary || '', /Origin/);
} finally {
envManager.restore();
}
}, results);

await testFunction('CORS allows expected headers', async () => {
const app = await createHttpServer(() => createTestMcpServer());
const res = await request(app)
.options('/mcp')
.set('Origin', 'http://example.com')
.set('Access-Control-Request-Method', 'POST')
.set('Access-Control-Request-Headers', 'Content-Type, mcp-session-id, authorization, mcp-protocol-version');

assert.equal(res.status, 204, 'OPTIONS preflight should succeed');
const allowHeaders = (res.headers['access-control-allow-headers'] || '').toLowerCase();
const expected = ['content-type', 'mcp-session-id', 'authorization', 'mcp-protocol-version'];
for (const header of expected) {
assert.ok(
allowHeaders.includes(header),
`Expected '${header}' in Access-Control-Allow-Headers, got: ${allowHeaders}`
);
}
}, results);

await testFunction('stateless POST limiter selection uses the request body and ignores session headers', async () => {
envManager.set('MCP_HTTP_STATELESS', 'true');
Expand Down
Loading