@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.
- package/.env.example +52 -16
- package/CLAUDE.md +75 -0
- package/Dockerfile +5 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +59 -3
- package/dist/config.js.map +1 -1
- package/dist/haClient/automations.js +2 -2
- package/dist/haClient/automations.js.map +1 -1
- package/dist/haClient/calendars.js +1 -1
- package/dist/haClient/calendars.js.map +1 -1
- package/dist/haClient/request.d.ts +2 -1
- package/dist/haClient/request.js +20 -11
- package/dist/haClient/request.js.map +1 -1
- package/dist/haClient/services.js +1 -1
- package/dist/haClient/services.js.map +1 -1
- package/dist/haClient/states.js +1 -1
- package/dist/haClient/states.js.map +1 -1
- package/dist/omadaClient/auth.d.ts +2 -1
- package/dist/omadaClient/auth.js +19 -30
- package/dist/omadaClient/auth.js.map +1 -1
- package/dist/omadaClient/request.d.ts +2 -1
- package/dist/omadaClient/request.js +18 -27
- package/dist/omadaClient/request.js.map +1 -1
- package/dist/server/auth.d.ts +6 -0
- package/dist/server/auth.js +22 -10
- package/dist/server/auth.js.map +1 -1
- package/dist/server/eventSubscription.d.ts +8 -0
- package/dist/server/eventSubscription.js +27 -5
- package/dist/server/eventSubscription.js.map +1 -1
- package/dist/server/http.d.ts +46 -0
- package/dist/server/http.js +143 -28
- package/dist/server/http.js.map +1 -1
- package/dist/server/rateLimiter.d.ts +16 -1
- package/dist/server/rateLimiter.js +32 -11
- package/dist/server/rateLimiter.js.map +1 -1
- package/dist/server/stdio.js +5 -0
- package/dist/server/stdio.js.map +1 -1
- package/dist/server/stream.d.ts +25 -0
- package/dist/server/stream.js +53 -2
- package/dist/server/stream.js.map +1 -1
- package/dist/tools/common.d.ts +11 -10
- package/dist/tools/common.js +20 -2
- package/dist/tools/common.js.map +1 -1
- package/dist/utils/config-validations.d.ts +7 -0
- package/dist/utils/config-validations.js +17 -0
- package/dist/utils/config-validations.js.map +1 -1
- package/dist/utils/jwt.d.ts +15 -1
- package/dist/utils/jwt.js +29 -4
- package/dist/utils/jwt.js.map +1 -1
- package/dist/utils/sanitizeError.d.ts +1 -0
- package/dist/utils/sanitizeError.js +18 -0
- package/dist/utils/sanitizeError.js.map +1 -0
- package/dist/utils/tlsDispatcher.d.ts +34 -0
- package/dist/utils/tlsDispatcher.js +33 -0
- package/dist/utils/tlsDispatcher.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docker-compose.yml +4 -1
- package/docs/CHANGELOG.md +58 -0
- package/openapi.json +1 -1
- package/package.json +4 -3
- package/src/config.ts +83 -3
- package/src/haClient/automations.ts +2 -2
- package/src/haClient/calendars.ts +1 -1
- package/src/haClient/request.ts +23 -11
- package/src/haClient/services.ts +1 -1
- package/src/haClient/states.ts +1 -1
- package/src/omadaClient/auth.ts +20 -28
- package/src/omadaClient/request.ts +19 -25
- package/src/server/auth.ts +41 -7
- package/src/server/eventSubscription.ts +30 -5
- package/src/server/http.ts +165 -29
- package/src/server/rateLimiter.ts +43 -11
- package/src/server/stdio.ts +6 -0
- package/src/server/stream.ts +65 -2
- package/src/tools/common.ts +23 -2
- package/src/utils/config-validations.ts +15 -0
- package/src/utils/jwt.ts +47 -4
- package/src/utils/sanitizeError.ts +18 -0
- package/src/utils/tlsDispatcher.ts +40 -0
- package/src/version.ts +1 -1
- package/tests/bdd/configuration.spec.ts +3 -0
- package/tests/config.test.ts +3 -0
- package/tests/resources/index.test.ts +3 -3
- package/tests/security/accessControl.test.ts +223 -0
- package/tests/security/authentication.test.ts +117 -0
- package/tests/security/configuration.test.ts +170 -0
- package/tests/security/cryptoTransport.test.ts +136 -0
- package/tests/security/dependencies.test.ts +152 -0
- package/tests/security/injection.test.ts +117 -0
- package/tests/security/insecureDesign.test.ts +122 -0
- package/tests/security/logging.test.ts +131 -0
- package/tests/security/resourceConsumption.test.ts +133 -0
- package/tests/tools/ai/analyzeSensors.test.ts +3 -3
- package/tests/tools/common.test.ts +5 -4
- package/tests/tools/homeassistant/handlers.test.ts +2 -2
- 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
|
-
|
|
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
|
|
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(),
|
package/src/haClient/request.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* Handles authentication and HTTP requests
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
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
|
-
|
|
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
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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();
|
package/src/haClient/services.ts
CHANGED
|
@@ -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,
|
package/src/haClient/states.ts
CHANGED
|
@@ -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) {
|
package/src/omadaClient/auth.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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,
|
package/src/server/auth.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
221
|
+
message: sanitizeError(error),
|
|
197
222
|
});
|
|
198
223
|
}
|
|
199
224
|
|