Files
mcpctl/src/mcpd/tests/security.test.ts

477 lines
18 KiB
TypeScript
Raw Normal View History

/**
* Security tests for mcpd.
*
* Tests for identified security issues:
* 1. audit-events endpoint bypasses RBAC (mapUrlToPermission returns 'skip')
* 2. x-service-account header impersonation (any authenticated user can set it)
* 3. MCP proxy maps to wrong RBAC action (POST 'create' instead of 'run')
* 4. externalUrl has no scheme/destination restriction (SSRF)
* 5. MCP proxy has no input validation on method/serverId
* 6. RBAC list filtering only checks 'name' field
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { registerMcpProxyRoutes } from '../src/routes/mcp-proxy.js';
import type { McpProxyRouteDeps } from '../src/routes/mcp-proxy.js';
import { registerAuditEventRoutes } from '../src/routes/audit-events.js';
import { AuditEventService } from '../src/services/audit-event.service.js';
import type { IAuditEventRepository } from '../src/repositories/interfaces.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import { CreateMcpServerSchema } from '../src/validation/mcp-server.schema.js';
// ─────────────────────────────────────────────────────────
// § 1 audit-events endpoint bypasses RBAC
// ─────────────────────────────────────────────────────────
/**
* Reproduce mapUrlToPermission from main.ts to test which URLs
* get RBAC checks and which are skipped.
*/
type PermissionCheck =
| { kind: 'resource'; resource: string; action: string; resourceName?: string }
| { kind: 'operation'; operation: string }
| { kind: 'skip' };
function mapUrlToPermission(method: string, url: string): PermissionCheck {
const match = url.match(/^\/api\/v1\/([a-z-]+)/);
if (!match) return { kind: 'skip' };
const segment = match[1] as string;
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
const resourceMap: Record<string, string | undefined> = {
'servers': 'servers',
'instances': 'instances',
'secrets': 'secrets',
'projects': 'projects',
'templates': 'templates',
'users': 'users',
'groups': 'groups',
'rbac': 'rbac',
'audit-logs': 'rbac',
'mcp': 'servers',
'prompts': 'prompts',
'promptrequests': 'promptrequests',
};
const resource = resourceMap[segment];
if (resource === undefined) return { kind: 'skip' };
let action: string;
switch (method) {
case 'GET':
case 'HEAD':
action = 'view';
break;
case 'POST':
action = 'create';
break;
case 'DELETE':
action = 'delete';
break;
default:
action = 'edit';
break;
}
const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/);
const resourceName = nameMatch?.[1];
const check: PermissionCheck = { kind: 'resource', resource, action };
if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName;
return check;
}
describe('Security: RBAC coverage gaps in mapUrlToPermission', () => {
it('audit-events endpoint is NOT in resourceMap — bypasses RBAC', () => {
// This documents a known security issue: any authenticated user can query
// all audit events regardless of their RBAC permissions
const check = mapUrlToPermission('GET', '/api/v1/audit-events');
// Currently returns 'skip' — this is the bug
expect(check.kind).toBe('skip');
});
it('audit-events POST (batch insert) also bypasses RBAC', () => {
const check = mapUrlToPermission('POST', '/api/v1/audit-events');
expect(check.kind).toBe('skip');
});
it('audit-events by ID also bypasses RBAC', () => {
const check = mapUrlToPermission('GET', '/api/v1/audit-events/some-cuid');
expect(check.kind).toBe('skip');
});
it('all known resource endpoints DO have RBAC coverage', () => {
const coveredEndpoints = [
'servers', 'instances', 'secrets', 'projects', 'templates',
'users', 'groups', 'rbac', 'audit-logs', 'prompts', 'promptrequests',
];
for (const endpoint of coveredEndpoints) {
const check = mapUrlToPermission('GET', `/api/v1/${endpoint}`);
expect(check.kind, `${endpoint} should have RBAC check`).not.toBe('skip');
}
});
it('MCP proxy maps POST to servers:create instead of servers:run', () => {
// /api/v1/mcp/proxy is a POST that executes tools — semantically this is
// a 'run' action, but mapUrlToPermission maps POST → 'create'
const check = mapUrlToPermission('POST', '/api/v1/mcp/proxy');
expect(check.kind).toBe('resource');
if (check.kind === 'resource') {
expect(check.resource).toBe('servers');
// BUG: should be 'run' for executing tools, not 'create'
expect(check.action).toBe('create');
}
});
it('non-api URLs correctly return skip', () => {
expect(mapUrlToPermission('GET', '/healthz').kind).toBe('skip');
expect(mapUrlToPermission('GET', '/health').kind).toBe('skip');
expect(mapUrlToPermission('GET', '/').kind).toBe('skip');
});
});
// ─────────────────────────────────────────────────────────
// § 2 x-service-account header impersonation
// ─────────────────────────────────────────────────────────
describe('Security: x-service-account header impersonation', () => {
// This test documents that any authenticated user can impersonate service accounts
// by setting the x-service-account header. The RBAC service trusts this header
// and adds the service account's permissions to the user's permissions.
it('x-service-account header is passed to RBAC without verification', () => {
// The RBAC service's getPermissions() accepts serviceAccountName directly.
// In main.ts, the value comes from: request.headers['x-service-account']
// There is no validation that the authenticated user IS the service account,
// or that the user is authorized to act as that service account.
//
// Attack scenario:
// 1. Attacker authenticates as regular user (low-privilege)
// 2. Sends request with header: x-service-account: project:admin
// 3. RBAC service treats them as having the service account's bindings
// 4. Attacker gets elevated permissions
// We verify this by examining the RBAC service code path:
// In rbac.service.ts line 144:
// if (s.kind === 'ServiceAccount') return serviceAccountName !== undefined && s.name === serviceAccountName;
// This matches ANY request with the right header value — no ownership check.
expect(true).toBe(true); // Structural documentation test
});
});
// ─────────────────────────────────────────────────────────
// § 3 MCP proxy input validation
// ─────────────────────────────────────────────────────────
describe('Security: MCP proxy input validation', () => {
let app: FastifyInstance;
afterEach(async () => {
if (app) await app.close();
});
function buildApp() {
const mcpProxyService = {
execute: vi.fn(async () => ({
jsonrpc: '2.0' as const,
id: 1,
result: { tools: [] },
})),
};
const auditLogService = {
create: vi.fn(async () => ({ id: 'log-1' })),
};
const authDeps = {
findSession: vi.fn(async () => ({
userId: 'user-1',
expiresAt: new Date(Date.now() + 3600_000),
})),
};
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
registerMcpProxyRoutes(app, {
mcpProxyService,
auditLogService,
authDeps,
} as unknown as McpProxyRouteDeps);
return { mcpProxyService, auditLogService };
}
it('accepts arbitrary method strings (no allowlist)', async () => {
// Any JSON-RPC method is forwarded to upstream servers without validation.
// An attacker could send methods like 'shutdown', 'admin/reset', etc.
const { mcpProxyService } = buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/v1/mcp/proxy',
payload: {
serverId: 'srv-1',
method: 'dangerous/admin_shutdown',
params: {},
},
headers: { authorization: 'Bearer valid-token' },
});
// Request succeeds — method is forwarded without validation
expect(res.statusCode).toBe(200);
expect(mcpProxyService.execute).toHaveBeenCalledWith({
serverId: 'srv-1',
method: 'dangerous/admin_shutdown',
params: {},
});
});
it('accepts empty method string', async () => {
const { mcpProxyService } = buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/v1/mcp/proxy',
payload: {
serverId: 'srv-1',
method: '',
params: {},
},
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(200);
expect(mcpProxyService.execute).toHaveBeenCalledWith(
expect.objectContaining({ method: '' }),
);
});
it('no Zod schema validation on request body', async () => {
// The route destructures body without schema validation.
// Extra fields are silently accepted.
const { mcpProxyService } = buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/v1/mcp/proxy',
payload: {
serverId: 'srv-1',
method: 'tools/list',
params: {},
__proto__: { isAdmin: true },
extraField: 'injected',
},
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(200);
});
});
// ─────────────────────────────────────────────────────────
// § 4 externalUrl SSRF validation
// ─────────────────────────────────────────────────────────
describe('Security: externalUrl SSRF via CreateMcpServerSchema', () => {
it('accepts internal IP addresses (SSRF risk)', () => {
// externalUrl uses z.string().url() which validates format but not destination
const internalUrls = [
'http://169.254.169.254/latest/meta-data/', // AWS metadata
'http://metadata.google.internal/', // GCP metadata
'http://100.100.100.200/latest/meta-data/', // Alibaba Cloud metadata
'http://10.0.0.1/', // Private network
'http://192.168.1.1/', // Private network
'http://172.16.0.1/', // Private network
'http://127.0.0.1:3100/', // Localhost (mcpd itself!)
'http://[::1]:3100/', // IPv6 localhost
'http://0.0.0.0/', // All interfaces
];
for (const url of internalUrls) {
const result = CreateMcpServerSchema.safeParse({
name: 'test-server',
externalUrl: url,
});
// All currently pass validation — this is the SSRF vulnerability
expect(result.success, `${url} should be flagged but currently passes`).toBe(true);
}
});
it('accepts file:// URLs', () => {
const result = CreateMcpServerSchema.safeParse({
name: 'test-server',
externalUrl: 'file:///etc/passwd',
});
// z.string().url() validates format, and file:// is a valid URL scheme
// Whether this passes or fails depends on the Zod version's url() validator
// This test documents the current behavior
if (result.success) {
// If this passes, it's an additional SSRF vector
expect(result.data.externalUrl).toBe('file:///etc/passwd');
}
});
it('correctly validates URL format', () => {
const invalid = CreateMcpServerSchema.safeParse({
name: 'test-server',
externalUrl: 'not-a-url',
});
expect(invalid.success).toBe(false);
});
});
// ─────────────────────────────────────────────────────────
// § 5 Audit events route — unauthenticated batch insert
// ─────────────────────────────────────────────────────────
describe('Security: audit-events batch insert has no auth in route definition', () => {
let app: FastifyInstance;
let repo: IAuditEventRepository;
beforeEach(async () => {
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
repo = {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
createMany: vi.fn(async (events: unknown[]) => events.length),
count: vi.fn(async () => 0),
};
const service = new AuditEventService(repo);
registerAuditEventRoutes(app, service);
await app.ready();
});
afterEach(async () => {
if (app) await app.close();
});
it('batch insert accepts events without authentication at route level', async () => {
// The route itself has no preHandler auth middleware (unlike mcp-proxy).
// Auth is only applied via the global hook in main.ts.
// If registerAuditEventRoutes is used outside of main.ts's global hook setup,
// audit events can be inserted without auth.
const res = await app.inject({
method: 'POST',
url: '/api/v1/audit/events',
payload: [
{
timestamp: new Date().toISOString(),
sessionId: 'fake-session',
projectName: 'injected-project',
eventKind: 'gate_decision',
source: 'attacker',
verified: true, // Attacker can claim verified=true
payload: { trigger: 'fake', intent: 'malicious' },
},
],
});
// Without global auth hook, this succeeds
expect(res.statusCode).toBe(201);
expect(repo.createMany).toHaveBeenCalled();
});
it('attacker can inject events with verified=true (no server-side enforcement)', async () => {
// The verified flag is accepted from the client without validation.
// mcplocal (which runs on untrusted user devices) sends verified=true for its events.
// An attacker could inject fake "verified" events to pollute the audit trail.
const res = await app.inject({
method: 'POST',
url: '/api/v1/audit/events',
payload: [
{
timestamp: new Date().toISOString(),
sessionId: 'attacker-session',
projectName: 'target-project',
eventKind: 'gate_decision',
source: 'mcpd', // Impersonate mcpd as source
verified: true, // Claim it's verified
payload: { trigger: 'begin_session', intent: 'legitimate looking' },
},
],
});
expect(res.statusCode).toBe(201);
// Verify the event was stored with attacker-controlled values
const storedEvents = (repo.createMany as ReturnType<typeof vi.fn>).mock.calls[0]![0] as Array<Record<string, unknown>>;
expect(storedEvents[0]).toMatchObject({
source: 'mcpd',
verified: true,
});
});
it('attacker can inject events for any project', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/audit/events',
payload: [
{
timestamp: new Date().toISOString(),
sessionId: 'attacker-session',
projectName: 'production-sensitive-project',
eventKind: 'tool_call_trace',
source: 'mcplocal',
verified: true,
payload: { toolName: 'legitimate_tool' },
},
],
});
expect(res.statusCode).toBe(201);
});
});
// ─────────────────────────────────────────────────────────
// § 6 RBAC list filtering only checks 'name' field
// ─────────────────────────────────────────────────────────
describe('Security: RBAC list filtering gaps', () => {
it('preSerialization hook only filters by name field', () => {
// From main.ts lines 390-397:
// The hook filters array responses by checking item['name'].
// Resources without a 'name' field pass through unfiltered.
//
// Affected resources:
// - AuditEvent (has no 'name' field → never filtered)
// - AuditLog (has no 'name' field → never filtered)
// - Any future resource without a 'name' field
// Simulate the filtering logic
const payload = [
{ id: '1', name: 'allowed-server', description: 'visible' },
{ id: '2', name: 'forbidden-server', description: 'should be hidden' },
{ id: '3', description: 'no name field — passes through' },
];
const rbacScope = { wildcard: false, names: new Set(['allowed-server']) };
// Apply the filtering logic from main.ts
const filtered = payload.filter((item) => {
const name = item['name' as keyof typeof item];
return typeof name === 'string' && rbacScope.names.has(name);
});
// Items with matching name are included
expect(filtered).toHaveLength(1);
expect(filtered[0]!.name).toBe('allowed-server');
// BUG: Items without a name field are EXCLUDED, not leaked through.
// Actually re-reading: typeof undefined === 'undefined', so the filter
// returns false for items without name. This means nameless items are
// EXCLUDED when rbacScope is active — which may cause audit events to
// disappear from filtered responses. Not a leak, but a usability issue.
});
it('wildcard scope bypasses all filtering', () => {
const rbacScope = { wildcard: true, names: new Set<string>() };
// When wildcard is true, the hook returns payload as-is
// This is correct behavior — wildcard means "see everything"
expect(rbacScope.wildcard).toBe(true);
});
});