diff --git a/src/utils/network-classifier.ts b/src/utils/network-classifier.ts new file mode 100644 index 0000000..ec61f05 --- /dev/null +++ b/src/utils/network-classifier.ts @@ -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(' '); +} diff --git a/tests/network-classifier.test.ts b/tests/network-classifier.test.ts new file mode 100644 index 0000000..846e8cb --- /dev/null +++ b/tests/network-classifier.test.ts @@ -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'); + }); +});