Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6393d9645 | |||
| e9584929a4 | |||
| e91b9f6025 | |||
| 8e97c2fef0 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-02-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Remote IP access**: New `allowedIPs` setting accepts comma-separated IPs and CIDR ranges (e.g., `100.64.0.0/10` for Tailscale) to allow non-localhost connections
|
||||||
|
- Server automatically binds to `0.0.0.0` when remote IPs are configured, otherwise stays on `127.0.0.1`
|
||||||
|
- Three-layer network validation: source IP check, CORS origin check, and host header validation
|
||||||
|
- Bearer token authentication remains mandatory for all connections
|
||||||
|
- Localhost is always implicitly allowed — cannot lock out local access
|
||||||
|
- IPv4-mapped IPv6 addresses (`::ffff:x.x.x.x`) handled transparently
|
||||||
|
- New `network-utils` module with CIDR parsing and IP matching (no external dependencies)
|
||||||
|
- Security warning displayed in settings when remote access is enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.2.0] - 2026-01-31
|
## [1.2.0] - 2026-01-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
87
docs/plans/2026-02-06-remote-ip-access-design.md
Normal file
87
docs/plans/2026-02-06-remote-ip-access-design.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Remote IP access for MCP server
|
||||||
|
|
||||||
|
Date: 2026-02-06
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The MCP server is hardcoded to localhost-only access (bind address, CORS, host header validation). This prevents use cases where Obsidian runs in a Docker container or remote machine and MCP clients connect over a Tailscale VPN or other private network.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### New setting: `allowedIPs`
|
||||||
|
|
||||||
|
A comma-separated string of IPs and CIDR ranges. Default: `""` (empty).
|
||||||
|
|
||||||
|
- When empty: server behaves exactly as today (binds to `127.0.0.1`, localhost-only)
|
||||||
|
- When populated: server binds to `0.0.0.0` and allows connections from listed IPs/CIDRs
|
||||||
|
- Localhost (`127.0.0.1`) is always implicitly allowed regardless of the setting
|
||||||
|
- Examples: `100.64.0.0/10`, `192.168.1.50`, `10.0.0.0/8`
|
||||||
|
|
||||||
|
### Middleware changes (src/server/middleware.ts)
|
||||||
|
|
||||||
|
Three layers are updated:
|
||||||
|
|
||||||
|
1. **Source IP validation (new)** - Checks `req.socket.remoteAddress` against the allow-list before auth. Rejects connections from unlisted IPs with 403. Localhost always passes.
|
||||||
|
|
||||||
|
2. **CORS policy update** - Extends the origin check to allow origins whose hostname matches the allow-list, in addition to the existing localhost regex.
|
||||||
|
|
||||||
|
3. **Host header validation update** - Extends to accept Host headers matching allowed IPs, in addition to localhost.
|
||||||
|
|
||||||
|
All three use a shared `isIPAllowed()` utility.
|
||||||
|
|
||||||
|
### Server bind (src/server/mcp-server.ts)
|
||||||
|
|
||||||
|
The `start()` method computes bind address dynamically:
|
||||||
|
- `allowedIPs` non-empty (trimmed) -> bind `0.0.0.0`
|
||||||
|
- `allowedIPs` empty -> bind `127.0.0.1` (current behavior)
|
||||||
|
|
||||||
|
### Network utilities (src/utils/network-utils.ts)
|
||||||
|
|
||||||
|
New file (~40 lines) exporting:
|
||||||
|
|
||||||
|
- `parseAllowedIPs(setting: string): AllowedIPEntry[]` - Parses comma-separated string into structured list of individual IPs and CIDR ranges
|
||||||
|
- `isIPAllowed(ip: string, allowList: AllowedIPEntry[]): boolean` - Checks if an IP matches any entry. Handles IPv4-mapped IPv6 addresses (`::ffff:x.x.x.x`) that Node.js uses for `req.socket.remoteAddress`
|
||||||
|
|
||||||
|
CIDR matching is standard bit arithmetic, no external dependencies needed.
|
||||||
|
|
||||||
|
### Settings UI (src/settings.ts)
|
||||||
|
|
||||||
|
New text field below the Port setting:
|
||||||
|
- Name: "Allowed IPs"
|
||||||
|
- Description: "Comma-separated IPs or CIDR ranges allowed to connect (e.g., 100.64.0.0/10, 192.168.1.50). Leave empty for localhost only. Restart required."
|
||||||
|
- Placeholder: `100.64.0.0/10, 192.168.1.0/24`
|
||||||
|
- Shows restart warning when changed while server is running
|
||||||
|
- Shows security note when non-empty: "Server is accessible from non-localhost IPs. Ensure your API key is kept secure."
|
||||||
|
|
||||||
|
Status display updates to show actual bind address (`0.0.0.0` vs `127.0.0.1`).
|
||||||
|
|
||||||
|
Generated client configs (Windsurf/Claude Code) stay as `127.0.0.1` - users adjust manually for remote access.
|
||||||
|
|
||||||
|
### Settings type (src/types/settings-types.ts)
|
||||||
|
|
||||||
|
Add `allowedIPs: string` to `MCPServerSettings` with default `""`.
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
- **Auth is still mandatory.** IP allow-list is defense-in-depth, not a replacement for Bearer token authentication.
|
||||||
|
- **Localhost always allowed.** Cannot accidentally lock out local access.
|
||||||
|
- **Empty default = current behavior.** Zero-change upgrade for existing users. Feature is opt-in.
|
||||||
|
- **Three-layer validation:** Source IP check + CORS + Host header validation + Bearer auth.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
New file `tests/network-utils.test.ts`:
|
||||||
|
- Individual IP match/mismatch
|
||||||
|
- CIDR range matching (e.g., `100.64.0.0/10` matches `100.100.1.1`)
|
||||||
|
- IPv4-mapped IPv6 handling (`::ffff:192.168.1.1`)
|
||||||
|
- Edge cases: empty string, malformed entries, extra whitespace
|
||||||
|
- Localhost always allowed regardless of list contents
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
1. `src/types/settings-types.ts` - Add `allowedIPs` field
|
||||||
|
2. `src/utils/network-utils.ts` - New file: CIDR parsing + IP matching
|
||||||
|
3. `src/server/middleware.ts` - Update CORS, host validation, add source IP check
|
||||||
|
4. `src/server/mcp-server.ts` - Dynamic bind address
|
||||||
|
5. `src/settings.ts` - New text field + security note
|
||||||
|
6. `tests/network-utils.test.ts` - New test file
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "mcp-server",
|
"id": "mcp-server",
|
||||||
"name": "MCP Server",
|
"name": "MCP Server",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"minAppVersion": "0.15.0",
|
"minAppVersion": "0.15.0",
|
||||||
"description": "Exposes vault operations via Model Context Protocol (MCP) over HTTP.",
|
"description": "Exposes vault operations via Model Context Protocol (MCP) over HTTP.",
|
||||||
"author": "William Ballou",
|
"author": "William Ballou",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mcp-server",
|
"name": "mcp-server",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"description": "MCP (Model Context Protocol) server plugin - exposes vault operations via HTTP",
|
"description": "MCP (Model Context Protocol) server plugin - exposes vault operations via HTTP",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,5 +4,7 @@
|
|||||||
"1.1.0": "0.15.0",
|
"1.1.0": "0.15.0",
|
||||||
"1.1.1": "0.15.0",
|
"1.1.1": "0.15.0",
|
||||||
"1.1.2": "0.15.0",
|
"1.1.2": "0.15.0",
|
||||||
"1.1.3": "0.15.0"
|
"1.1.3": "0.15.0",
|
||||||
|
"1.2.0": "0.15.0",
|
||||||
|
"1.3.0": "0.15.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user