@coffeerunhobby/mcp-ha-connect 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.env.example +52 -16
  2. package/CLAUDE.md +75 -0
  3. package/Dockerfile +5 -0
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +59 -3
  6. package/dist/config.js.map +1 -1
  7. package/dist/haClient/automations.js +2 -2
  8. package/dist/haClient/automations.js.map +1 -1
  9. package/dist/haClient/calendars.js +1 -1
  10. package/dist/haClient/calendars.js.map +1 -1
  11. package/dist/haClient/request.d.ts +2 -1
  12. package/dist/haClient/request.js +20 -11
  13. package/dist/haClient/request.js.map +1 -1
  14. package/dist/haClient/services.js +1 -1
  15. package/dist/haClient/services.js.map +1 -1
  16. package/dist/haClient/states.js +1 -1
  17. package/dist/haClient/states.js.map +1 -1
  18. package/dist/omadaClient/auth.d.ts +2 -1
  19. package/dist/omadaClient/auth.js +19 -30
  20. package/dist/omadaClient/auth.js.map +1 -1
  21. package/dist/omadaClient/request.d.ts +2 -1
  22. package/dist/omadaClient/request.js +18 -27
  23. package/dist/omadaClient/request.js.map +1 -1
  24. package/dist/server/auth.d.ts +6 -0
  25. package/dist/server/auth.js +22 -10
  26. package/dist/server/auth.js.map +1 -1
  27. package/dist/server/eventSubscription.d.ts +8 -0
  28. package/dist/server/eventSubscription.js +27 -5
  29. package/dist/server/eventSubscription.js.map +1 -1
  30. package/dist/server/http.d.ts +46 -0
  31. package/dist/server/http.js +143 -28
  32. package/dist/server/http.js.map +1 -1
  33. package/dist/server/rateLimiter.d.ts +16 -1
  34. package/dist/server/rateLimiter.js +32 -11
  35. package/dist/server/rateLimiter.js.map +1 -1
  36. package/dist/server/stdio.js +5 -0
  37. package/dist/server/stdio.js.map +1 -1
  38. package/dist/server/stream.d.ts +25 -0
  39. package/dist/server/stream.js +53 -2
  40. package/dist/server/stream.js.map +1 -1
  41. package/dist/tools/common.d.ts +11 -10
  42. package/dist/tools/common.js +20 -2
  43. package/dist/tools/common.js.map +1 -1
  44. package/dist/utils/config-validations.d.ts +7 -0
  45. package/dist/utils/config-validations.js +17 -0
  46. package/dist/utils/config-validations.js.map +1 -1
  47. package/dist/utils/jwt.d.ts +15 -1
  48. package/dist/utils/jwt.js +29 -4
  49. package/dist/utils/jwt.js.map +1 -1
  50. package/dist/utils/sanitizeError.d.ts +1 -0
  51. package/dist/utils/sanitizeError.js +18 -0
  52. package/dist/utils/sanitizeError.js.map +1 -0
  53. package/dist/utils/tlsDispatcher.d.ts +34 -0
  54. package/dist/utils/tlsDispatcher.js +33 -0
  55. package/dist/utils/tlsDispatcher.js.map +1 -0
  56. package/dist/version.d.ts +1 -1
  57. package/dist/version.js +1 -1
  58. package/docker-compose.yml +4 -1
  59. package/docs/CHANGELOG.md +58 -0
  60. package/openapi.json +1 -1
  61. package/package.json +4 -3
  62. package/src/config.ts +83 -3
  63. package/src/haClient/automations.ts +2 -2
  64. package/src/haClient/calendars.ts +1 -1
  65. package/src/haClient/request.ts +23 -11
  66. package/src/haClient/services.ts +1 -1
  67. package/src/haClient/states.ts +1 -1
  68. package/src/omadaClient/auth.ts +20 -28
  69. package/src/omadaClient/request.ts +19 -25
  70. package/src/server/auth.ts +41 -7
  71. package/src/server/eventSubscription.ts +30 -5
  72. package/src/server/http.ts +165 -29
  73. package/src/server/rateLimiter.ts +43 -11
  74. package/src/server/stdio.ts +6 -0
  75. package/src/server/stream.ts +65 -2
  76. package/src/tools/common.ts +23 -2
  77. package/src/utils/config-validations.ts +15 -0
  78. package/src/utils/jwt.ts +47 -4
  79. package/src/utils/sanitizeError.ts +18 -0
  80. package/src/utils/tlsDispatcher.ts +40 -0
  81. package/src/version.ts +1 -1
  82. package/tests/bdd/configuration.spec.ts +3 -0
  83. package/tests/config.test.ts +3 -0
  84. package/tests/resources/index.test.ts +3 -3
  85. package/tests/security/accessControl.test.ts +223 -0
  86. package/tests/security/authentication.test.ts +117 -0
  87. package/tests/security/configuration.test.ts +170 -0
  88. package/tests/security/cryptoTransport.test.ts +136 -0
  89. package/tests/security/dependencies.test.ts +152 -0
  90. package/tests/security/injection.test.ts +117 -0
  91. package/tests/security/insecureDesign.test.ts +122 -0
  92. package/tests/security/logging.test.ts +131 -0
  93. package/tests/security/resourceConsumption.test.ts +133 -0
  94. package/tests/tools/ai/analyzeSensors.test.ts +3 -3
  95. package/tests/tools/common.test.ts +5 -4
  96. package/tests/tools/homeassistant/handlers.test.ts +2 -2
  97. package/tests/tools/omada/handlers.test.ts +3 -3
@@ -0,0 +1,136 @@
1
+ /**
2
+ * SEC-CRYPTO — Cryptographic / transport-security findings.
3
+ *
4
+ * OWASP API8:2023 Security Misconfiguration / A02:2021 Cryptographic Failures.
5
+ *
6
+ * H2 Per-client TLS scoping. The Omada and HA clients must NOT toggle the
7
+ * process-global `NODE_TLS_REJECT_UNAUTHORIZED` env var to talk to a
8
+ * self-signed controller — that disables certificate validation for EVERY
9
+ * concurrent outbound request in the process (a TOCTOU race that silently
10
+ * drops TLS verification for unrelated HA/AI traffic). The insecure mode
11
+ * must be scoped to the single client via a per-instance undici dispatcher.
12
+ *
13
+ * L5 SSE client IDs must be generated with a CSPRNG (randomUUID), not
14
+ * Math.random(), so they are not predictable/guessable.
15
+ */
16
+
17
+ import { describe, it, expect, vi, afterEach } from 'vitest';
18
+ import { RequestHandler } from '../../src/omadaClient/request.js';
19
+ import { AuthManager } from '../../src/omadaClient/auth.js';
20
+ import { generateClientId } from '../../src/server/eventSubscription.js';
21
+
22
+ const ENV_KEY = 'NODE_TLS_REJECT_UNAUTHORIZED';
23
+
24
+ function jsonResponse(body: unknown): Response {
25
+ return new Response(JSON.stringify(body), {
26
+ status: 200,
27
+ headers: { 'content-type': 'application/json' },
28
+ });
29
+ }
30
+
31
+ describe('SEC-CRYPTO H2: per-client TLS scoping (no global env mutation)', () => {
32
+ const realFetch = global.fetch;
33
+
34
+ afterEach(() => {
35
+ global.fetch = realFetch;
36
+ vi.restoreAllMocks();
37
+ delete process.env[ENV_KEY];
38
+ });
39
+
40
+ it('Omada request with strictSsl=false never mutates global NODE_TLS_REJECT_UNAUTHORIZED', async () => {
41
+ let envDuringFetch: string | undefined = 'SENTINEL';
42
+ let dispatcherDuringFetch: unknown;
43
+
44
+ global.fetch = vi.fn(async (_url: unknown, init: unknown) => {
45
+ envDuringFetch = process.env[ENV_KEY];
46
+ dispatcherDuringFetch = (init as { dispatcher?: unknown }).dispatcher;
47
+ return jsonResponse({ errorCode: 0, result: {} });
48
+ }) as unknown as typeof fetch;
49
+
50
+ const auth = {
51
+ getAccessToken: async () => 'access-token',
52
+ clearToken: () => {},
53
+ } as unknown as AuthManager;
54
+
55
+ const handler = new RequestHandler(
56
+ { baseUrl: 'https://omada.example:8043', strictSsl: false },
57
+ auth
58
+ );
59
+
60
+ await handler.get('/openapi/v1/anything');
61
+
62
+ // The env var must NOT be set at any point — it stays undefined throughout.
63
+ expect(envDuringFetch).toBeUndefined();
64
+ expect(process.env[ENV_KEY]).toBeUndefined();
65
+ // Insecure TLS must be carried by a per-call dispatcher instead.
66
+ expect(dispatcherDuringFetch).toBeDefined();
67
+ });
68
+
69
+ it('Omada request with strictSsl=true sends no insecure dispatcher', async () => {
70
+ let dispatcherDuringFetch: unknown = 'SENTINEL';
71
+
72
+ global.fetch = vi.fn(async (_url: unknown, init: unknown) => {
73
+ dispatcherDuringFetch = (init as { dispatcher?: unknown }).dispatcher;
74
+ return jsonResponse({ errorCode: 0, result: {} });
75
+ }) as unknown as typeof fetch;
76
+
77
+ const auth = {
78
+ getAccessToken: async () => 'access-token',
79
+ clearToken: () => {},
80
+ } as unknown as AuthManager;
81
+
82
+ const handler = new RequestHandler(
83
+ { baseUrl: 'https://omada.example:8043', strictSsl: true },
84
+ auth
85
+ );
86
+
87
+ await handler.get('/openapi/v1/anything');
88
+
89
+ expect(dispatcherDuringFetch).toBeUndefined();
90
+ expect(process.env[ENV_KEY]).toBeUndefined();
91
+ });
92
+
93
+ it('Omada auth with strictSsl=false never mutates global NODE_TLS_REJECT_UNAUTHORIZED', async () => {
94
+ let envDuringFetch: string | undefined = 'SENTINEL';
95
+ let dispatcherDuringFetch: unknown;
96
+
97
+ global.fetch = vi.fn(async (_url: unknown, init: unknown) => {
98
+ envDuringFetch = process.env[ENV_KEY];
99
+ dispatcherDuringFetch = (init as { dispatcher?: unknown }).dispatcher;
100
+ return jsonResponse({
101
+ errorCode: 0,
102
+ result: { accessToken: 'a', refreshToken: 'r', expiresIn: 3600 },
103
+ });
104
+ }) as unknown as typeof fetch;
105
+
106
+ const auth = new AuthManager({
107
+ baseUrl: 'https://omada.example:8043',
108
+ clientId: 'id',
109
+ clientSecret: 'secret',
110
+ omadacId: 'omadac',
111
+ strictSsl: false,
112
+ });
113
+
114
+ const token = await auth.getAccessToken();
115
+
116
+ expect(token).toBe('a');
117
+ expect(envDuringFetch).toBeUndefined();
118
+ expect(process.env[ENV_KEY]).toBeUndefined();
119
+ expect(dispatcherDuringFetch).toBeDefined();
120
+ });
121
+ });
122
+
123
+ describe('SEC-CRYPTO L5: SSE client IDs use a CSPRNG', () => {
124
+ it('generateClientId returns an unpredictable UUID, not a Math.random/timestamp string', () => {
125
+ const id = generateClientId();
126
+ // RFC 4122 v4 UUID shape.
127
+ expect(id).toMatch(
128
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
129
+ );
130
+ });
131
+
132
+ it('generateClientId returns distinct values across calls', () => {
133
+ const ids = new Set(Array.from({ length: 100 }, () => generateClientId()));
134
+ expect(ids.size).toBe(100);
135
+ });
136
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * SEC-DEPS — Vulnerable & Outdated Components (OWASP A06:2021)
3
+ *
4
+ * Finding M9: production dependency tree shipped known-vulnerable transitive
5
+ * packages (hono / qs / path-to-regexp via the MCP SDK, plus a direct `ws`).
6
+ *
7
+ * These tests are the durable CI gate for that finding:
8
+ * 1. `npm audit --omit=dev` must report ZERO high/critical advisories in the
9
+ * PRODUCTION dependency tree (dev-only tooling is out of scope). A small,
10
+ * explicitly-documented allowlist exists for unavoidable transitive advisories
11
+ * that have no upstream fix yet — each entry must be justified in a comment.
12
+ * 2. After bumping the SDK/ws, re-assert the per-request transport invariant so a
13
+ * dependency upgrade can never silently change how transports are constructed.
14
+ */
15
+
16
+ import { describe, it, expect } from 'vitest';
17
+ import { execFileSync } from 'node:child_process';
18
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
19
+
20
+ /**
21
+ * Advisory IDs (GHSA / npm) we knowingly accept because there is no fixed
22
+ * upstream release reachable through our dependency tree. MUST stay empty unless
23
+ * a transitive advisory is genuinely unfixable — add the ID with a justification.
24
+ */
25
+ const HIGH_CRITICAL_ALLOWLIST = new Set<string | number>([
26
+ // (intentionally empty — all known high/critical advisories are fixed)
27
+ ]);
28
+
29
+ interface AuditAdvisory {
30
+ severity: string;
31
+ source?: number;
32
+ url?: string;
33
+ title?: string;
34
+ name?: string;
35
+ }
36
+
37
+ interface AuditReport {
38
+ vulnerabilities?: Record<
39
+ string,
40
+ {
41
+ severity: string;
42
+ via: Array<string | AuditAdvisory>;
43
+ }
44
+ >;
45
+ metadata?: {
46
+ vulnerabilities?: Record<string, number>;
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Run `npm audit --omit=dev --json` and return the parsed report. npm audit exits
52
+ * NON-zero when advisories are present, so we capture stdout from the thrown error
53
+ * too. Returns null when the registry is unreachable (offline dev) so the gate
54
+ * skips rather than producing a false failure.
55
+ */
56
+ function runProdAudit(): AuditReport | null {
57
+ let stdout: string;
58
+ try {
59
+ stdout = execFileSync('npm', ['audit', '--omit=dev', '--json'], {
60
+ encoding: 'utf8',
61
+ shell: process.platform === 'win32', // npm is npm.cmd on Windows
62
+ stdio: ['ignore', 'pipe', 'ignore'],
63
+ maxBuffer: 32 * 1024 * 1024,
64
+ });
65
+ } catch (err) {
66
+ const e = err as { stdout?: string; message?: string };
67
+ if (typeof e.stdout === 'string' && e.stdout.trim().startsWith('{')) {
68
+ stdout = e.stdout;
69
+ } else {
70
+ // Could not run audit (offline / registry down) — signal skip.
71
+ return null;
72
+ }
73
+ }
74
+
75
+ try {
76
+ return JSON.parse(stdout) as AuditReport;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ describe('SEC-DEPS — Vulnerable & Outdated Components (M9)', () => {
83
+ describe('production audit gate', () => {
84
+ it('reports zero high/critical advisories in the production tree', () => {
85
+ const report = runProdAudit();
86
+ if (report === null) {
87
+ // Registry unreachable — do not fail the suite offline.
88
+ console.warn('SEC-DEPS: npm audit unavailable (offline?), skipping gate assertion');
89
+ return;
90
+ }
91
+
92
+ const offenders: string[] = [];
93
+ for (const [pkg, vuln] of Object.entries(report.vulnerabilities ?? {})) {
94
+ const sev = vuln.severity?.toLowerCase();
95
+ if (sev !== 'high' && sev !== 'critical') {
96
+ continue;
97
+ }
98
+ // Allow only if EVERY advisory behind this package is allowlisted.
99
+ const advisoryIds = vuln.via
100
+ .filter((v): v is AuditAdvisory => typeof v === 'object')
101
+ .map((v) => v.source)
102
+ .filter((s): s is number => typeof s === 'number');
103
+ const allAllowlisted =
104
+ advisoryIds.length > 0 && advisoryIds.every((id) => HIGH_CRITICAL_ALLOWLIST.has(id));
105
+ if (!allAllowlisted) {
106
+ offenders.push(`${pkg} (${sev}; advisories: ${advisoryIds.join(', ') || 'n/a'})`);
107
+ }
108
+ }
109
+
110
+ expect(offenders, `Unfixed high/critical prod advisories:\n${offenders.join('\n')}`).toEqual(
111
+ []
112
+ );
113
+ });
114
+
115
+ it('reports zero high/critical via the audit metadata summary', () => {
116
+ const report = runProdAudit();
117
+ if (report === null) {
118
+ console.warn('SEC-DEPS: npm audit unavailable (offline?), skipping metadata assertion');
119
+ return;
120
+ }
121
+ const counts = report.metadata?.vulnerabilities ?? {};
122
+ const high = counts.high ?? 0;
123
+ const critical = counts.critical ?? 0;
124
+ // With an empty allowlist the metadata summary must also be clean.
125
+ if (HIGH_CRITICAL_ALLOWLIST.size === 0) {
126
+ expect(high + critical).toBe(0);
127
+ }
128
+ });
129
+ });
130
+
131
+ describe('per-request transport invariant (post-upgrade)', () => {
132
+ it('SDK still exposes a constructable StreamableHTTPServerTransport', () => {
133
+ expect(typeof StreamableHTTPServerTransport).toBe('function');
134
+ });
135
+
136
+ it('constructs an independent transport per call (stateless mode)', () => {
137
+ const make = (): StreamableHTTPServerTransport =>
138
+ new StreamableHTTPServerTransport({
139
+ sessionIdGenerator: undefined, // stateless: one transport per request
140
+ enableDnsRebindingProtection: true,
141
+ allowedHosts: ['localhost:3000'],
142
+ allowedOrigins: ['http://localhost:3000'],
143
+ });
144
+
145
+ const a = make();
146
+ const b = make();
147
+ expect(a).toBeInstanceOf(StreamableHTTPServerTransport);
148
+ expect(b).toBeInstanceOf(StreamableHTTPServerTransport);
149
+ expect(a).not.toBe(b); // distinct instances — no shared session state
150
+ });
151
+ });
152
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * SEC-INJ — Injection (path / parameter)
3
+ * OWASP A03:2021 (Injection); overlaps SEC-SSRF (A10:2021).
4
+ *
5
+ * Finding covered:
6
+ * H4 — Home Assistant API paths interpolate user input without encoding, so an
7
+ * entity_id / domain / service containing "/", "..", "?" or "#" could escape
8
+ * the intended /api/<resource> path or inject query parameters.
9
+ *
10
+ * Secure behavior asserted:
11
+ * - Call sites percent-encode each user-controlled path segment.
12
+ * - The request layer refuses any path that resolves outside /api/ (traversal guard).
13
+ */
14
+
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16
+ import { RequestHandler } from '../../src/haClient/request.js';
17
+ import { StateOperations } from '../../src/haClient/states.js';
18
+ import { ServiceOperations } from '../../src/haClient/services.js';
19
+
20
+ function makeHandler() {
21
+ return new RequestHandler({
22
+ baseUrl: 'http://ha.test:8123',
23
+ token: 'tok',
24
+ timeout: 30000,
25
+ strictSsl: true,
26
+ });
27
+ }
28
+
29
+ /** Capture the URL string passed to fetch and return an empty JSON response. */
30
+ function stubFetch() {
31
+ const fetchMock = vi.fn(async () => ({
32
+ ok: true,
33
+ status: 200,
34
+ statusText: 'OK',
35
+ headers: { get: () => 'application/json' },
36
+ json: async () => ({}),
37
+ text: async () => '',
38
+ }));
39
+ vi.stubGlobal('fetch', fetchMock);
40
+ return fetchMock;
41
+ }
42
+
43
+ describe('SEC-INJ — Injection (H4: HA API path)', () => {
44
+ beforeEach(() => {
45
+ stubFetch();
46
+ });
47
+ afterEach(() => {
48
+ vi.unstubAllGlobals();
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ describe('request-layer traversal guard', () => {
53
+ it('rejects a path that escapes /api via ".." before making any request', async () => {
54
+ const handler = makeHandler();
55
+ await expect(handler.get('/states/../../secret')).rejects.toThrow();
56
+ expect(fetch).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it('allows a normal /api path', async () => {
60
+ const handler = makeHandler();
61
+ await handler.get('/states');
62
+ expect(fetch).toHaveBeenCalledOnce();
63
+ const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
64
+ expect(new URL(calledUrl).pathname).toBe('/api/states');
65
+ });
66
+ });
67
+
68
+ describe('call-site encoding — StateOperations.getState', () => {
69
+ it('encodes a malicious entity_id so it cannot escape /api/states', async () => {
70
+ const states = new StateOperations(makeHandler());
71
+ await states.getState('x/../../admin');
72
+
73
+ const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
74
+ const { pathname } = new URL(calledUrl);
75
+ // The whole id stays a single encoded segment under /api/states/.
76
+ expect(pathname.startsWith('/api/states/')).toBe(true);
77
+ expect(pathname).not.toContain('/admin');
78
+ expect(calledUrl).toContain('%2F'); // the slashes were encoded
79
+ });
80
+
81
+ it('encodes "?" so it cannot inject query parameters', async () => {
82
+ const states = new StateOperations(makeHandler());
83
+ await states.getState('light.x?evil=1');
84
+
85
+ const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
86
+ expect(new URL(calledUrl).searchParams.get('evil')).toBeNull();
87
+ });
88
+
89
+ it('passes a normal entity_id through unchanged', async () => {
90
+ const states = new StateOperations(makeHandler());
91
+ await states.getState('light.kitchen');
92
+
93
+ const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
94
+ expect(new URL(calledUrl).pathname).toBe('/api/states/light.kitchen');
95
+ });
96
+ });
97
+
98
+ describe('call-site encoding — ServiceOperations.callService', () => {
99
+ it('encodes domain and service segments', async () => {
100
+ const services = new ServiceOperations(makeHandler());
101
+ await services.callService({ domain: 'light', service: 'turn_on/../../admin' });
102
+
103
+ const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
104
+ const { pathname } = new URL(calledUrl);
105
+ expect(pathname.startsWith('/api/services/light/')).toBe(true);
106
+ expect(pathname).not.toContain('/admin');
107
+ });
108
+
109
+ it('passes a normal service call through unchanged', async () => {
110
+ const services = new ServiceOperations(makeHandler());
111
+ await services.callService({ domain: 'light', service: 'turn_on' });
112
+
113
+ const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
114
+ expect(new URL(calledUrl).pathname).toBe('/api/services/light/turn_on');
115
+ });
116
+ });
117
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * SEC-DESIGN — Insecure Design / Fail-open
3
+ * OWASP A04:2021 (Insecure Design).
4
+ *
5
+ * Findings covered:
6
+ * L3 — default-allow `?? 0xFF` fallbacks. A tool invocation that arrives with no
7
+ * permission mask was granted FULL permissions. Secure behavior: fail closed
8
+ * (deny) unless the transport is the trusted-local stdio path, which opts in
9
+ * explicitly via setLocalFullTrust().
10
+ * L1 — automation/script CREATION is effectively arbitrary HA template/shell exec.
11
+ * Policy: it must remain behind a privileged bit (CONFIGURE), never a read or
12
+ * plain-control bit. This guard locks that contract so it can't silently
13
+ * regress to a lower tier.
14
+ */
15
+
16
+ import { describe, it, expect, vi, afterEach } from 'vitest';
17
+ import type { ToolExtra } from '../../src/tools/common.js';
18
+ import { wrapToolHandler, toToolResult, setLocalFullTrust } from '../../src/tools/common.js';
19
+ import { Permission, Role, hasPermission } from '../../src/permissions/index.js';
20
+
21
+ /** Build a ToolExtra; pass `undefined` to omit the permission mask entirely. */
22
+ function extraWith(permissions?: number): ToolExtra {
23
+ return {
24
+ sessionId: 'test-session',
25
+ signal: new AbortController().signal,
26
+ requestId: 1,
27
+ authInfo:
28
+ permissions !== undefined
29
+ ? { token: 't', clientId: 'c', scopes: [], extra: { permissions } }
30
+ : undefined,
31
+ } as unknown as ToolExtra;
32
+ }
33
+
34
+ describe('SEC-DESIGN — Insecure Design / Fail-open', () => {
35
+ // The local-trust flag is process-global; never let a test leak it.
36
+ afterEach(() => setLocalFullTrust(false));
37
+
38
+ describe('L3: missing permission mask fails closed', () => {
39
+ it('denies a privileged tool when no authInfo is present (HTTP anomaly)', async () => {
40
+ const handler = vi.fn().mockResolvedValue(toToolResult('ok'));
41
+ const wrapped = wrapToolHandler('dangerTool', handler, Permission.ADMIN);
42
+
43
+ const result = await wrapped({}, extraWith(undefined));
44
+
45
+ expect(handler).not.toHaveBeenCalled();
46
+ expect(result.isError).toBe(true);
47
+ expect((result.content[0] as { text: string }).text).toContain('Permission denied');
48
+ });
49
+
50
+ it('denies even a QUERY-only tool when no mask is present', async () => {
51
+ const handler = vi.fn().mockResolvedValue(toToolResult('ok'));
52
+ const wrapped = wrapToolHandler('readTool', handler, Permission.QUERY);
53
+
54
+ const result = await wrapped({}, extraWith(undefined));
55
+
56
+ expect(handler).not.toHaveBeenCalled();
57
+ expect(result.isError).toBe(true);
58
+ });
59
+
60
+ it('still enforces an explicit insufficient mask (regression guard)', async () => {
61
+ const handler = vi.fn().mockResolvedValue(toToolResult('ok'));
62
+ const wrapped = wrapToolHandler('dangerTool', handler, Permission.ADMIN);
63
+
64
+ const result = await wrapped({}, extraWith(Permission.QUERY));
65
+
66
+ expect(handler).not.toHaveBeenCalled();
67
+ expect(result.isError).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe('L3: stdio local-trust opt-in', () => {
72
+ it('grants full permissions for no-auth calls ONLY when local trust is enabled', async () => {
73
+ const handler = vi.fn().mockResolvedValue(toToolResult('ok'));
74
+ const wrapped = wrapToolHandler('dangerTool', handler, Permission.ADMIN);
75
+
76
+ setLocalFullTrust(true);
77
+ const result = await wrapped({}, extraWith(undefined));
78
+
79
+ expect(handler).toHaveBeenCalledOnce();
80
+ expect(result.isError).toBe(false);
81
+ });
82
+
83
+ it('reverts to fail-closed once local trust is turned back off', async () => {
84
+ const handler = vi.fn().mockResolvedValue(toToolResult('ok'));
85
+ const wrapped = wrapToolHandler('dangerTool', handler, Permission.ADMIN);
86
+
87
+ setLocalFullTrust(true);
88
+ setLocalFullTrust(false);
89
+ const result = await wrapped({}, extraWith(undefined));
90
+
91
+ expect(handler).not.toHaveBeenCalled();
92
+ expect(result.isError).toBe(true);
93
+ });
94
+ });
95
+
96
+ describe('L1: automation/script creation stays behind a privileged bit', () => {
97
+ it('CONFIGURE (the createAutomation gate) is NOT granted by read/operator roles', () => {
98
+ // createAutomation is registered with Permission.CONFIGURE. Document & lock that
99
+ // this bit represents code-exec-equivalent power: it must not leak into the
100
+ // low-privilege roles a typical read/control token would carry.
101
+ expect(hasPermission(Role.READONLY, Permission.CONFIGURE)).toBe(false);
102
+ expect(hasPermission(Role.OPERATOR, Permission.CONFIGURE)).toBe(false);
103
+
104
+ // Only the explicitly-privileged roles include it.
105
+ expect(hasPermission(Role.CONTRIBUTOR, Permission.CONFIGURE)).toBe(true);
106
+ expect(hasPermission(Role.ADMIN, Permission.CONFIGURE)).toBe(true);
107
+ });
108
+
109
+ it('a CONFIGURE gate denies an OPERATOR token but allows a CONTRIBUTOR token', async () => {
110
+ const handler = vi.fn().mockResolvedValue(toToolResult('created'));
111
+ const wrapped = wrapToolHandler('createAutomation', handler, Permission.CONFIGURE);
112
+
113
+ const denied = await wrapped({}, extraWith(Role.OPERATOR));
114
+ expect(handler).not.toHaveBeenCalled();
115
+ expect(denied.isError).toBe(true);
116
+
117
+ const allowed = await wrapped({}, extraWith(Role.CONTRIBUTOR));
118
+ expect(handler).toHaveBeenCalledOnce();
119
+ expect(allowed.isError).toBe(false);
120
+ });
121
+ });
122
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * SEC-LOG — Logging & Information Disclosure
3
+ * OWASP A09:2021 (Security Logging and Monitoring Failures) / sensitive data exposure.
4
+ *
5
+ * Findings covered:
6
+ * M6 — error responses leak the raw internal `error.message` to clients
7
+ * (may contain internal hostnames, file paths, tokens, stack detail).
8
+ * M7 — the unauthenticated `/health` endpoint discloses version, auth method,
9
+ * and internal AI provider URLs.
10
+ *
11
+ * These tests assert the SECURE behavior: clients receive a generic error message
12
+ * (the detail is logged server-side only), and `/health` returns liveness only.
13
+ */
14
+
15
+ import { describe, it, expect, vi } from 'vitest';
16
+ import type { IncomingMessage, ServerResponse } from 'node:http';
17
+ import type { HaClient } from '../../src/haClient/index.js';
18
+ import { sanitizeError } from '../../src/utils/sanitizeError.js';
19
+ import { handleRestApi, handleHealthCheck } from '../../src/server/http.js';
20
+ import type { AuthenticatedRequest } from '../../src/server/auth.js';
21
+
22
+ /** An error whose message embeds infrastructure detail we must never echo. */
23
+ const LEAKY = new Error('connect ECONNREFUSED 10.0.0.19:8123 (/volume1/docker/secret)');
24
+
25
+ /** Build a mock request carrying a full permission mask. */
26
+ function mockReq(method: string, url: string): AuthenticatedRequest {
27
+ const listeners: Record<string, ((arg?: unknown) => void)[]> = {};
28
+ const req = {
29
+ method,
30
+ url,
31
+ headers: { host: 'mcp.test:3000' },
32
+ auth: { token: 't', clientId: 'u', scopes: [], extra: { permissions: 0xff } },
33
+ on(event: string, cb: (arg?: unknown) => void) {
34
+ (listeners[event] ??= []).push(cb);
35
+ if (event === 'end') {
36
+ queueMicrotask(() => {
37
+ for (const e of listeners['end'] ?? []) e();
38
+ });
39
+ }
40
+ return req;
41
+ },
42
+ } as unknown as AuthenticatedRequest;
43
+ return req;
44
+ }
45
+
46
+ /** Capture status code and JSON body written to the response. */
47
+ function mockRes(): ServerResponse & { _body?: unknown } {
48
+ const res = {
49
+ statusCode: 200,
50
+ headersSent: false,
51
+ setHeader: vi.fn(),
52
+ end: vi.fn(function (this: { _body?: unknown }, payload?: unknown) {
53
+ if (typeof payload === 'string') {
54
+ try {
55
+ this._body = JSON.parse(payload);
56
+ } catch {
57
+ this._body = payload;
58
+ }
59
+ }
60
+ return this;
61
+ }),
62
+ } as unknown as ServerResponse & { _body?: unknown };
63
+ return res;
64
+ }
65
+
66
+ describe('SEC-LOG — Logging & Information Disclosure', () => {
67
+ describe('M6: sanitizeError never echoes internal detail', () => {
68
+ it('returns a non-empty generic string', () => {
69
+ const out = sanitizeError(LEAKY);
70
+ expect(typeof out).toBe('string');
71
+ expect(out.length).toBeGreaterThan(0);
72
+ });
73
+
74
+ it('strips internal hostnames, ports and file paths from Error messages', () => {
75
+ const out = sanitizeError(LEAKY);
76
+ expect(out).not.toContain('10.0.0.19');
77
+ expect(out).not.toContain('8123');
78
+ expect(out).not.toContain('/volume1');
79
+ expect(out).not.toContain('ECONNREFUSED');
80
+ });
81
+
82
+ it('does not echo raw non-Error values', () => {
83
+ const out = sanitizeError('raw secret /volume1/docker/.env token=abc123');
84
+ expect(out).not.toContain('/volume1');
85
+ expect(out).not.toContain('abc123');
86
+ });
87
+
88
+ it('supports a caller-supplied generic fallback', () => {
89
+ expect(sanitizeError(LEAKY, 'Upstream request failed')).toBe('Upstream request failed');
90
+ });
91
+ });
92
+
93
+ describe('M6: handleRestApi 500 path returns a generic message', () => {
94
+ it('does not leak the upstream error detail to the client', async () => {
95
+ const client = { getStates: vi.fn().mockRejectedValue(LEAKY) } as unknown as HaClient;
96
+ const req = mockReq('GET', '/api/states');
97
+ const res = mockRes();
98
+
99
+ await handleRestApi(req, res, '/api/states', client);
100
+
101
+ expect(res.statusCode).toBe(500);
102
+ const body = JSON.stringify(res._body);
103
+ expect(body).not.toContain('10.0.0.19');
104
+ expect(body).not.toContain('/volume1');
105
+ expect(body).not.toContain('ECONNREFUSED');
106
+ });
107
+ });
108
+
109
+ describe('M7: /health discloses liveness only', () => {
110
+ it('returns status only — no version, auth method, or internal AI URLs', () => {
111
+ const res = mockRes();
112
+
113
+ handleHealthCheck(res);
114
+
115
+ const body = res._body as Record<string, unknown>;
116
+ expect(body).toEqual({ status: 'healthy' });
117
+
118
+ // Defense in depth: the serialized body carries none of the old internals.
119
+ const serialized = JSON.stringify(body);
120
+ expect(serialized).not.toContain('version');
121
+ expect(serialized).not.toContain('authMethod');
122
+ expect(serialized).not.toContain('aiUrl');
123
+ expect(serialized).not.toContain('aiProvider');
124
+ expect(body).not.toHaveProperty('rateLimitEnabled');
125
+ expect(body).not.toHaveProperty('eventsConnectedClients');
126
+ });
127
+ });
128
+ });
129
+
130
+ // Avoid unused-type lint on the IncomingMessage import in some configs.
131
+ export type _LogTestReq = IncomingMessage;