mirror of
https://github.com/aaronsb/obsidian-mcp-plugin.git
synced 2026-07-22 06:45:14 +00:00
feat(network): network exposure classifier + tests (ADR-107)
Pure functions consumed by the bind-time guard, settings UI, and MCP initialize.instructions injector: classify(NetworkState) → Verdict, resolveListenHost(), resolveBindAxis(), normalizeBindInput(), classifyFromSettings(), agentInstructionsForVerdict(). Single source of truth for "how exposed is the current network configuration?". The 9-cell ADR-107 table maps (protocol × bind × certSource) → one of 🟢 ok / 🟡 warn / 🔴 jail. Custom-input normalization collapses loopback aliases (127.x.x.x / localhost / ::1) and wildcard aliases (0.0.0.0 / ::) to the corresponding explicit BindMode so the same intent expressed three ways converges to one state. 40 unit tests cover all 9 table cells, all normalization paths, the defensive empty-custom fallback, and the agent-instructions formatter.
This commit is contained in:
parent
5884ac492d
commit
ef4f6c0814
2 changed files with 361 additions and 0 deletions
178
src/utils/network-classifier.ts
Normal file
178
src/utils/network-classifier.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* ADR-107: Network exposure modes as a classified state machine.
|
||||
*
|
||||
* Pure functions consumed by server-bind code, settings UI, and the
|
||||
* MCP `initialize` instructions injector — single source of truth for
|
||||
* "how exposed is the current network configuration?"
|
||||
*/
|
||||
|
||||
export type BindMode = 'loopback' | 'all' | 'custom';
|
||||
|
||||
export type Protocol = 'http' | 'https';
|
||||
|
||||
export type CertSource = 'self' | 'user';
|
||||
|
||||
export type ResolvedBind = 'loopback' | 'all' | 'custom-loopback' | 'custom-other';
|
||||
|
||||
export interface NetworkState {
|
||||
protocol: Protocol;
|
||||
bind: ResolvedBind;
|
||||
certSource: CertSource;
|
||||
}
|
||||
|
||||
export type VerdictClass = 'ok' | 'warn' | 'jail';
|
||||
|
||||
export interface Verdict {
|
||||
class: VerdictClass;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const LOOPBACK_ALIASES = new Set(['127.0.0.1', 'localhost', '::1', '::ffff:127.0.0.1']);
|
||||
const WILDCARD_ALIASES = new Set(['0.0.0.0', '::']);
|
||||
const LOOPBACK_PREFIXES = ['127.'];
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
const h = host.trim().toLowerCase();
|
||||
if (LOOPBACK_ALIASES.has(h)) return true;
|
||||
return LOOPBACK_PREFIXES.some((p) => h.startsWith(p));
|
||||
}
|
||||
|
||||
function isWildcardHost(host: string): boolean {
|
||||
return WILDCARD_ALIASES.has(host.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user typed a loopback alias or wildcard into the custom field,
|
||||
* collapse it to the corresponding explicit mode. Returns the canonical
|
||||
* mode + the host to persist (blank for non-custom).
|
||||
*/
|
||||
export function normalizeBindInput(
|
||||
mode: BindMode,
|
||||
customHost: string
|
||||
): { mode: BindMode; customHost: string } {
|
||||
if (mode !== 'custom') return { mode, customHost: '' };
|
||||
const trimmed = customHost.trim();
|
||||
if (trimmed === '') return { mode: 'custom', customHost: '' };
|
||||
if (isLoopbackHost(trimmed)) return { mode: 'loopback', customHost: '' };
|
||||
if (isWildcardHost(trimmed)) return { mode: 'all', customHost: '' };
|
||||
return { mode: 'custom', customHost: trimmed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve settings to the actual host string passed to server.listen().
|
||||
*/
|
||||
export function resolveListenHost(mode: BindMode, customHost: string): string {
|
||||
switch (mode) {
|
||||
case 'all':
|
||||
return '0.0.0.0';
|
||||
case 'custom': {
|
||||
const trimmed = customHost.trim();
|
||||
return trimmed === '' ? '127.0.0.1' : trimmed;
|
||||
}
|
||||
case 'loopback':
|
||||
default:
|
||||
return '127.0.0.1';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve settings to the bind axis used by classify().
|
||||
* `custom` collapses to `custom-loopback` or `custom-other` based on
|
||||
* whether the typed host is a loopback alias.
|
||||
*/
|
||||
export function resolveBindAxis(mode: BindMode, customHost: string): ResolvedBind {
|
||||
if (mode === 'loopback') return 'loopback';
|
||||
if (mode === 'all') return 'all';
|
||||
const trimmed = customHost.trim();
|
||||
if (trimmed === '' || isLoopbackHost(trimmed)) return 'custom-loopback';
|
||||
return 'custom-other';
|
||||
}
|
||||
|
||||
/**
|
||||
* The full classification table from ADR-107.
|
||||
*/
|
||||
export function classify(state: NetworkState): Verdict {
|
||||
const { protocol, bind, certSource } = state;
|
||||
|
||||
if (protocol === 'http') {
|
||||
if (bind === 'loopback' || bind === 'custom-loopback') {
|
||||
return {
|
||||
class: 'ok',
|
||||
reason: 'HTTP on loopback — traffic never leaves this machine.'
|
||||
};
|
||||
}
|
||||
if (bind === 'all') {
|
||||
return {
|
||||
class: 'jail',
|
||||
reason:
|
||||
'HTTP bound to every interface. API key and vault content travel in cleartext to anyone on the network.'
|
||||
};
|
||||
}
|
||||
return {
|
||||
class: 'jail',
|
||||
reason:
|
||||
'HTTP bound to a non-loopback address. API key and vault content travel in cleartext on the wire.'
|
||||
};
|
||||
}
|
||||
|
||||
// protocol === 'https'
|
||||
if (bind === 'loopback' || bind === 'custom-loopback') {
|
||||
return {
|
||||
class: 'ok',
|
||||
reason: 'HTTPS on loopback — self-signed certificate is fine here.'
|
||||
};
|
||||
}
|
||||
|
||||
if (certSource === 'user') {
|
||||
return {
|
||||
class: 'ok',
|
||||
reason:
|
||||
bind === 'all'
|
||||
? 'HTTPS on every interface with a user-supplied certificate — the intended public deployment.'
|
||||
: 'HTTPS on a custom interface with a user-supplied certificate — the intended LAN/remote deployment.'
|
||||
};
|
||||
}
|
||||
|
||||
// self-signed on non-loopback
|
||||
return {
|
||||
class: 'warn',
|
||||
reason:
|
||||
bind === 'all'
|
||||
? 'HTTPS on every interface with a self-signed certificate — encrypted but clients must trust-on-first-use.'
|
||||
: 'HTTPS on a custom interface with a self-signed certificate — encrypted but clients must trust-on-first-use.'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: build NetworkState from settings + classify in one call.
|
||||
*/
|
||||
export function classifyFromSettings(args: {
|
||||
httpsEnabled: boolean;
|
||||
bindMode: BindMode;
|
||||
customBindHost: string;
|
||||
userSuppliedCert: boolean;
|
||||
}): Verdict {
|
||||
return classify({
|
||||
protocol: args.httpsEnabled ? 'https' : 'http',
|
||||
bind: resolveBindAxis(args.bindMode, args.customBindHost),
|
||||
certSource: args.userSuppliedCert ? 'user' : 'self'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent-visible warning copy injected into MCP initialize.instructions
|
||||
* when the verdict is `jail`. Returns null otherwise.
|
||||
*/
|
||||
export function agentInstructionsForVerdict(
|
||||
verdict: Verdict,
|
||||
resolvedHost: string,
|
||||
port: number
|
||||
): string | null {
|
||||
if (verdict.class !== 'jail') return null;
|
||||
return [
|
||||
'SECURITY WARNING: This Obsidian MCP server is configured to serve vault contents',
|
||||
`over an unencrypted network interface (${resolvedHost}:${port}). The API key and all`,
|
||||
'document text travel in cleartext over the network. If the user did not intend this,',
|
||||
'advise them to reconfigure the plugin to use HTTPS or to bind to loopback (127.0.0.1) only.'
|
||||
].join(' ');
|
||||
}
|
||||
183
tests/network-classifier.test.ts
Normal file
183
tests/network-classifier.test.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import {
|
||||
classify,
|
||||
classifyFromSettings,
|
||||
resolveListenHost,
|
||||
resolveBindAxis,
|
||||
normalizeBindInput,
|
||||
agentInstructionsForVerdict,
|
||||
NetworkState
|
||||
} from '../src/utils/network-classifier';
|
||||
|
||||
describe('classify() — 9-cell ADR-107 table', () => {
|
||||
const cases: Array<{ name: string; state: NetworkState; expected: 'ok' | 'warn' | 'jail' }> = [
|
||||
{ name: '1: http + loopback', state: { protocol: 'http', bind: 'loopback', certSource: 'self' }, expected: 'ok' },
|
||||
{ name: '2: http + custom-other', state: { protocol: 'http', bind: 'custom-other', certSource: 'self' }, expected: 'jail' },
|
||||
{ name: '3: http + all', state: { protocol: 'http', bind: 'all', certSource: 'self' }, expected: 'jail' },
|
||||
{ name: '4: https + loopback + self', state: { protocol: 'https', bind: 'loopback', certSource: 'self' }, expected: 'ok' },
|
||||
{ name: '5: https + loopback + user', state: { protocol: 'https', bind: 'loopback', certSource: 'user' }, expected: 'ok' },
|
||||
{ name: '6: https + custom-other + self',state: { protocol: 'https', bind: 'custom-other', certSource: 'self' }, expected: 'warn' },
|
||||
{ name: '7: https + custom-other + user',state: { protocol: 'https', bind: 'custom-other', certSource: 'user' }, expected: 'ok' },
|
||||
{ name: '8: https + all + self', state: { protocol: 'https', bind: 'all', certSource: 'self' }, expected: 'warn' },
|
||||
{ name: '9: https + all + user', state: { protocol: 'https', bind: 'all', certSource: 'user' }, expected: 'ok' }
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
test(c.name, () => {
|
||||
const v = classify(c.state);
|
||||
expect(v.class).toBe(c.expected);
|
||||
expect(v.reason).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
test('custom-loopback under http classifies as ok (not jail)', () => {
|
||||
expect(classify({ protocol: 'http', bind: 'custom-loopback', certSource: 'self' }).class).toBe('ok');
|
||||
});
|
||||
|
||||
test('custom-loopback under https + self classifies as ok (not warn)', () => {
|
||||
expect(classify({ protocol: 'https', bind: 'custom-loopback', certSource: 'self' }).class).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBindInput()', () => {
|
||||
test.each([
|
||||
['127.0.0.1', 'loopback'],
|
||||
['localhost', 'loopback'],
|
||||
['LOCALHOST', 'loopback'],
|
||||
['::1', 'loopback'],
|
||||
['::ffff:127.0.0.1', 'loopback'],
|
||||
['127.0.0.99', 'loopback'],
|
||||
[' 127.0.0.1 ', 'loopback']
|
||||
])('"%s" collapses to loopback mode', (input, expectedMode) => {
|
||||
const out = normalizeBindInput('custom', input);
|
||||
expect(out.mode).toBe(expectedMode);
|
||||
expect(out.customHost).toBe('');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['0.0.0.0', 'all'],
|
||||
['::', 'all'],
|
||||
[' 0.0.0.0', 'all']
|
||||
])('"%s" collapses to all mode', (input, expectedMode) => {
|
||||
const out = normalizeBindInput('custom', input);
|
||||
expect(out.mode).toBe(expectedMode);
|
||||
expect(out.customHost).toBe('');
|
||||
});
|
||||
|
||||
test('arbitrary LAN address stays custom and is trimmed', () => {
|
||||
const out = normalizeBindInput('custom', ' 192.168.1.50 ');
|
||||
expect(out.mode).toBe('custom');
|
||||
expect(out.customHost).toBe('192.168.1.50');
|
||||
});
|
||||
|
||||
test('empty custom stays custom with empty host', () => {
|
||||
expect(normalizeBindInput('custom', '')).toEqual({ mode: 'custom', customHost: '' });
|
||||
expect(normalizeBindInput('custom', ' ')).toEqual({ mode: 'custom', customHost: '' });
|
||||
});
|
||||
|
||||
test('non-custom modes blank the customHost', () => {
|
||||
expect(normalizeBindInput('loopback', '192.168.1.5')).toEqual({ mode: 'loopback', customHost: '' });
|
||||
expect(normalizeBindInput('all', '192.168.1.5')).toEqual({ mode: 'all', customHost: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveListenHost()', () => {
|
||||
test('loopback → 127.0.0.1', () => {
|
||||
expect(resolveListenHost('loopback', '')).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
test('all → 0.0.0.0', () => {
|
||||
expect(resolveListenHost('all', '')).toBe('0.0.0.0');
|
||||
});
|
||||
|
||||
test('custom → trimmed customHost', () => {
|
||||
expect(resolveListenHost('custom', ' 192.168.1.50 ')).toBe('192.168.1.50');
|
||||
});
|
||||
|
||||
test('custom with empty host falls back to loopback (defensive)', () => {
|
||||
expect(resolveListenHost('custom', '')).toBe('127.0.0.1');
|
||||
expect(resolveListenHost('custom', ' ')).toBe('127.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBindAxis()', () => {
|
||||
test('loopback mode → loopback axis', () => {
|
||||
expect(resolveBindAxis('loopback', '')).toBe('loopback');
|
||||
});
|
||||
|
||||
test('all mode → all axis', () => {
|
||||
expect(resolveBindAxis('all', '')).toBe('all');
|
||||
});
|
||||
|
||||
test('custom with empty host → custom-loopback (defensive)', () => {
|
||||
expect(resolveBindAxis('custom', '')).toBe('custom-loopback');
|
||||
});
|
||||
|
||||
test('custom with loopback alias → custom-loopback', () => {
|
||||
expect(resolveBindAxis('custom', '127.0.0.1')).toBe('custom-loopback');
|
||||
expect(resolveBindAxis('custom', 'localhost')).toBe('custom-loopback');
|
||||
});
|
||||
|
||||
test('custom with LAN address → custom-other', () => {
|
||||
expect(resolveBindAxis('custom', '192.168.1.50')).toBe('custom-other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyFromSettings()', () => {
|
||||
test('default install (http + loopback) → ok', () => {
|
||||
const v = classifyFromSettings({
|
||||
httpsEnabled: false,
|
||||
bindMode: 'loopback',
|
||||
customBindHost: '',
|
||||
userSuppliedCert: false
|
||||
});
|
||||
expect(v.class).toBe('ok');
|
||||
});
|
||||
|
||||
test('http + all → jail', () => {
|
||||
const v = classifyFromSettings({
|
||||
httpsEnabled: false,
|
||||
bindMode: 'all',
|
||||
customBindHost: '',
|
||||
userSuppliedCert: false
|
||||
});
|
||||
expect(v.class).toBe('jail');
|
||||
});
|
||||
|
||||
test('https + all + self-signed → warn', () => {
|
||||
const v = classifyFromSettings({
|
||||
httpsEnabled: true,
|
||||
bindMode: 'all',
|
||||
customBindHost: '',
|
||||
userSuppliedCert: false
|
||||
});
|
||||
expect(v.class).toBe('warn');
|
||||
});
|
||||
|
||||
test('https + all + user cert → ok', () => {
|
||||
const v = classifyFromSettings({
|
||||
httpsEnabled: true,
|
||||
bindMode: 'all',
|
||||
customBindHost: '',
|
||||
userSuppliedCert: true
|
||||
});
|
||||
expect(v.class).toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentInstructionsForVerdict()', () => {
|
||||
test('returns null for ok', () => {
|
||||
expect(agentInstructionsForVerdict({ class: 'ok', reason: 'x' }, '127.0.0.1', 3001)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for warn', () => {
|
||||
expect(agentInstructionsForVerdict({ class: 'warn', reason: 'x' }, '0.0.0.0', 3443)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns warning string for jail, includes host and port', () => {
|
||||
const s = agentInstructionsForVerdict({ class: 'jail', reason: 'x' }, '0.0.0.0', 3001);
|
||||
expect(s).not.toBeNull();
|
||||
expect(s).toContain('SECURITY WARNING');
|
||||
expect(s).toContain('0.0.0.0:3001');
|
||||
expect(s).toContain('cleartext');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue