@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
package/src/config.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { isValidBindAddress, isValidOrigin } from './utils/config-validations.js';
2
+ import { isValidBindAddress, isValidOrigin, isLoopbackAddress } from './utils/config-validations.js';
3
3
  import { parsePermissionsConfig, type PermissionsConfig } from './permissions/index.js';
4
4
  import { logger } from './utils/logger.js';
5
5
  import type { AIProviderType } from './localAI/types.js';
@@ -64,6 +64,7 @@ const envSchema = z
64
64
  httpHealthcheckPath: z.string().optional(),
65
65
  httpAllowCors: createBooleanStringSchema(true),
66
66
  httpAllowedOrigins: z.string().optional().transform((v) => v?.split(',').map((s) => s.trim()).filter(Boolean)),
67
+ httpAllowedHosts: z.string().optional().transform((v) => v?.split(',').map((s) => s.trim()).filter(Boolean)),
67
68
 
68
69
  // SSE Event Subscription Configuration
69
70
  sseEventsEnabled: createBooleanStringSchema(true),
@@ -73,10 +74,18 @@ const envSchema = z
73
74
  rateLimitEnabled: createBooleanStringSchema(true),
74
75
  rateLimitWindowMs: numericStringSchema,
75
76
  rateLimitMaxRequests: numericStringSchema,
77
+ // M4: immediate-peer IPs whose forwarding headers may be trusted (CSV).
78
+ rateLimitTrustedProxies: z
79
+ .string()
80
+ .optional()
81
+ .transform((v) => v?.split(',').map((s) => s.trim()).filter(Boolean)),
76
82
 
77
83
  // Authentication Configuration
78
84
  authMethod: z.enum(['none', 'bearer']).optional().default('none'),
79
85
  authSecret: z.string().optional(),
86
+ authRequireExp: createBooleanStringSchema(false),
87
+ authIssuer: z.string().min(1).optional(),
88
+ authAudience: z.string().min(1).optional(),
80
89
  permissionsConfig: z.string().optional(),
81
90
  })
82
91
  .refine(
@@ -121,6 +130,41 @@ const envSchema = z
121
130
  message: 'MCP_AUTH_SECRET is required when MCP_AUTH_METHOD is "bearer"',
122
131
  path: ['authSecret'],
123
132
  }
133
+ )
134
+ .refine(
135
+ (data) => {
136
+ // M8: a weak signing secret undermines the whole bearer scheme.
137
+ if (data.authMethod === 'bearer' && data.authSecret && data.authSecret.length < 32) {
138
+ return false;
139
+ }
140
+ return true;
141
+ },
142
+ {
143
+ message: 'MCP_AUTH_SECRET must be at least 32 characters when MCP_AUTH_METHOD is "bearer"',
144
+ path: ['authSecret'],
145
+ }
146
+ )
147
+ .refine(
148
+ (data) => {
149
+ // H5: an unauthenticated server must never be reachable off-host. Allow
150
+ // method=none only when the (explicit) bind address is loopback. A missing
151
+ // bind address defaults to 127.0.0.1 later, so it is safe.
152
+ if (
153
+ data.authMethod === 'none' &&
154
+ data.httpBindAddr &&
155
+ !isLoopbackAddress(data.httpBindAddr)
156
+ ) {
157
+ return false;
158
+ }
159
+ return true;
160
+ },
161
+ {
162
+ message:
163
+ 'MCP_AUTH_METHOD=none is only permitted on a loopback bind address ' +
164
+ '(127.0.0.0/8 or ::1). Set MCP_AUTH_METHOD=bearer (with MCP_AUTH_SECRET) ' +
165
+ 'to bind a public interface such as 0.0.0.0.',
166
+ path: ['authMethod'],
167
+ }
124
168
  );
125
169
 
126
170
  export interface EnvironmentConfig {
@@ -165,6 +209,12 @@ export interface EnvironmentConfig {
165
209
  httpHealthcheckPath?: string;
166
210
  httpAllowCors: boolean;
167
211
  httpAllowedOrigins?: string[];
212
+ /**
213
+ * Opt-in list of Host-header values (host[:port]) the server is reached by.
214
+ * When set, DNS-rebinding Host validation is enforced at the transport (M3);
215
+ * when unset, Host validation is OFF (origin/CORS is handled separately).
216
+ */
217
+ httpAllowedHosts?: string[];
168
218
 
169
219
  // SSE Event Subscription Configuration
170
220
  sseEventsEnabled: boolean;
@@ -174,10 +224,14 @@ export interface EnvironmentConfig {
174
224
  rateLimitEnabled: boolean;
175
225
  rateLimitWindowMs: number;
176
226
  rateLimitMaxRequests: number;
227
+ rateLimitTrustedProxies?: string[];
177
228
 
178
229
  // Authentication Configuration
179
230
  authMethod: 'none' | 'bearer';
180
231
  authSecret?: string;
232
+ authRequireExp: boolean;
233
+ authIssuer?: string;
234
+ authAudience?: string;
181
235
  permissions: PermissionsConfig;
182
236
  }
183
237
 
@@ -224,6 +278,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
224
278
  httpHealthcheckPath: env.MCP_HTTP_HEALTHCHECK_PATH,
225
279
  httpAllowCors: env.MCP_HTTP_ALLOW_CORS,
226
280
  httpAllowedOrigins: env.MCP_HTTP_ALLOWED_ORIGINS,
281
+ httpAllowedHosts: env.MCP_HTTP_ALLOWED_HOSTS,
227
282
 
228
283
  // SSE Event Subscription Configuration
229
284
  sseEventsEnabled: env.MCP_SSE_EVENTS_ENABLED,
@@ -233,10 +288,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
233
288
  rateLimitEnabled: env.MCP_RATE_LIMIT_ENABLED,
234
289
  rateLimitWindowMs: env.MCP_RATE_LIMIT_WINDOW_MS,
235
290
  rateLimitMaxRequests: env.MCP_RATE_LIMIT_MAX_REQUESTS,
291
+ rateLimitTrustedProxies: env.MCP_RATE_LIMIT_TRUSTED_PROXIES,
236
292
 
237
293
  // Authentication Configuration
238
294
  authMethod: env.MCP_AUTH_METHOD,
239
295
  authSecret: env.MCP_AUTH_SECRET,
296
+ authRequireExp: env.MCP_AUTH_REQUIRE_EXP,
297
+ authIssuer: env.MCP_AUTH_ISSUER,
298
+ authAudience: env.MCP_AUTH_AUDIENCE,
240
299
  permissionsConfig: env.MCP_PERMISSIONS_CONFIG,
241
300
  });
242
301
 
@@ -247,13 +306,29 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
247
306
 
248
307
  const httpPath = parsed.data.httpPath ?? '/mcp';
249
308
  const httpBindAddr = parsed.data.httpBindAddr ?? '127.0.0.1';
250
- let httpAllowedOrigins = parsed.data.httpAllowedOrigins ?? ['127.0.0.1', 'localhost'];
309
+ const httpPort = parsed.data.httpPort ?? 3000;
310
+
311
+ // M3: default origins must be full scheme://host:port so they actually match a
312
+ // real browser `Origin` header (the SDK compares it by exact string). Bare
313
+ // hostnames like "localhost" never match "http://localhost:3000" and so left
314
+ // DNS-rebinding/CORS protection effectively disabled for the default config.
315
+ let httpAllowedOrigins =
316
+ parsed.data.httpAllowedOrigins ??
317
+ [`http://localhost:${httpPort}`, `http://127.0.0.1:${httpPort}`];
251
318
 
252
319
  if (httpAllowedOrigins.includes('*')) {
253
320
  logger.warn('Wildcard (*) origin allowed - origin validation disabled');
254
321
  httpAllowedOrigins = [];
255
322
  }
256
323
 
324
+ // H5: make the no-auth posture loud even when the bind is loopback.
325
+ if (parsed.data.authMethod === 'none') {
326
+ logger.warn(
327
+ 'MCP_AUTH_METHOD=none: authentication is DISABLED. This is only permitted ' +
328
+ 'on a loopback bind; set MCP_AUTH_METHOD=bearer to expose the server.'
329
+ );
330
+ }
331
+
257
332
  return {
258
333
  // Plugin Enable Flags
259
334
  haPluginEnabled: parsed.data.haPluginEnabled,
@@ -289,13 +364,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
289
364
  stateful: parsed.data.stateful,
290
365
 
291
366
  // MCP Server HTTP Configuration
292
- httpPort: parsed.data.httpPort,
367
+ httpPort,
293
368
  httpBindAddr,
294
369
  httpPath,
295
370
  httpEnableHealthcheck: parsed.data.httpEnableHealthcheck,
296
371
  httpHealthcheckPath: parsed.data.httpHealthcheckPath,
297
372
  httpAllowCors: parsed.data.httpAllowCors,
298
373
  httpAllowedOrigins,
374
+ httpAllowedHosts: parsed.data.httpAllowedHosts,
299
375
 
300
376
  // SSE Event Subscription Configuration
301
377
  sseEventsEnabled: parsed.data.sseEventsEnabled,
@@ -305,10 +381,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
305
381
  rateLimitEnabled: parsed.data.rateLimitEnabled,
306
382
  rateLimitWindowMs: parsed.data.rateLimitWindowMs ?? 60000,
307
383
  rateLimitMaxRequests: parsed.data.rateLimitMaxRequests ?? 100,
384
+ rateLimitTrustedProxies: parsed.data.rateLimitTrustedProxies,
308
385
 
309
386
  // Authentication Configuration
310
387
  authMethod: parsed.data.authMethod,
311
388
  authSecret: parsed.data.authSecret,
389
+ authRequireExp: parsed.data.authRequireExp,
390
+ authIssuer: parsed.data.authIssuer,
391
+ authAudience: parsed.data.authAudience,
312
392
  permissions: parsePermissionsConfig(parsed.data.permissionsConfig),
313
393
  };
314
394
  }
@@ -162,7 +162,7 @@ export class AutomationOperations {
162
162
  logger.info('Creating automation', { alias: config.alias, id: automationId });
163
163
 
164
164
  // POST to /config/automation/config/{id} - the ID must be in the URL path
165
- await this.request.post<{ result: string }>(`/config/automation/config/${automationId}`, {
165
+ await this.request.post<{ result: string }>(`/config/automation/config/${encodeURIComponent(automationId)}`, {
166
166
  id: automationId,
167
167
  alias: config.alias,
168
168
  description: config.description || '',
@@ -224,6 +224,6 @@ export class AutomationOperations {
224
224
  const automationId = entityId.replace('automation.', '');
225
225
  logger.debug('Fetching automation trace', { automationId });
226
226
 
227
- return this.request.get<unknown[]>(`/trace/automation/${automationId}`);
227
+ return this.request.get<unknown[]>(`/trace/automation/${encodeURIComponent(automationId)}`);
228
228
  }
229
229
  }
@@ -54,7 +54,7 @@ export class CalendarOperations {
54
54
 
55
55
  // Calendar events use a special API endpoint, not the standard services API
56
56
  const events = await this.request.get<CalendarEvent[]>(
57
- `/calendars/${entityId}`,
57
+ `/calendars/${encodeURIComponent(entityId)}`,
58
58
  {
59
59
  start: start.toISOString(),
60
60
  end: end.toISOString(),
@@ -3,10 +3,11 @@
3
3
  * Handles authentication and HTTP requests
4
4
  */
5
5
 
6
- import https from 'https';
6
+ import type { Dispatcher } from 'undici';
7
7
  import type { RequestOptions } from '../types/index.js';
8
8
  import { AuthenticationError, ApiError } from '../types/index.js';
9
9
  import { logger } from '../utils/logger.js';
10
+ import { createTlsDispatcher, type FetchInitWithDispatcher } from '../utils/tlsDispatcher.js';
10
11
 
11
12
  export interface RequestHandlerConfig {
12
13
  baseUrl: string;
@@ -19,7 +20,8 @@ export interface RequestHandlerConfig {
19
20
  * HTTP request handler for Home Assistant API calls.
20
21
  */
21
22
  export class RequestHandler {
22
- private httpsAgent?: https.Agent;
23
+ /** Per-client undici dispatcher; set only when strictSsl is disabled (H2). */
24
+ private dispatcher?: Dispatcher;
23
25
  private baseUrl: string;
24
26
  private token: string;
25
27
  private timeout: number;
@@ -29,11 +31,11 @@ export class RequestHandler {
29
31
  this.token = config.token;
30
32
  this.timeout = config.timeout;
31
33
 
32
- // Create HTTPS agent with custom SSL settings
33
- if (!config.strictSsl) {
34
- this.httpsAgent = new https.Agent({
35
- rejectUnauthorized: false,
36
- });
34
+ // Relax TLS validation for THIS client only, via a per-instance undici
35
+ // dispatcher. Node's global fetch ignores the legacy `agent` option, so the
36
+ // previous https.Agent was silently a no-op for self-signed HA instances.
37
+ this.dispatcher = createTlsDispatcher(config.strictSsl);
38
+ if (this.dispatcher) {
37
39
  logger.warn('SSL certificate validation is disabled');
38
40
  }
39
41
  }
@@ -44,6 +46,13 @@ export class RequestHandler {
44
46
  async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
45
47
  const url = new URL(`${this.baseUrl}/api${path}`);
46
48
 
49
+ // H4 defense-in-depth: refuse any path that resolves outside /api/.
50
+ // Call sites encode user-controlled segments, but this guard catches any
51
+ // raw "../" traversal that would otherwise escape the API namespace.
52
+ if (url.pathname !== '/api' && !url.pathname.startsWith('/api/')) {
53
+ throw new ApiError('Invalid API path', 400, { path });
54
+ }
55
+
47
56
  // Add query parameters
48
57
  if (options.params) {
49
58
  Object.entries(options.params).forEach(([key, value]) => {
@@ -64,14 +73,17 @@ export class RequestHandler {
64
73
  });
65
74
 
66
75
  try {
67
- const response = await fetch(url.toString(), {
76
+ const init: FetchInitWithDispatcher = {
68
77
  method: options.method ?? 'GET',
69
78
  headers,
70
79
  body: options.body ? JSON.stringify(options.body) : undefined,
71
- // @ts-expect-error - agent is valid but not in types
72
- agent: this.httpsAgent,
73
80
  signal: AbortSignal.timeout(this.timeout),
74
- });
81
+ };
82
+ if (this.dispatcher) {
83
+ init.dispatcher = this.dispatcher;
84
+ }
85
+
86
+ const response = await fetch(url.toString(), init);
75
87
 
76
88
  if (!response.ok) {
77
89
  const errorText = await response.text();
@@ -23,7 +23,7 @@ export class ServiceOperations {
23
23
  hasTarget: !!data.target,
24
24
  });
25
25
 
26
- const path = `/services/${data.domain}/${data.service}`;
26
+ const path = `/services/${encodeURIComponent(data.domain)}/${encodeURIComponent(data.service)}`;
27
27
  const body = {
28
28
  ...data.service_data,
29
29
  ...data.target,
@@ -31,7 +31,7 @@ export class StateOperations {
31
31
  logger.debug('Fetching state', { entityId });
32
32
 
33
33
  try {
34
- const state = await this.request.get<Entity>(`/states/${entityId}`);
34
+ const state = await this.request.get<Entity>(`/states/${encodeURIComponent(entityId)}`);
35
35
  return state;
36
36
  } catch (error) {
37
37
  if (error instanceof ApiError && error.statusCode === 404) {
@@ -1,5 +1,7 @@
1
+ import type { Dispatcher } from 'undici';
1
2
  import type { OmadaApiResponse, TokenResult } from '../types/index.js';
2
3
  import { logger } from '../utils/logger.js';
4
+ import { createTlsDispatcher, type FetchInitWithDispatcher } from '../utils/tlsDispatcher.js';
3
5
 
4
6
  const TOKEN_EXPIRY_BUFFER_SECONDS = 30;
5
7
 
@@ -27,7 +29,8 @@ export class AuthManager {
27
29
  private readonly clientSecret: string;
28
30
  private readonly omadacId: string;
29
31
  private readonly timeout: number;
30
- private readonly strictSsl: boolean;
32
+ /** Per-client undici dispatcher; set only when strictSsl is disabled (H2). */
33
+ private readonly dispatcher?: Dispatcher;
31
34
 
32
35
  constructor(config: AuthManagerConfig) {
33
36
  this.baseUrl = config.baseUrl;
@@ -35,7 +38,7 @@ export class AuthManager {
35
38
  this.clientSecret = config.clientSecret;
36
39
  this.omadacId = config.omadacId;
37
40
  this.timeout = config.timeout ?? 30000;
38
- this.strictSsl = config.strictSsl ?? true;
41
+ this.dispatcher = createTlsDispatcher(config.strictSsl);
39
42
  }
40
43
 
41
44
  /**
@@ -100,34 +103,23 @@ export class AuthManager {
100
103
  }
101
104
 
102
105
  try {
103
- // For self-signed certificates, we need to temporarily disable TLS validation
104
- // Node.js native fetch doesn't support the `agent` option like axios/node-fetch
105
- const originalRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
106
- if (!this.strictSsl) {
107
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
106
+ // For self-signed certificates we relax TLS validation for THIS client
107
+ // only, via a per-instance undici dispatcher (H2) — never by toggling the
108
+ // process-global NODE_TLS_REJECT_UNAUTHORIZED env var.
109
+ const init: FetchInitWithDispatcher = {
110
+ method: 'POST',
111
+ headers: {
112
+ 'Content-Type': 'application/json',
113
+ Accept: 'application/json',
114
+ },
115
+ body: JSON.stringify(body),
116
+ signal: AbortSignal.timeout(this.timeout),
117
+ };
118
+ if (this.dispatcher) {
119
+ init.dispatcher = this.dispatcher;
108
120
  }
109
121
 
110
- let response: Response;
111
- try {
112
- response = await fetch(url.toString(), {
113
- method: 'POST',
114
- headers: {
115
- 'Content-Type': 'application/json',
116
- Accept: 'application/json',
117
- },
118
- body: JSON.stringify(body),
119
- signal: AbortSignal.timeout(this.timeout),
120
- });
121
- } finally {
122
- // Restore original setting
123
- if (!this.strictSsl) {
124
- if (originalRejectUnauthorized === undefined) {
125
- delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
126
- } else {
127
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalRejectUnauthorized;
128
- }
129
- }
130
- }
122
+ const response = await fetch(url.toString(), init);
131
123
 
132
124
  if (!response.ok) {
133
125
  const errorText = await response.text();
@@ -1,5 +1,7 @@
1
+ import type { Dispatcher } from 'undici';
1
2
  import type { OmadaApiResponse, PaginatedResult } from '../types/index.js';
2
3
  import { logger } from '../utils/logger.js';
4
+ import { createTlsDispatcher, type FetchInitWithDispatcher } from '../utils/tlsDispatcher.js';
3
5
 
4
6
  import type { AuthManager } from './auth.js';
5
7
 
@@ -25,7 +27,8 @@ export interface RequestHandlerConfig {
25
27
  export class RequestHandler {
26
28
  private readonly baseUrl: string;
27
29
  private readonly timeout: number;
28
- private readonly strictSsl: boolean;
30
+ /** Per-client undici dispatcher; set only when strictSsl is disabled (H2). */
31
+ private readonly dispatcher?: Dispatcher;
29
32
 
30
33
  constructor(
31
34
  config: RequestHandlerConfig,
@@ -33,7 +36,7 @@ export class RequestHandler {
33
36
  ) {
34
37
  this.baseUrl = config.baseUrl;
35
38
  this.timeout = config.timeout ?? 30000;
36
- this.strictSsl = config.strictSsl ?? true;
39
+ this.dispatcher = createTlsDispatcher(config.strictSsl);
37
40
  }
38
41
 
39
42
  /**
@@ -98,31 +101,22 @@ export class RequestHandler {
98
101
  });
99
102
 
100
103
  try {
101
- // For self-signed certificates, we need to temporarily disable TLS validation
102
- // Node.js native fetch doesn't support the `agent` option like axios/node-fetch
103
- const originalRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
104
- if (!this.strictSsl) {
105
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
104
+ // For self-signed certificates we relax TLS validation for THIS client
105
+ // only, via a per-instance undici dispatcher (H2). Node's global fetch
106
+ // ignores the legacy `agent` option, and toggling the process-global
107
+ // NODE_TLS_REJECT_UNAUTHORIZED env var would disable validation for every
108
+ // concurrent outbound request in the process.
109
+ const init: FetchInitWithDispatcher = {
110
+ method,
111
+ headers,
112
+ body: config.data ? JSON.stringify(config.data) : undefined,
113
+ signal: AbortSignal.timeout(this.timeout),
114
+ };
115
+ if (this.dispatcher) {
116
+ init.dispatcher = this.dispatcher;
106
117
  }
107
118
 
108
- let response: Response;
109
- try {
110
- response = await fetch(url.toString(), {
111
- method,
112
- headers,
113
- body: config.data ? JSON.stringify(config.data) : undefined,
114
- signal: AbortSignal.timeout(this.timeout),
115
- });
116
- } finally {
117
- // Restore original setting
118
- if (!this.strictSsl) {
119
- if (originalRejectUnauthorized === undefined) {
120
- delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
121
- } else {
122
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalRejectUnauthorized;
123
- }
124
- }
125
- }
119
+ const response = await fetch(url.toString(), init);
126
120
 
127
121
  logger.info('Omada response', {
128
122
  method,
@@ -5,7 +5,7 @@
5
5
  import type { IncomingMessage, ServerResponse } from 'node:http';
6
6
  import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
7
7
  import { verifyJwt, type JwtPayload } from '../utils/jwt.js';
8
- import { getUserPermissions, type PermissionsConfig } from '../permissions/index.js';
8
+ import { getUserPermissions, Role, type PermissionsConfig } from '../permissions/index.js';
9
9
  import { logger } from '../utils/logger.js';
10
10
 
11
11
  export type AuthMethod = 'none' | 'bearer';
@@ -15,8 +15,17 @@ export interface AuthConfig {
15
15
  secret?: string;
16
16
  permissions?: PermissionsConfig;
17
17
  skipPaths?: string[];
18
+ /** Reject tokens without an exp claim (H3). */
19
+ requireExp?: boolean;
20
+ /** Expected JWT issuer, if any (L6). */
21
+ issuer?: string;
22
+ /** Expected JWT audience, if any (L6). */
23
+ audience?: string;
18
24
  }
19
25
 
26
+ /** Emit the "no exp" advisory at most once per process. */
27
+ let warnedNoExp = false;
28
+
20
29
  export interface AuthResult {
21
30
  authenticated: boolean;
22
31
  payload?: JwtPayload;
@@ -32,7 +41,18 @@ export interface AuthenticatedRequest extends IncomingMessage {
32
41
  /**
33
42
  * Validate JWT from Authorization header
34
43
  */
35
- function validateJwt(req: IncomingMessage, secret: string, permConfig?: PermissionsConfig): AuthResult {
44
+ interface ClaimPolicy {
45
+ requireExp?: boolean;
46
+ issuer?: string;
47
+ audience?: string;
48
+ }
49
+
50
+ function validateJwt(
51
+ req: IncomingMessage,
52
+ secret: string,
53
+ permConfig?: PermissionsConfig,
54
+ policy: ClaimPolicy = {}
55
+ ): AuthResult {
36
56
  const authHeader = req.headers.authorization;
37
57
 
38
58
  if (!authHeader) {
@@ -44,16 +64,30 @@ function validateJwt(req: IncomingMessage, secret: string, permConfig?: Permissi
44
64
  }
45
65
 
46
66
  const token = authHeader.slice(7);
47
- const result = verifyJwt(token, secret);
67
+ const result = verifyJwt(token, secret, {
68
+ requireExp: policy.requireExp,
69
+ issuer: policy.issuer,
70
+ audience: policy.audience,
71
+ clockSkewSec: 60,
72
+ });
48
73
 
49
74
  if (!result.valid) {
50
75
  return { authenticated: false, error: result.error };
51
76
  }
52
77
 
53
- // Get user permissions from config
78
+ // H3: warn (once) when accepting a token that can never expire.
79
+ if (!policy.requireExp && result.payload?.exp === undefined && !warnedNoExp) {
80
+ warnedNoExp = true;
81
+ logger.warn('Accepting a JWT without an exp claim; set MCP_AUTH_REQUIRE_EXP=true to enforce token expiry');
82
+ }
83
+
84
+ // Get user permissions from config.
85
+ // L3: fail closed when no permission map is configured — an authenticated token
86
+ // with no RBAC mapping gets NONE, never full access. (In the real server path
87
+ // `permConfig` is always supplied by loadConfig, so this is defense in depth.)
54
88
  const permissions = permConfig
55
89
  ? getUserPermissions(result.payload?.sub, permConfig)
56
- : 0xFF; // No config = all permissions
90
+ : Role.NONE;
57
91
 
58
92
  return { authenticated: true, payload: result.payload, permissions };
59
93
  }
@@ -63,7 +97,7 @@ function validateJwt(req: IncomingMessage, secret: string, permConfig?: Permissi
63
97
  * Returns true if request should proceed, false if blocked
64
98
  */
65
99
  export function createAuthMiddleware(config: AuthConfig): (req: IncomingMessage, res: ServerResponse) => boolean {
66
- const { method, secret, permissions: permConfig, skipPaths = [] } = config;
100
+ const { method, secret, permissions: permConfig, skipPaths = [], requireExp, issuer, audience } = config;
67
101
 
68
102
  return (req: IncomingMessage, res: ServerResponse): boolean => {
69
103
  if (method === 'none') {
@@ -95,7 +129,7 @@ export function createAuthMiddleware(config: AuthConfig): (req: IncomingMessage,
95
129
  return false;
96
130
  }
97
131
 
98
- const result = validateJwt(req, secret, permConfig);
132
+ const result = validateJwt(req, secret, permConfig, { requireExp, issuer, audience });
99
133
 
100
134
  if (!result.authenticated) {
101
135
  logger.warn('Auth failed', { path, error: result.error, ip: req.socket.remoteAddress });
@@ -4,8 +4,12 @@
4
4
  */
5
5
 
6
6
  import type { IncomingMessage, ServerResponse } from 'node:http';
7
+ import { randomUUID } from 'node:crypto';
7
8
  import type { EventSubscriber, HaEvent } from '../haClient/events.js';
8
9
  import { logger } from '../utils/logger.js';
10
+ import { sanitizeError } from '../utils/sanitizeError.js';
11
+ import { Permission, hasPermission } from '../permissions/index.js';
12
+ import type { AuthenticatedRequest } from './auth.js';
9
13
 
10
14
  export interface SSEClient {
11
15
  id: string;
@@ -22,6 +26,17 @@ export interface SSEClient {
22
26
  // Store active SSE clients
23
27
  const clients = new Map<string, SSEClient>();
24
28
 
29
+ /**
30
+ * Generate an unguessable SSE client ID (L5).
31
+ *
32
+ * Uses a CSPRNG (randomUUID) rather than Math.random()/Date.now(), so a client
33
+ * ID cannot be predicted or enumerated by an observer who knows roughly when a
34
+ * connection was opened.
35
+ */
36
+ export function generateClientId(): string {
37
+ return randomUUID();
38
+ }
39
+
25
40
  /**
26
41
  * Parse query parameters from URL
27
42
  */
@@ -64,7 +79,17 @@ export async function handleEventSubscription(
64
79
  res: ServerResponse,
65
80
  eventSubscriber: EventSubscriber
66
81
  ): Promise<void> {
67
- const clientId = `sse_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
82
+ // L2: the SSE stream reads entity state — require QUERY, like every other read.
83
+ // Auth middleware has already populated req.auth; fail closed if the mask is absent.
84
+ const userPermissions = ((req as AuthenticatedRequest).auth?.extra?.permissions as number | undefined) ?? 0;
85
+ if (!hasPermission(userPermissions, Permission.QUERY)) {
86
+ logger.warn('SSE subscription denied: missing QUERY permission');
87
+ res.writeHead(403, { 'Content-Type': 'application/json' });
88
+ res.end(JSON.stringify({ error: 'Forbidden', message: 'SSE event subscription requires QUERY permission' }));
89
+ return;
90
+ }
91
+
92
+ const clientId = generateClientId();
68
93
  const params = parseQueryParams(req.url ?? '');
69
94
 
70
95
  // Parse filter parameters
@@ -72,14 +97,12 @@ export async function handleEventSubscription(
72
97
  const entityId = params.get('entity_id') ?? undefined;
73
98
  const eventTypesParam = params.get('event_types');
74
99
  const eventTypes = eventTypesParam ? eventTypesParam.split(',').map(t => t.trim()) : undefined;
75
- const token = params.get('token');
76
100
 
77
101
  logger.info('New SSE subscription request', {
78
102
  clientId,
79
103
  domain,
80
104
  entityId,
81
105
  eventTypes,
82
- hasToken: !!token,
83
106
  });
84
107
 
85
108
  // Set SSE headers
@@ -119,9 +142,10 @@ export async function handleEventSubscription(
119
142
  await eventSubscriber.connect();
120
143
  } catch (error) {
121
144
  logger.error('Failed to connect event subscriber', { error });
145
+ // M6: log detail above; the SSE client gets a generic message only.
122
146
  sendSSEEvent(res, 'error', {
123
147
  error: 'Failed to connect to Home Assistant',
124
- message: error instanceof Error ? error.message : String(error),
148
+ message: sanitizeError(error),
125
149
  });
126
150
  res.end();
127
151
  clients.delete(clientId);
@@ -191,9 +215,10 @@ export async function handleEventSubscription(
191
215
  });
192
216
  } catch (error) {
193
217
  logger.error('Failed to subscribe to events', { error, clientId });
218
+ // M6: log detail above; the SSE client gets a generic message only.
194
219
  sendSSEEvent(res, 'error', {
195
220
  error: 'Failed to subscribe to events',
196
- message: error instanceof Error ? error.message : String(error),
221
+ message: sanitizeError(error),
197
222
  });
198
223
  }
199
224