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:
2026-02-06 20:28:45 -05:00
parent 8e97c2fef0
commit e91b9f6025
6 changed files with 299 additions and 11 deletions

View File

@@ -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();
});

View File

@@ -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'));
});
}

View File

@@ -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'});

View File

@@ -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,

View 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
View 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);
});
});