docs: add design for remote IP access feature
Allows configuring non-localhost IPs/CIDRs for remote MCP access, enabling use cases like Obsidian in Docker over Tailscale.
This commit is contained in:
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
|
||||||
Reference in New Issue
Block a user