feat: configure network access policies through environment
This commit is contained in:
1 parent
91365ee315
commit
265f28e16d
17 files changed
+456
-58
No files matched your search
@@ -0,0 +1,30 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isAllowedMcpHost } from '../src/mcp/hosts';
|
||||
|
||||
const resource = new URL('https://worthpath.example/mcp');
|
||||
|
||||
test('default Host protection preserves canonical and development hosts', () => {
|
||||
assert.equal(isAllowedMcpHost('worthpath.example', resource, undefined, true), true);
|
||||
const local = new URL('http://localhost:3100/mcp');
|
||||
assert.equal(isAllowedMcpHost('127.0.0.1:3100', local, undefined, false), true);
|
||||
assert.equal(isAllowedMcpHost('localhost:3100', local, '', false), true);
|
||||
assert.equal(isAllowedMcpHost('127.0.0.1:3100', local, '', true), false);
|
||||
assert.equal(isAllowedMcpHost('198.18.0.1:3100', resource, undefined, false), false);
|
||||
});
|
||||
|
||||
test('explicit Host list replaces defaults and matches exact ports', () => {
|
||||
const configured = ' 198.18.0.1:3100, HOST.example:3100, [::1]:3100 ';
|
||||
for (const host of ['198.18.0.1:3100', 'host.example:3100', '[::1]:3100'])
|
||||
assert.equal(isAllowedMcpHost(host, resource, configured, true), true);
|
||||
for (const host of ['198.18.0.1:3101', 'worthpath.example', 'host.example:3100.evil'])
|
||||
assert.equal(isAllowedMcpHost(host, resource, configured, true), false);
|
||||
});
|
||||
|
||||
test('wildcard allows all hosts but still rejects a missing Host', () => {
|
||||
for (const host of ['198.18.0.1:3100', '192.168.1.2:3100', '[2001:db8::1]:3100', 'example.com'])
|
||||
assert.equal(isAllowedMcpHost(host, resource, '*', false), true);
|
||||
assert.equal(isAllowedMcpHost(undefined, resource, '*', false), false);
|
||||
assert.equal(isAllowedMcpHost('', resource, '*', false), false);
|
||||
assert.equal(isAllowedMcpHost('example.com', resource, '*.example.com', false), false);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AgentOAuth, urls, webLink } from '../src/mcp/oauth';
|
||||
|
||||
test('HTTP resources, web links and client redirects work beyond loopback in development', async () => {
|
||||
const before = { ...process.env };
|
||||
try {
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.MCP_PUBLIC_URL = 'http://192.0.2.10:3100/mcp';
|
||||
process.env.MCP_WEB_URL = 'http://192.0.2.10:5173';
|
||||
assert.equal(urls().issuer.href, 'http://192.0.2.10:3100/');
|
||||
assert.equal(new URL(webLink('agent_authorization', 'test-id')).pathname, '/agent/authorize');
|
||||
const db: any = { agentClient: { count: async () => 0, create: async () => ({}) } };
|
||||
const oauth = new AgentOAuth(db);
|
||||
await oauth.clientsStore.registerClient({
|
||||
redirect_uris: ['http://192.0.2.20:47891/callback'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
process.env.MCP_PUBLIC_URL = 'ftp://192.0.2.10/mcp';
|
||||
assert.throws(() => urls(), /HTTPS/);
|
||||
process.env.MCP_PUBLIC_URL = 'http://192.0.2.10:3100/mcp';
|
||||
process.env.MCP_WEB_URL = 'file:///tmp/test';
|
||||
assert.throws(() => urls(), /HTTPS/);
|
||||
await assert.rejects(() =>
|
||||
oauth.clientsStore.registerClient({
|
||||
redirect_uris: ['file:///tmp/test'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
for (const k of ['NODE_ENV', 'MCP_PUBLIC_URL', 'MCP_WEB_URL']) {
|
||||
if (before[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = before[k];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('production requires HTTPS for configured endpoints and rejects non-loopback HTTP callbacks', async () => {
|
||||
const before = { ...process.env };
|
||||
try {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.MCP_PUBLIC_URL = 'http://192.0.2.10:3100/mcp';
|
||||
process.env.MCP_WEB_URL = 'https://worthpath.example';
|
||||
assert.throws(() => urls(), /HTTPS/);
|
||||
process.env.MCP_PUBLIC_URL = 'https://api.worthpath.example/mcp';
|
||||
process.env.MCP_WEB_URL = 'http://192.0.2.10:5173';
|
||||
assert.throws(() => urls(), /HTTPS/);
|
||||
process.env.MCP_WEB_URL = 'https://worthpath.example';
|
||||
assert.equal(urls().web.protocol, 'https:');
|
||||
const oauth = new AgentOAuth({
|
||||
agentClient: { count: async () => 0, create: async () => ({}) },
|
||||
} as any);
|
||||
await assert.rejects(() =>
|
||||
oauth.clientsStore.registerClient({
|
||||
redirect_uris: ['http://192.0.2.20:47891/callback'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
}),
|
||||
);
|
||||
await oauth.clientsStore.registerClient({
|
||||
redirect_uris: ['http://127.0.0.1:47891/callback'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
} finally {
|
||||
for (const k of ['NODE_ENV', 'MCP_PUBLIC_URL', 'MCP_WEB_URL']) {
|
||||
if (before[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = before[k];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { networkConfig, isNetworkOriginAllowed } from '../src/network';
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -452,7 +453,7 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
|
||||
401,
|
||||
);
|
||||
const originDenied = await fetch(resource, {
|
||||
method: 'POST',
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://evil.invalid',
|
||||
Authorization: 'Bearer ' + a.token,
|
||||
@@ -460,7 +461,13 @@ test('official SDK Streamable HTTP: real MySQL full business paths, drafts, isol
|
||||
},
|
||||
body: '{}',
|
||||
});
|
||||
assert.equal(originDenied.status, 403);
|
||||
const originAllowed = isNetworkOriginAllowed(
|
||||
'https://evil.invalid',
|
||||
process.env.MCP_ALLOWED_ORIGINS ||
|
||||
new URL(process.env.MCP_WEB_URL || 'http://localhost:5173').origin,
|
||||
networkConfig().allowWildcardOrigins,
|
||||
);
|
||||
assert.equal(originDenied.status, originAllowed ? 204 : 403);
|
||||
} finally {
|
||||
for (const c of clients) await c.close().catch(() => {});
|
||||
await db.user.deleteMany({ where: { id: { in: users } } });
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { networkConfig, isNetworkOriginAllowed, isNetworkHostAllowed } from '../src/network';
|
||||
test('network switches accept explicit deployment policy and validate cookie requirements', () => {
|
||||
const keys = [
|
||||
'NODE_ENV',
|
||||
'NETWORK_ALLOW_HTTP',
|
||||
'NETWORK_ALLOW_HTTP_REDIRECTS',
|
||||
'NETWORK_ALLOW_WILDCARD_ORIGINS',
|
||||
'NETWORK_REQUIRE_SECURE_COOKIE',
|
||||
'NETWORK_HSTS',
|
||||
'NETWORK_UPGRADE_INSECURE_REQUESTS',
|
||||
'COOKIE_SECURE',
|
||||
'COOKIE_SAME_SITE',
|
||||
];
|
||||
const before = { ...process.env };
|
||||
try {
|
||||
for (const k of keys) delete process.env[k];
|
||||
process.env.NODE_ENV = 'production';
|
||||
assert.equal(networkConfig().allowHttp, false);
|
||||
assert.equal(networkConfig().requireSecureCookie, true);
|
||||
process.env.NETWORK_ALLOW_HTTP = 'true';
|
||||
process.env.NETWORK_ALLOW_WILDCARD_ORIGINS = 'true';
|
||||
process.env.NETWORK_ALLOW_HTTP_REDIRECTS = 'true';
|
||||
assert.equal(networkConfig().allowHttp, true);
|
||||
assert.equal(networkConfig().allowWildcardOrigins, true);
|
||||
assert.equal(networkConfig().allowHttpRedirects, true);
|
||||
process.env.COOKIE_SECURE = 'false';
|
||||
process.env.COOKIE_SAME_SITE = 'none';
|
||||
assert.throws(() => networkConfig(), /requires/);
|
||||
process.env.COOKIE_SAME_SITE = 'strict';
|
||||
process.env.NETWORK_ALLOW_HTTP = 'typo';
|
||||
assert.throws(() => networkConfig(), /true or false/);
|
||||
} finally {
|
||||
for (const k of keys) {
|
||||
if (before[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = before[k];
|
||||
}
|
||||
}
|
||||
});
|
||||
test('Host and Origin lists accept all networks or enforce exact configured entries', () => {
|
||||
assert.equal(isNetworkOriginAllowed('http://192.0.2.15:5173', '*', true), true);
|
||||
assert.equal(isNetworkOriginAllowed('https://elsewhere.example', '*', false), false);
|
||||
assert.equal(isNetworkOriginAllowed('null', '*', true), false);
|
||||
assert.equal(isNetworkOriginAllowed('file:///tmp', '*', true), false);
|
||||
assert.equal(
|
||||
isNetworkOriginAllowed('http://a.example', 'http://a.example, https://b.example', false),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNetworkOriginAllowed('http://a.example.evil', 'http://a.example', false), false);
|
||||
assert.equal(isNetworkHostAllowed('192.0.2.15:3100', '*'), true);
|
||||
assert.equal(isNetworkHostAllowed('example.com:3100', 'example.com:3100'), true);
|
||||
assert.equal(isNetworkHostAllowed('example.com:3101', 'example.com:3100'), false);
|
||||
assert.equal(isNetworkHostAllowed(undefined, '*'), false);
|
||||
});
|
||||
Reference in new issue
Block a user