@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,40 @@
1
+ /**
2
+ * Per-client TLS dispatcher helper (SEC-CRYPTO / H2).
3
+ *
4
+ * When a client must talk to a host with a self-signed certificate, the ONLY
5
+ * acceptable way to relax certificate validation is to scope it to that single
6
+ * client. The previous implementation toggled the process-global
7
+ * `NODE_TLS_REJECT_UNAUTHORIZED` env var around each `fetch` call, which
8
+ * disables TLS verification for EVERY concurrent outbound request in the
9
+ * process for the duration of the call — a TOCTOU race that silently strips
10
+ * certificate validation from unrelated Home Assistant / AI traffic.
11
+ *
12
+ * Node's global `fetch` (undici) ignores the legacy `agent` option; the
13
+ * supported mechanism is a per-call `dispatcher`. We build one undici Agent per
14
+ * client instance and pass it on every request, leaving the process-global TLS
15
+ * policy untouched.
16
+ */
17
+
18
+ import { Agent, type Dispatcher } from 'undici';
19
+
20
+ /**
21
+ * `fetch` init augmented with undici's per-request dispatcher option, which is
22
+ * not present in the lib.dom / @types/node `RequestInit` typings.
23
+ */
24
+ export type FetchInitWithDispatcher = RequestInit & { dispatcher?: Dispatcher };
25
+
26
+ /**
27
+ * Build an undici dispatcher that disables TLS certificate validation, scoped
28
+ * to a single client instance. Returns `undefined` when `strictSsl` is not
29
+ * explicitly disabled, so strict clients carry no dispatcher and use Node's
30
+ * default (validating) global agent.
31
+ *
32
+ * @param strictSsl - When `false`, certificate validation is disabled for this
33
+ * client only. Any other value (including `undefined`) keeps validation on.
34
+ */
35
+ export function createTlsDispatcher(strictSsl: boolean | undefined): Dispatcher | undefined {
36
+ if (strictSsl === false) {
37
+ return new Agent({ connect: { rejectUnauthorized: false } });
38
+ }
39
+ return undefined;
40
+ }
package/src/version.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Package version - hardcoded at build time for reliability
3
3
  * Keep in sync with package.json version
4
4
  */
5
- export const VERSION = '1.2.0';
5
+ export const VERSION = '1.3.0';
@@ -211,6 +211,9 @@ describeFeature(feature, ({ Scenario }) => {
211
211
  HA_URL: 'http://homeassistant.local:8123',
212
212
  HA_TOKEN: 'test-token',
213
213
  MCP_HTTP_BIND_ADDR: '192.168.1.100',
214
+ // H5: a non-loopback bind requires authentication.
215
+ MCP_AUTH_METHOD: 'bearer',
216
+ MCP_AUTH_SECRET: 'test-secret-key-32chars-minimum!',
214
217
  };
215
218
  });
216
219
 
@@ -175,6 +175,9 @@ describe('loadConfig', () => {
175
175
  HA_URL: 'http://homeassistant.10.0.0.19.nip.io:8123',
176
176
  HA_TOKEN: 'test-token-12345',
177
177
  MCP_HTTP_BIND_ADDR: '192.168.1.100',
178
+ // H5: a non-loopback bind requires authentication.
179
+ MCP_AUTH_METHOD: 'bearer',
180
+ MCP_AUTH_SECRET: 'test-secret-key-32chars-minimum!',
178
181
  };
179
182
 
180
183
  const config = loadConfig(env);
@@ -9,13 +9,13 @@ import { registerAllResources } from '../../src/resources/index.js';
9
9
 
10
10
  // Create mock server that captures registered resources
11
11
  function createMockServer() {
12
- const resources = new Map<string, { config: unknown; handler: Function }>();
12
+ const resources = new Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }>();
13
13
  return {
14
- resource: vi.fn((name: string, uriOrTemplate: unknown, config: unknown, handler: Function) => {
14
+ resource: vi.fn((name: string, uriOrTemplate: unknown, config: unknown, handler: (...args: unknown[]) => unknown) => {
15
15
  resources.set(name, { config, handler });
16
16
  }),
17
17
  resources,
18
- } as unknown as McpServer & { resources: Map<string, { config: unknown; handler: Function }> };
18
+ } as unknown as McpServer & { resources: Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }> };
19
19
  }
20
20
 
21
21
  // Create mock HaClient
@@ -0,0 +1,223 @@
1
+ /**
2
+ * SEC-AC — Access Control / Authorization
3
+ * OWASP A01:2021 (Broken Access Control), API1/API5:2023 (BOLA/BFLA)
4
+ *
5
+ * Findings covered:
6
+ * H1 — REST /api/* routes bypass RBAC (authenticated but no permission check).
7
+ *
8
+ * These tests assert the SECURE behavior: every REST route is gated on the same
9
+ * Permission.* bit as its MCP-tool twin, and a token lacking that bit is rejected
10
+ * with 403 BEFORE any Home Assistant call is made.
11
+ */
12
+
13
+ import { describe, it, expect, vi } from 'vitest';
14
+ import type { IncomingMessage, ServerResponse } from 'node:http';
15
+ import type { HaClient } from '../../src/haClient/index.js';
16
+ import { Permission } from '../../src/permissions/index.js';
17
+ import { requiredRestPermission, handleRestApi } from '../../src/server/http.js';
18
+ import { handleEventSubscription, getClientCount } from '../../src/server/eventSubscription.js';
19
+ import type { EventSubscriber } from '../../src/haClient/events.js';
20
+ import type { AuthenticatedRequest } from '../../src/server/auth.js';
21
+
22
+ /** Build a mock request carrying a permission mask in auth.extra. */
23
+ function mockReq(
24
+ method: string,
25
+ url: string,
26
+ permissions: number,
27
+ body?: unknown
28
+ ): AuthenticatedRequest {
29
+ const listeners: Record<string, ((arg?: unknown) => void)[]> = {};
30
+ const req = {
31
+ method,
32
+ url,
33
+ headers: { host: 'mcp.test:3000' },
34
+ auth: { token: 't', clientId: 'u', scopes: [], extra: { permissions } },
35
+ on(event: string, cb: (arg?: unknown) => void) {
36
+ (listeners[event] ??= []).push(cb);
37
+ // Drive the body stream synchronously on the next tick for POST parseBody().
38
+ if (event === 'end') {
39
+ queueMicrotask(() => {
40
+ if (body !== undefined) {
41
+ for (const d of listeners['data'] ?? []) d(Buffer.from(JSON.stringify(body)));
42
+ }
43
+ for (const e of listeners['end'] ?? []) e();
44
+ });
45
+ }
46
+ return req;
47
+ },
48
+ } as unknown as AuthenticatedRequest;
49
+ return req;
50
+ }
51
+
52
+ /** Capture status code and JSON body written to the response. */
53
+ function mockRes(): ServerResponse & { _status?: number; _body?: unknown } {
54
+ const res = {
55
+ statusCode: 200,
56
+ headersSent: false,
57
+ setHeader: vi.fn(),
58
+ end: vi.fn(function (this: { _body?: unknown }, payload?: unknown) {
59
+ if (typeof payload === 'string') {
60
+ try {
61
+ this._body = JSON.parse(payload);
62
+ } catch {
63
+ this._body = payload;
64
+ }
65
+ }
66
+ return this;
67
+ }),
68
+ } as unknown as ServerResponse & { _status?: number; _body?: unknown };
69
+ // mirror statusCode into _status when sendJson sets it
70
+ Object.defineProperty(res, '_status', {
71
+ get() {
72
+ return res.statusCode;
73
+ },
74
+ configurable: true,
75
+ });
76
+ return res;
77
+ }
78
+
79
+ describe('SEC-AC — Access Control', () => {
80
+ describe('H1: REST route → permission mapping (mirrors MCP-tool twins)', () => {
81
+ it('maps read routes to QUERY', () => {
82
+ expect(requiredRestPermission('GET', '/api/states')).toBe(Permission.QUERY);
83
+ expect(requiredRestPermission('GET', '/api/states/light.kitchen')).toBe(Permission.QUERY);
84
+ expect(requiredRestPermission('GET', '/api/sensors')).toBe(Permission.QUERY);
85
+ expect(requiredRestPermission('GET', '/api/entities/light')).toBe(Permission.QUERY);
86
+ expect(requiredRestPermission('GET', '/api/search')).toBe(Permission.QUERY);
87
+ expect(requiredRestPermission('GET', '/api/history/sensor.temp')).toBe(Permission.QUERY);
88
+ expect(requiredRestPermission('GET', '/api/version')).toBe(Permission.QUERY);
89
+ });
90
+
91
+ it('maps the service-call route to CONTROL', () => {
92
+ expect(requiredRestPermission('POST', '/api/services/light/turn_on')).toBe(Permission.CONTROL);
93
+ });
94
+
95
+ it('returns null for unknown routes (fall through to 404, no auth needed)', () => {
96
+ expect(requiredRestPermission('GET', '/api/unknown')).toBeNull();
97
+ expect(requiredRestPermission('DELETE', '/api/states')).toBeNull();
98
+ });
99
+ });
100
+
101
+ describe('H1: enforcement — insufficient permission is rejected with 403', () => {
102
+ it('blocks GET /api/states without QUERY and never calls the client', async () => {
103
+ const client = { getStates: vi.fn().mockResolvedValue([]) } as unknown as HaClient;
104
+ const req = mockReq('GET', '/api/states', Permission.CONTROL); // CONTROL, not QUERY
105
+ const res = mockRes();
106
+
107
+ await handleRestApi(req, res, '/api/states', client);
108
+
109
+ expect(res.statusCode).toBe(403);
110
+ expect(client.getStates).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('blocks POST /api/services without CONTROL and never calls the service', async () => {
114
+ const client = { callService: vi.fn().mockResolvedValue({ ok: true }) } as unknown as HaClient;
115
+ const req = mockReq('POST', '/api/services/light/turn_on', Permission.QUERY, {
116
+ entity_id: 'light.kitchen',
117
+ });
118
+ const res = mockRes();
119
+
120
+ await handleRestApi(req, res, '/api/services/light/turn_on', client);
121
+
122
+ expect(res.statusCode).toBe(403);
123
+ expect(client.callService).not.toHaveBeenCalled();
124
+ });
125
+ });
126
+
127
+ describe('H1: enforcement — sufficient permission is allowed', () => {
128
+ it('allows GET /api/states with QUERY', async () => {
129
+ const client = { getStates: vi.fn().mockResolvedValue([{ entity_id: 'light.x' }]) } as unknown as HaClient;
130
+ const req = mockReq('GET', '/api/states', Permission.QUERY);
131
+ const res = mockRes();
132
+
133
+ await handleRestApi(req, res, '/api/states', client);
134
+
135
+ expect(res.statusCode).toBe(200);
136
+ expect(client.getStates).toHaveBeenCalledOnce();
137
+ });
138
+
139
+ it('allows POST /api/services with CONTROL', async () => {
140
+ const client = { callService: vi.fn().mockResolvedValue({ ok: true }) } as unknown as HaClient;
141
+ const req = mockReq('POST', '/api/services/light/turn_on', Permission.CONTROL, {
142
+ entity_id: 'light.kitchen',
143
+ });
144
+ const res = mockRes();
145
+
146
+ await handleRestApi(req, res, '/api/services/light/turn_on', client);
147
+
148
+ expect(res.statusCode).toBe(200);
149
+ expect(client.callService).toHaveBeenCalledOnce();
150
+ });
151
+
152
+ it('treats SUPERUSER (0xFF) as allowed everywhere', async () => {
153
+ const client = { getStates: vi.fn().mockResolvedValue([]) } as unknown as HaClient;
154
+ const req = mockReq('GET', '/api/states', 0xff);
155
+ const res = mockRes();
156
+
157
+ await handleRestApi(req, res, '/api/states', client);
158
+
159
+ expect(res.statusCode).toBe(200);
160
+ });
161
+ });
162
+
163
+ describe('L2: SSE /subscribe_events requires QUERY permission', () => {
164
+ /** Minimal SSE-capable response mock. */
165
+ function sseRes() {
166
+ return {
167
+ writableEnded: false,
168
+ writeHead: vi.fn(),
169
+ write: vi.fn(),
170
+ end: vi.fn(),
171
+ } as unknown as ServerResponse & { writeHead: ReturnType<typeof vi.fn> };
172
+ }
173
+
174
+ /** Request mock that captures the 'close' handler so we can tear down timers. */
175
+ function sseReq(permissions: number) {
176
+ const handlers: Record<string, (arg?: unknown) => void> = {};
177
+ const req = {
178
+ url: '/subscribe_events',
179
+ headers: {},
180
+ auth: { token: 't', clientId: 'u', scopes: [], extra: { permissions } },
181
+ on(event: string, cb: (arg?: unknown) => void) {
182
+ handlers[event] = cb;
183
+ return req;
184
+ },
185
+ } as unknown as AuthenticatedRequest;
186
+ return { req, handlers };
187
+ }
188
+
189
+ it('rejects a subscriber lacking QUERY with 403 and creates no client', async () => {
190
+ const before = getClientCount();
191
+ const { req } = sseReq(Permission.CONTROL); // CONTROL, not QUERY
192
+ const res = sseRes();
193
+ const subscriber = { isConnected: vi.fn() } as unknown as EventSubscriber;
194
+
195
+ await handleEventSubscription(req, res, subscriber);
196
+
197
+ expect(res.writeHead).toHaveBeenCalledWith(403, expect.anything());
198
+ expect(getClientCount()).toBe(before); // no leak
199
+ expect(subscriber.isConnected).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it('allows a subscriber with QUERY to open the stream', async () => {
203
+ const { req, handlers } = sseReq(Permission.QUERY);
204
+ const res = sseRes();
205
+ const subscriber = {
206
+ isConnected: vi.fn().mockReturnValue(true),
207
+ subscribeEventType: vi.fn().mockResolvedValue('sub-1'),
208
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
209
+ } as unknown as EventSubscriber;
210
+
211
+ await handleEventSubscription(req, res, subscriber);
212
+
213
+ // 200 stream opened, not 403
214
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
215
+ 'Content-Type': 'text/event-stream',
216
+ }));
217
+ expect(subscriber.subscribeEventType).toHaveBeenCalled();
218
+
219
+ // tear down the keep-alive interval the handler registered
220
+ handlers['close']?.();
221
+ });
222
+ });
223
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * SEC-AUTHN — Authentication & Session
3
+ * OWASP A07:2021 (Identification & Authentication Failures), API2:2023
4
+ *
5
+ * Findings covered:
6
+ * H3 — tokens without `exp` never expire; expiry is optional and unenforceable.
7
+ * M8 — JWT secret strength is not enforced at startup.
8
+ * L6 — no `iss` / `aud` / `nbf` / clock-skew validation.
9
+ *
10
+ * Secure behavior asserted here:
11
+ * - verifyJwt can REQUIRE exp (opt-in), validate nbf, and check iss/aud when configured.
12
+ * - The default (no options) stays backward-compatible: a token without exp is accepted.
13
+ * - loadConfig refuses a bearer secret shorter than 32 chars.
14
+ */
15
+
16
+ import { describe, it, expect } from 'vitest';
17
+ import { createJwt, verifyJwt } from '../../src/utils/jwt.js';
18
+ import { loadConfig } from '../../src/config.js';
19
+
20
+ const SECRET = 'test-secret-key-32chars-minimum!';
21
+ const now = () => Math.floor(Date.now() / 1000);
22
+
23
+ describe('SEC-AUTHN — Authentication', () => {
24
+ describe('H3: enforce token expiry (opt-in via requireExp)', () => {
25
+ it('rejects a token without exp when requireExp is set', () => {
26
+ const token = createJwt({ sub: 'service' }, SECRET);
27
+ const result = verifyJwt(token, SECRET, { requireExp: true });
28
+ expect(result.valid).toBe(false);
29
+ expect(result.error).toMatch(/exp/i);
30
+ });
31
+
32
+ it('accepts a token with a future exp when requireExp is set', () => {
33
+ const token = createJwt({ sub: 'u', exp: now() + 3600 }, SECRET);
34
+ expect(verifyJwt(token, SECRET, { requireExp: true }).valid).toBe(true);
35
+ });
36
+
37
+ it('still rejects an expired token when requireExp is set', () => {
38
+ const token = createJwt({ sub: 'u', exp: now() - 100 }, SECRET);
39
+ expect(verifyJwt(token, SECRET, { requireExp: true }).valid).toBe(false);
40
+ });
41
+
42
+ it('default (no options) remains backward-compatible: accepts no-exp token', () => {
43
+ const token = createJwt({ sub: 'service' }, SECRET);
44
+ expect(verifyJwt(token, SECRET).valid).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe('L6: nbf (not-before) validation', () => {
49
+ it('rejects a token whose nbf is in the future', () => {
50
+ const token = createJwt({ sub: 'u', nbf: now() + 3600 }, SECRET);
51
+ const result = verifyJwt(token, SECRET);
52
+ expect(result.valid).toBe(false);
53
+ expect(result.error).toMatch(/not yet valid|nbf/i);
54
+ });
55
+
56
+ it('accepts a token whose nbf is in the past', () => {
57
+ const token = createJwt({ sub: 'u', nbf: now() - 100 }, SECRET);
58
+ expect(verifyJwt(token, SECRET).valid).toBe(true);
59
+ });
60
+
61
+ it('tolerates small clock skew on nbf', () => {
62
+ const token = createJwt({ sub: 'u', nbf: now() + 30 }, SECRET);
63
+ expect(verifyJwt(token, SECRET, { clockSkewSec: 60 }).valid).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe('L6: issuer / audience validation (only when configured)', () => {
68
+ it('rejects a token with the wrong issuer', () => {
69
+ const token = createJwt({ sub: 'u', iss: 'evil' }, SECRET);
70
+ const result = verifyJwt(token, SECRET, { issuer: 'mcp-ha-connect' });
71
+ expect(result.valid).toBe(false);
72
+ expect(result.error).toMatch(/issuer/i);
73
+ });
74
+
75
+ it('accepts a token with the expected issuer', () => {
76
+ const token = createJwt({ sub: 'u', iss: 'mcp-ha-connect' }, SECRET);
77
+ expect(verifyJwt(token, SECRET, { issuer: 'mcp-ha-connect' }).valid).toBe(true);
78
+ });
79
+
80
+ it('rejects a token whose audience does not match', () => {
81
+ const token = createJwt({ sub: 'u', aud: 'other' }, SECRET);
82
+ const result = verifyJwt(token, SECRET, { audience: 'mcp' });
83
+ expect(result.valid).toBe(false);
84
+ expect(result.error).toMatch(/audience/i);
85
+ });
86
+
87
+ it('accepts a token whose audience array contains the expected value', () => {
88
+ const token = createJwt({ sub: 'u', aud: ['mcp', 'other'] }, SECRET);
89
+ expect(verifyJwt(token, SECRET, { audience: 'mcp' }).valid).toBe(true);
90
+ });
91
+
92
+ it('ignores iss/aud when not configured (backward-compatible)', () => {
93
+ const token = createJwt({ sub: 'u', iss: 'whatever', aud: 'whatever' }, SECRET);
94
+ expect(verifyJwt(token, SECRET).valid).toBe(true);
95
+ });
96
+ });
97
+
98
+ describe('M8: bearer secret strength enforced at startup', () => {
99
+ const base = { MCP_SERVER_USE_HTTP: 'true', MCP_AUTH_METHOD: 'bearer' };
100
+
101
+ it('rejects a bearer secret shorter than 32 characters', () => {
102
+ expect(() =>
103
+ loadConfig({ ...base, MCP_AUTH_SECRET: 'too-short' } as NodeJS.ProcessEnv)
104
+ ).toThrow(/32|secret/i);
105
+ });
106
+
107
+ it('accepts a bearer secret of at least 32 characters', () => {
108
+ expect(() =>
109
+ loadConfig({ ...base, MCP_AUTH_SECRET: 'a'.repeat(32) } as NodeJS.ProcessEnv)
110
+ ).not.toThrow();
111
+ });
112
+
113
+ it('does not require a secret when auth method is none', () => {
114
+ expect(() => loadConfig({ MCP_AUTH_METHOD: 'none' } as NodeJS.ProcessEnv)).not.toThrow();
115
+ });
116
+ });
117
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * SEC-CONFIG — Security Misconfiguration (OWASP API8:2023 / A05:2021).
3
+ *
4
+ * H5 Fail-closed bind: MCP_AUTH_METHOD=none must not be combined with a
5
+ * non-loopback bind address — that exposes an unauthenticated server.
6
+ * M1 docker-compose.yml must not hard-code a wildcard `MCP_HTTP_ALLOWED_ORIGINS=*`.
7
+ * M3 DNS-rebinding Host validation is OPT-IN via MCP_HTTP_ALLOWED_HOSTS: OFF when
8
+ * unset (no silent upgrade break), enforcing exactly the listed host[:port]
9
+ * (plus loopback) when set. Default origins stay full scheme://host:port forms
10
+ * so the separate Origin/CORS layer matches real Origin headers.
11
+ * M10 Dockerfile must run as a non-root USER.
12
+ * L4 HTTP responses must carry baseline security headers.
13
+ * L7 .env.example must use the real var (MCP_AUTH_SECRET, not MCP_AUTH_TOKEN)
14
+ * and ship secure defaults.
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import { readFileSync } from 'node:fs';
19
+ import { resolve } from 'node:path';
20
+ import { loadConfig } from '../../src/config.js';
21
+ import { buildTransportSecurityOptions } from '../../src/server/stream.js';
22
+ import { applySecurityHeaders } from '../../src/server/http.js';
23
+
24
+ const repoRoot = process.cwd();
25
+ const read = (rel: string): string => readFileSync(resolve(repoRoot, rel), 'utf8');
26
+
27
+ const SECRET = 'test-secret-key-32chars-minimum!';
28
+
29
+ describe('SEC-CONFIG H5: refuse auth=none on a non-loopback bind', () => {
30
+ it('throws when MCP_AUTH_METHOD defaults to none and bind is 0.0.0.0', () => {
31
+ expect(() => loadConfig({ MCP_HTTP_BIND_ADDR: '0.0.0.0' } as NodeJS.ProcessEnv)).toThrow(
32
+ /none/i
33
+ );
34
+ });
35
+
36
+ it('allows none with an explicit loopback bind', () => {
37
+ const cfg = loadConfig({ MCP_HTTP_BIND_ADDR: '127.0.0.1' } as NodeJS.ProcessEnv);
38
+ expect(cfg.authMethod).toBe('none');
39
+ });
40
+
41
+ it('allows none with the default (loopback) bind', () => {
42
+ const cfg = loadConfig({} as NodeJS.ProcessEnv);
43
+ expect(cfg.authMethod).toBe('none');
44
+ expect(cfg.httpBindAddr).toBe('127.0.0.1');
45
+ });
46
+
47
+ it('allows bearer on a public bind when a strong secret is set', () => {
48
+ const cfg = loadConfig({
49
+ MCP_HTTP_BIND_ADDR: '0.0.0.0',
50
+ MCP_AUTH_METHOD: 'bearer',
51
+ MCP_AUTH_SECRET: SECRET,
52
+ } as NodeJS.ProcessEnv);
53
+ expect(cfg.authMethod).toBe('bearer');
54
+ expect(cfg.httpBindAddr).toBe('0.0.0.0');
55
+ });
56
+ });
57
+
58
+ describe('SEC-CONFIG M1: compose has no wildcard origin', () => {
59
+ it('docker-compose.yml does not set MCP_HTTP_ALLOWED_ORIGINS=*', () => {
60
+ const compose = read('docker-compose.yml');
61
+ expect(compose).not.toMatch(/MCP_HTTP_ALLOWED_ORIGINS\s*[:=]\s*\*/);
62
+ });
63
+ });
64
+
65
+ describe('SEC-CONFIG M3: opt-in DNS-rebinding Host validation', () => {
66
+ const base = {
67
+ MCP_HTTP_BIND_ADDR: '0.0.0.0',
68
+ MCP_AUTH_METHOD: 'bearer',
69
+ MCP_AUTH_SECRET: SECRET,
70
+ } as NodeJS.ProcessEnv;
71
+
72
+ it('default config origins are full scheme://host:port forms', () => {
73
+ const cfg = loadConfig({} as NodeJS.ProcessEnv);
74
+ expect(cfg.httpAllowedOrigins?.some((o) => /^https?:\/\/[^/]+:\d+$/.test(o))).toBe(true);
75
+ });
76
+
77
+ it('leaves Host validation OFF when MCP_HTTP_ALLOWED_HOSTS is unset (no upgrade break)', () => {
78
+ const cfg = loadConfig({} as NodeJS.ProcessEnv);
79
+ const opts = buildTransportSecurityOptions(cfg);
80
+ expect(opts.enableDnsRebindingProtection).toBe(false);
81
+ expect(opts.allowedHosts).toEqual([]);
82
+ // Origin/CORS is enforced by the HTTP layer, not the transport.
83
+ expect(opts.allowedOrigins).toEqual([]);
84
+ });
85
+
86
+ it('does NOT derive Host allowlist from CORS origins', () => {
87
+ // Origins are client/CORS addresses; the server host is frequently different.
88
+ // Setting origins alone must not turn on (or seed) Host validation.
89
+ const cfg = loadConfig({
90
+ ...base,
91
+ MCP_HTTP_ALLOWED_ORIGINS: 'https://client.example.com:5678',
92
+ } as NodeJS.ProcessEnv);
93
+ const opts = buildTransportSecurityOptions(cfg);
94
+ expect(opts.enableDnsRebindingProtection).toBe(false);
95
+ expect(opts.allowedHosts).toEqual([]);
96
+ });
97
+
98
+ it('enforces exactly the configured hosts (plus loopback) when MCP_HTTP_ALLOWED_HOSTS is set', () => {
99
+ const cfg = loadConfig({
100
+ ...base,
101
+ MCP_HTTP_ALLOWED_HOSTS: 'mcp.example.com,192.168.0.18:3000',
102
+ } as NodeJS.ProcessEnv);
103
+ const opts = buildTransportSecurityOptions(cfg);
104
+ expect(opts.enableDnsRebindingProtection).toBe(true);
105
+ expect(opts.allowedHosts).toContain('mcp.example.com');
106
+ expect(opts.allowedHosts).toContain('192.168.0.18:3000');
107
+ // Loopback/bind conveniences are always added so on-box access keeps working.
108
+ expect(opts.allowedHosts).toContain('localhost:3000');
109
+ expect(opts.allowedHosts).toContain('0.0.0.0:3000');
110
+ // A host the operator did NOT list is absent → the SDK 403s it.
111
+ expect(opts.allowedHosts).not.toContain('evil.attacker.test');
112
+ });
113
+
114
+ it('accepts a full-URL host entry and reduces it to host[:port] form', () => {
115
+ const cfg = loadConfig({
116
+ ...base,
117
+ MCP_HTTP_ALLOWED_HOSTS: 'https://mcp.example.com',
118
+ } as NodeJS.ProcessEnv);
119
+ const opts = buildTransportSecurityOptions(cfg);
120
+ expect(opts.allowedHosts).toContain('mcp.example.com');
121
+ // Origins are still not enforced at the transport.
122
+ expect(opts.allowedOrigins).toEqual([]);
123
+ });
124
+ });
125
+
126
+ describe('SEC-CONFIG M10: Dockerfile runs as non-root', () => {
127
+ it('Dockerfile declares a non-root USER', () => {
128
+ const dockerfile = read('Dockerfile');
129
+ const userLines = dockerfile.split('\n').filter((l) => /^\s*USER\s+/i.test(l));
130
+ expect(userLines.length).toBeGreaterThan(0);
131
+ // The last USER directive in effect must not be root.
132
+ const last = userLines[userLines.length - 1].trim().replace(/^USER\s+/i, '');
133
+ expect(last).not.toMatch(/^root\b/);
134
+ expect(last).not.toMatch(/^0\b/);
135
+ });
136
+ });
137
+
138
+ describe('SEC-CONFIG L4: baseline security headers', () => {
139
+ it('applySecurityHeaders sets nosniff, frame-deny, and a referrer policy', () => {
140
+ const set: Record<string, string> = {};
141
+ const res = {
142
+ setHeader(name: string, value: string) {
143
+ set[name] = value;
144
+ },
145
+ } as unknown as import('node:http').ServerResponse;
146
+
147
+ applySecurityHeaders(res);
148
+
149
+ expect(set['X-Content-Type-Options']).toBe('nosniff');
150
+ expect(set['X-Frame-Options']).toBe('DENY');
151
+ expect(set['Referrer-Policy']).toBeDefined();
152
+ });
153
+ });
154
+
155
+ describe('SEC-CONFIG L7: .env.example uses correct var + secure defaults', () => {
156
+ const env = read('.env.example');
157
+
158
+ it('uses MCP_AUTH_SECRET, never MCP_AUTH_TOKEN', () => {
159
+ expect(env).toMatch(/MCP_AUTH_SECRET/);
160
+ expect(env).not.toMatch(/MCP_AUTH_TOKEN/);
161
+ });
162
+
163
+ it('defaults HA_STRICT_SSL to true', () => {
164
+ expect(env).toMatch(/^HA_STRICT_SSL=true\s*$/m);
165
+ });
166
+
167
+ it('defaults MCP_AUTH_METHOD to bearer', () => {
168
+ expect(env).toMatch(/^MCP_AUTH_METHOD=bearer\s*$/m);
169
+ });
170
+ });