diff --git a/src/server/mcp-server.ts b/src/server/mcp-server.ts index 156882f..874f705 100644 --- a/src/server/mcp-server.ts +++ b/src/server/mcp-server.ts @@ -101,7 +101,8 @@ export class MCPServer { public async start(): Promise { return new Promise((resolve, reject) => { try { - this.server = this.app.listen(this.settings.port, '127.0.0.1', () => { + const bindAddress = this.settings.allowedIPs?.trim() ? '0.0.0.0' : '127.0.0.1'; + this.server = this.app.listen(this.settings.port, bindAddress, () => { resolve(); }); diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 234bc65..9c52cba 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -3,12 +3,24 @@ import express from 'express'; import cors from 'cors'; import { MCPServerSettings } from '../types/settings-types'; import { ErrorCodes, JSONRPCResponse } from '../types/mcp-types'; +import { parseAllowedIPs, isIPAllowed } from '../utils/network-utils'; export function setupMiddleware(app: Express, settings: MCPServerSettings, createErrorResponse: (id: string | number | null, code: number, message: string) => JSONRPCResponse): void { + const allowList = parseAllowedIPs(settings.allowedIPs); + // Parse JSON bodies app.use(express.json()); - // CORS configuration - Always enabled with fixed localhost-only policy + // Source IP validation - reject connections from unlisted IPs before any other checks + app.use((req: Request, res: Response, next: NextFunction) => { + const remoteAddress = req.socket.remoteAddress; + if (remoteAddress && !isIPAllowed(remoteAddress, allowList)) { + return res.status(403).json(createErrorResponse(null, ErrorCodes.InvalidRequest, 'Connection from this IP is not allowed')); + } + next(); + }); + + // CORS configuration const corsOptions = { origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { // Allow requests with no origin (like CLI clients, curl, MCP SDKs) @@ -19,10 +31,22 @@ export function setupMiddleware(app: Express, settings: MCPServerSettings, creat // Allow localhost and 127.0.0.1 on any port, both HTTP and HTTPS const localhostRegex = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/; if (localhostRegex.test(origin)) { - callback(null, true); - } else { - callback(new Error('Not allowed by CORS')); + return callback(null, true); } + + // Check if origin hostname is in the allow-list + if (allowList.length > 0) { + try { + const url = new URL(origin); + if (isIPAllowed(url.hostname, allowList)) { + return callback(null, true); + } + } catch { + // Invalid origin URL, fall through to reject + } + } + + callback(new Error('Not allowed by CORS')); }, credentials: true }; @@ -44,15 +68,27 @@ export function setupMiddleware(app: Express, settings: MCPServerSettings, creat next(); }); - // Origin validation for security (DNS rebinding protection) + // Host header validation for security (DNS rebinding protection) app.use((req: Request, res: Response, next: NextFunction) => { const host = req.headers.host; - // Only allow localhost connections - if (host && !host.startsWith('localhost') && !host.startsWith('127.0.0.1')) { - return res.status(403).json(createErrorResponse(null, ErrorCodes.InvalidRequest, 'Only localhost connections allowed')); + if (!host) { + return next(); } - next(); + // Strip port from host header + const hostname = host.split(':')[0]; + + // Always allow localhost + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return next(); + } + + // Check against allow-list + if (allowList.length > 0 && isIPAllowed(hostname, allowList)) { + return next(); + } + + return res.status(403).json(createErrorResponse(null, ErrorCodes.InvalidRequest, 'Connection from this host is not allowed')); }); } diff --git a/src/settings.ts b/src/settings.ts index 16c5b0a..1d9f41c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -138,9 +138,10 @@ export class MCPServerSettingTab extends PluginSettingTab { const statusEl = containerEl.createEl('div', {cls: 'mcp-server-status'}); const isRunning = this.plugin.mcpServer?.isRunning() ?? false; + const bindAddress = this.plugin.settings.allowedIPs?.trim() ? '0.0.0.0' : '127.0.0.1'; statusEl.createEl('p', { text: isRunning - ? `✅ Running on http://127.0.0.1:${this.plugin.settings.port}/mcp` + ? `✅ Running on http://${bindAddress}:${this.plugin.settings.port}/mcp` : '⭕ Stopped' }); @@ -203,6 +204,29 @@ export class MCPServerSettingTab extends PluginSettingTab { } })); + // Allowed IPs setting + new Setting(containerEl) + .setName('Allowed IPs') + .setDesc('Comma-separated IPs or CIDR ranges allowed to connect remotely (e.g., 100.64.0.0/10, 192.168.1.50). Leave empty for localhost only. Restart required.') + .addText(text => text + .setPlaceholder('100.64.0.0/10, 192.168.1.0/24') + .setValue(this.plugin.settings.allowedIPs) + .onChange(async (value) => { + this.plugin.settings.allowedIPs = value; + await this.plugin.saveSettings(); + if (this.plugin.mcpServer?.isRunning()) { + new Notice('⚠️ Server restart required for allowed IPs changes to take effect'); + } + })); + + // Security note when remote access is enabled + if (this.plugin.settings.allowedIPs?.trim()) { + const securityNote = containerEl.createEl('div', {cls: 'mcp-security-note'}); + securityNote.createEl('p', { + text: '⚠️ Server is accessible from non-localhost IPs. Ensure your API key is kept secure.' + }); + } + // Authentication (Always Enabled) const authDetails = containerEl.createEl('details', {cls: 'mcp-auth-section'}); const authSummary = authDetails.createEl('summary', {cls: 'mcp-auth-summary'}); diff --git a/src/types/settings-types.ts b/src/types/settings-types.ts index bdf5a94..4f330fa 100644 --- a/src/types/settings-types.ts +++ b/src/types/settings-types.ts @@ -3,6 +3,7 @@ export interface MCPServerSettings { port: number; apiKey: string; // Now required, not optional enableAuth: boolean; // Will be removed in future, kept for migration + allowedIPs: string; // Comma-separated IPs/CIDRs allowed to connect remotely } export interface NotificationSettings { @@ -20,6 +21,7 @@ export const DEFAULT_SETTINGS: MCPPluginSettings = { port: 3000, apiKey: '', // Will be auto-generated on first load enableAuth: true, // Always true now + allowedIPs: '', // Empty = localhost only autoStart: false, // Notification defaults notificationsEnabled: false, diff --git a/src/utils/network-utils.ts b/src/utils/network-utils.ts new file mode 100644 index 0000000..661055a --- /dev/null +++ b/src/utils/network-utils.ts @@ -0,0 +1,84 @@ +export interface AllowedIPEntry { + type: 'ip' | 'cidr'; + ip: number; // 32-bit numeric IPv4 + mask: number; // 32-bit subnet mask (only for CIDR) +} + +/** + * Convert dotted IPv4 string to 32-bit number. + * Returns null if invalid. + */ +function ipToNumber(ip: string): number | null { + const parts = ip.split('.'); + if (parts.length !== 4) return null; + let num = 0; + for (const part of parts) { + const octet = parseInt(part, 10); + if (isNaN(octet) || octet < 0 || octet > 255) return null; + num = (num << 8) | octet; + } + return num >>> 0; // Ensure unsigned +} + +/** + * Strip IPv4-mapped IPv6 prefix (::ffff:) if present. + */ +function normalizeIP(ip: string): string { + if (ip.startsWith('::ffff:')) { + return ip.slice(7); + } + return ip; +} + +/** + * Parse a comma-separated string of IPs and CIDRs into structured entries. + * Invalid entries are silently skipped. + */ +export function parseAllowedIPs(setting: string): AllowedIPEntry[] { + if (!setting || !setting.trim()) return []; + + const entries: AllowedIPEntry[] = []; + for (const raw of setting.split(',')) { + const trimmed = raw.trim(); + if (!trimmed) continue; + + if (trimmed.includes('/')) { + const [ipStr, prefixStr] = trimmed.split('/'); + const ip = ipToNumber(ipStr); + const prefix = parseInt(prefixStr, 10); + if (ip === null || isNaN(prefix) || prefix < 0 || prefix > 32) continue; + const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; + entries.push({ type: 'cidr', ip: (ip & mask) >>> 0, mask }); + } else { + const ip = ipToNumber(trimmed); + if (ip === null) continue; + entries.push({ type: 'ip', ip, mask: 0xFFFFFFFF }); + } + } + return entries; +} + +/** + * Check if an IP address is allowed by the given allow-list. + * Localhost (127.0.0.1) is always allowed. + */ +export function isIPAllowed(ip: string, allowList: AllowedIPEntry[]): boolean { + const normalized = normalizeIP(ip); + + // Localhost is always allowed + if (normalized === '127.0.0.1' || normalized === 'localhost') return true; + + if (allowList.length === 0) return false; + + const num = ipToNumber(normalized); + if (num === null) return false; + + for (const entry of allowList) { + if (entry.type === 'ip') { + if (num === entry.ip) return true; + } else { + if (((num & entry.mask) >>> 0) === entry.ip) return true; + } + } + return false; +} diff --git a/tests/network-utils.test.ts b/tests/network-utils.test.ts new file mode 100644 index 0000000..8645489 --- /dev/null +++ b/tests/network-utils.test.ts @@ -0,0 +1,141 @@ +/** + * Unit tests for network-utils + */ + +import { parseAllowedIPs, isIPAllowed, AllowedIPEntry } from '../src/utils/network-utils'; + +describe('parseAllowedIPs', () => { + test('should return empty array for empty string', () => { + expect(parseAllowedIPs('')).toEqual([]); + }); + + test('should return empty array for whitespace-only string', () => { + expect(parseAllowedIPs(' ')).toEqual([]); + }); + + test('should parse a single IP', () => { + const result = parseAllowedIPs('192.168.1.1'); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('ip'); + }); + + test('should parse multiple comma-separated IPs', () => { + const result = parseAllowedIPs('192.168.1.1, 10.0.0.5'); + expect(result).toHaveLength(2); + }); + + test('should parse CIDR notation', () => { + const result = parseAllowedIPs('100.64.0.0/10'); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('cidr'); + }); + + test('should parse mixed IPs and CIDRs', () => { + const result = parseAllowedIPs('192.168.1.1, 10.0.0.0/8, 172.16.0.5'); + expect(result).toHaveLength(3); + expect(result[0].type).toBe('ip'); + expect(result[1].type).toBe('cidr'); + expect(result[2].type).toBe('ip'); + }); + + test('should handle extra whitespace', () => { + const result = parseAllowedIPs(' 192.168.1.1 , 10.0.0.5 '); + expect(result).toHaveLength(2); + }); + + test('should skip invalid entries', () => { + const result = parseAllowedIPs('192.168.1.1, invalid, 10.0.0.5'); + expect(result).toHaveLength(2); + }); + + test('should skip invalid CIDR prefix', () => { + const result = parseAllowedIPs('10.0.0.0/33'); + expect(result).toHaveLength(0); + }); + + test('should skip entries with invalid octets', () => { + const result = parseAllowedIPs('256.0.0.1'); + expect(result).toHaveLength(0); + }); + + test('should handle trailing commas', () => { + const result = parseAllowedIPs('192.168.1.1,'); + expect(result).toHaveLength(1); + }); +}); + +describe('isIPAllowed', () => { + test('should always allow 127.0.0.1 with empty list', () => { + expect(isIPAllowed('127.0.0.1', [])).toBe(true); + }); + + test('should always allow localhost with empty list', () => { + expect(isIPAllowed('localhost', [])).toBe(true); + }); + + test('should always allow IPv4-mapped localhost', () => { + expect(isIPAllowed('::ffff:127.0.0.1', [])).toBe(true); + }); + + test('should reject non-localhost with empty list', () => { + expect(isIPAllowed('192.168.1.1', [])).toBe(false); + }); + + test('should match exact IP', () => { + const allowList = parseAllowedIPs('192.168.1.50'); + expect(isIPAllowed('192.168.1.50', allowList)).toBe(true); + expect(isIPAllowed('192.168.1.51', allowList)).toBe(false); + }); + + test('should match CIDR range', () => { + const allowList = parseAllowedIPs('10.0.0.0/8'); + expect(isIPAllowed('10.0.0.1', allowList)).toBe(true); + expect(isIPAllowed('10.255.255.255', allowList)).toBe(true); + expect(isIPAllowed('11.0.0.1', allowList)).toBe(false); + }); + + test('should match Tailscale CGNAT range (100.64.0.0/10)', () => { + const allowList = parseAllowedIPs('100.64.0.0/10'); + expect(isIPAllowed('100.64.0.1', allowList)).toBe(true); + expect(isIPAllowed('100.100.50.25', allowList)).toBe(true); + expect(isIPAllowed('100.127.255.255', allowList)).toBe(true); + expect(isIPAllowed('100.128.0.0', allowList)).toBe(false); + expect(isIPAllowed('100.63.255.255', allowList)).toBe(false); + }); + + test('should handle IPv4-mapped IPv6 addresses', () => { + const allowList = parseAllowedIPs('192.168.1.50'); + expect(isIPAllowed('::ffff:192.168.1.50', allowList)).toBe(true); + expect(isIPAllowed('::ffff:192.168.1.51', allowList)).toBe(false); + }); + + test('should handle IPv4-mapped IPv6 with CIDR', () => { + const allowList = parseAllowedIPs('10.0.0.0/8'); + expect(isIPAllowed('::ffff:10.5.3.1', allowList)).toBe(true); + expect(isIPAllowed('::ffff:11.0.0.1', allowList)).toBe(false); + }); + + test('should match against multiple entries', () => { + const allowList = parseAllowedIPs('192.168.1.0/24, 10.0.0.5'); + expect(isIPAllowed('192.168.1.100', allowList)).toBe(true); + expect(isIPAllowed('10.0.0.5', allowList)).toBe(true); + expect(isIPAllowed('10.0.0.6', allowList)).toBe(false); + }); + + test('should handle /32 CIDR as single IP', () => { + const allowList = parseAllowedIPs('192.168.1.1/32'); + expect(isIPAllowed('192.168.1.1', allowList)).toBe(true); + expect(isIPAllowed('192.168.1.2', allowList)).toBe(false); + }); + + test('should handle /0 CIDR as allow-all', () => { + const allowList = parseAllowedIPs('0.0.0.0/0'); + expect(isIPAllowed('1.2.3.4', allowList)).toBe(true); + expect(isIPAllowed('255.255.255.255', allowList)).toBe(true); + }); + + test('should return false for invalid IP input', () => { + const allowList = parseAllowedIPs('10.0.0.0/8'); + expect(isIPAllowed('not-an-ip', allowList)).toBe(false); + }); +});