feat: add configurable IP allow-list for remote MCP access
Enable non-localhost connections by specifying allowed IPs/CIDRs in settings (e.g., 100.64.0.0/10 for Tailscale). Server auto-binds to 0.0.0.0 when remote IPs are configured, with three-layer validation (source IP, CORS, host header) plus mandatory Bearer token auth.
This commit is contained in:
@@ -101,7 +101,8 @@ export class MCPServer {
|
||||
public async start(): Promise<void> {
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'});
|
||||
|
||||
@@ -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,
|
||||
|
||||
84
src/utils/network-utils.ts
Normal file
84
src/utils/network-utils.ts
Normal file
@@ -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;
|
||||
}
|
||||
141
tests/network-utils.test.ts
Normal file
141
tests/network-utils.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user