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> {
|
public async start(): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
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();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,24 @@ import express from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { MCPServerSettings } from '../types/settings-types';
|
import { MCPServerSettings } from '../types/settings-types';
|
||||||
import { ErrorCodes, JSONRPCResponse } from '../types/mcp-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 {
|
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
|
// Parse JSON bodies
|
||||||
app.use(express.json());
|
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 = {
|
const corsOptions = {
|
||||||
origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
|
origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
|
||||||
// Allow requests with no origin (like CLI clients, curl, MCP SDKs)
|
// 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
|
// Allow localhost and 127.0.0.1 on any port, both HTTP and HTTPS
|
||||||
const localhostRegex = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
|
const localhostRegex = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
|
||||||
if (localhostRegex.test(origin)) {
|
if (localhostRegex.test(origin)) {
|
||||||
callback(null, true);
|
return callback(null, true);
|
||||||
} else {
|
|
||||||
callback(new Error('Not allowed by CORS'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
credentials: true
|
||||||
};
|
};
|
||||||
@@ -44,15 +68,27 @@ export function setupMiddleware(app: Express, settings: MCPServerSettings, creat
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Origin validation for security (DNS rebinding protection)
|
// Host header validation for security (DNS rebinding protection)
|
||||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
const host = req.headers.host;
|
const host = req.headers.host;
|
||||||
|
|
||||||
// Only allow localhost connections
|
if (!host) {
|
||||||
if (host && !host.startsWith('localhost') && !host.startsWith('127.0.0.1')) {
|
return next();
|
||||||
return res.status(403).json(createErrorResponse(null, ErrorCodes.InvalidRequest, 'Only localhost connections allowed'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 statusEl = containerEl.createEl('div', {cls: 'mcp-server-status'});
|
||||||
const isRunning = this.plugin.mcpServer?.isRunning() ?? false;
|
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', {
|
statusEl.createEl('p', {
|
||||||
text: isRunning
|
text: isRunning
|
||||||
? `✅ Running on http://127.0.0.1:${this.plugin.settings.port}/mcp`
|
? `✅ Running on http://${bindAddress}:${this.plugin.settings.port}/mcp`
|
||||||
: '⭕ Stopped'
|
: '⭕ 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)
|
// Authentication (Always Enabled)
|
||||||
const authDetails = containerEl.createEl('details', {cls: 'mcp-auth-section'});
|
const authDetails = containerEl.createEl('details', {cls: 'mcp-auth-section'});
|
||||||
const authSummary = authDetails.createEl('summary', {cls: 'mcp-auth-summary'});
|
const authSummary = authDetails.createEl('summary', {cls: 'mcp-auth-summary'});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface MCPServerSettings {
|
|||||||
port: number;
|
port: number;
|
||||||
apiKey: string; // Now required, not optional
|
apiKey: string; // Now required, not optional
|
||||||
enableAuth: boolean; // Will be removed in future, kept for migration
|
enableAuth: boolean; // Will be removed in future, kept for migration
|
||||||
|
allowedIPs: string; // Comma-separated IPs/CIDRs allowed to connect remotely
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
@@ -20,6 +21,7 @@ export const DEFAULT_SETTINGS: MCPPluginSettings = {
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
apiKey: '', // Will be auto-generated on first load
|
apiKey: '', // Will be auto-generated on first load
|
||||||
enableAuth: true, // Always true now
|
enableAuth: true, // Always true now
|
||||||
|
allowedIPs: '', // Empty = localhost only
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
// Notification defaults
|
// Notification defaults
|
||||||
notificationsEnabled: false,
|
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