@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/server/http.ts
CHANGED
|
@@ -11,9 +11,11 @@ import type { LocalAIClient } from '../localAI/index.js';
|
|
|
11
11
|
import type { OmadaClient } from '../omadaClient/index.js';
|
|
12
12
|
import { logger } from '../utils/logger.js';
|
|
13
13
|
import { handleStreamRequest, type StreamTransportState, type StreamTransportOptions } from './stream.js';
|
|
14
|
-
import { handleEventSubscription
|
|
14
|
+
import { handleEventSubscription } from './eventSubscription.js';
|
|
15
15
|
import { RateLimiter } from './rateLimiter.js';
|
|
16
|
-
import { createAuthMiddleware } from './auth.js';
|
|
16
|
+
import { createAuthMiddleware, type AuthenticatedRequest } from './auth.js';
|
|
17
|
+
import { Permission, hasPermission, getPermissionNames } from '../permissions/index.js';
|
|
18
|
+
import { sanitizeError } from '../utils/sanitizeError.js';
|
|
17
19
|
import { VERSION } from '../version.js';
|
|
18
20
|
|
|
19
21
|
// Session storage for stateful mode
|
|
@@ -53,27 +55,83 @@ function wrapResponseWithTiming(res: ServerResponse): { res: ServerResponse; sta
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/**
|
|
56
|
-
*
|
|
58
|
+
* Maximum accepted request body size, in bytes (M5 / OWASP API4:2023).
|
|
59
|
+
* 1 MB is comfortably above any legitimate MCP/JSON-RPC or REST payload.
|
|
57
60
|
*/
|
|
58
|
-
|
|
61
|
+
export const MAX_BODY_BYTES = 1024 * 1024;
|
|
62
|
+
|
|
63
|
+
/** Error carrying an HTTP status for the request handler to surface. */
|
|
64
|
+
interface HttpError extends Error {
|
|
65
|
+
statusCode?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse a JSON request body, enforcing a hard size cap (M5).
|
|
70
|
+
*
|
|
71
|
+
* If the streamed body exceeds `maxBytes` the promise rejects with a 413-tagged
|
|
72
|
+
* error and the socket is destroyed, so an attacker cannot exhaust memory by
|
|
73
|
+
* streaming an unbounded payload. Buffering stops the moment the cap is crossed.
|
|
74
|
+
*/
|
|
75
|
+
export function parseBody(req: IncomingMessage, maxBytes: number = MAX_BODY_BYTES): Promise<unknown> {
|
|
59
76
|
return new Promise((resolve, reject) => {
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
const chunks: Buffer[] = [];
|
|
78
|
+
let size = 0;
|
|
79
|
+
let aborted = false;
|
|
80
|
+
|
|
81
|
+
req.on('data', (chunk: Buffer | string) => {
|
|
82
|
+
if (aborted) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
86
|
+
size += buf.length;
|
|
87
|
+
if (size > maxBytes) {
|
|
88
|
+
aborted = true;
|
|
89
|
+
const err: HttpError = new Error('Request body too large');
|
|
90
|
+
err.statusCode = 413;
|
|
91
|
+
reject(err);
|
|
92
|
+
req.destroy();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
chunks.push(buf);
|
|
96
|
+
});
|
|
97
|
+
|
|
62
98
|
req.on('end', () => {
|
|
63
|
-
if (
|
|
99
|
+
if (aborted) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (chunks.length === 0) {
|
|
64
103
|
resolve(undefined);
|
|
65
104
|
return;
|
|
66
105
|
}
|
|
67
106
|
try {
|
|
68
|
-
resolve(JSON.parse(
|
|
107
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
69
108
|
} catch (error) {
|
|
70
109
|
reject(new Error(`Invalid JSON: ${(error as Error).message}`));
|
|
71
110
|
}
|
|
72
111
|
});
|
|
112
|
+
|
|
73
113
|
req.on('error', reject);
|
|
74
114
|
});
|
|
75
115
|
}
|
|
76
116
|
|
|
117
|
+
/** Extract a numeric HTTP status from a thrown value, if present. */
|
|
118
|
+
function errorStatusCode(error: unknown): number | undefined {
|
|
119
|
+
const code = (error as HttpError | undefined)?.statusCode;
|
|
120
|
+
return typeof code === 'number' ? code : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Apply baseline security response headers (L4 / OWASP A05:2021).
|
|
125
|
+
*
|
|
126
|
+
* Cheap, static defenses applied to every response: block MIME sniffing,
|
|
127
|
+
* forbid framing (clickjacking), and avoid leaking full URLs in the Referer.
|
|
128
|
+
*/
|
|
129
|
+
export function applySecurityHeaders(res: ServerResponse): void {
|
|
130
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
131
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
132
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
133
|
+
}
|
|
134
|
+
|
|
77
135
|
/**
|
|
78
136
|
* Send JSON response with proper Content-Length header
|
|
79
137
|
*/
|
|
@@ -86,24 +144,16 @@ function sendJson(res: ServerResponse, statusCode: number, data: unknown): void
|
|
|
86
144
|
}
|
|
87
145
|
|
|
88
146
|
/**
|
|
89
|
-
* Handle health check endpoint
|
|
147
|
+
* Handle health check endpoint (M7 / OWASP A09:2021).
|
|
148
|
+
*
|
|
149
|
+
* `/health` is unauthenticated (it is on the auth skip-list so container/orchestrator
|
|
150
|
+
* probes can reach it). It therefore returns liveness ONLY — never version, auth
|
|
151
|
+
* method, AI provider URLs, client counts, or any other internal configuration that
|
|
152
|
+
* would aid an unauthenticated attacker fingerprinting the deployment. Operators who
|
|
153
|
+
* need that detail can query the authenticated MCP tools (e.g. `getVersion`).
|
|
90
154
|
*/
|
|
91
|
-
function handleHealthCheck(res: ServerResponse
|
|
92
|
-
sendJson(res, 200, {
|
|
93
|
-
status: 'healthy',
|
|
94
|
-
version: VERSION,
|
|
95
|
-
transport: 'stream',
|
|
96
|
-
stateful: config.stateful,
|
|
97
|
-
authMethod: config.authMethod,
|
|
98
|
-
aiEnabled,
|
|
99
|
-
aiProvider: aiEnabled ? config.aiProvider : undefined,
|
|
100
|
-
aiUrl: aiEnabled ? config.aiUrl : undefined,
|
|
101
|
-
aiModel: aiEnabled ? config.aiModel : undefined,
|
|
102
|
-
eventsEnabled,
|
|
103
|
-
eventsPath: eventsEnabled ? config.sseEventsPath : undefined,
|
|
104
|
-
eventsConnectedClients: eventsEnabled ? getClientCount() : undefined,
|
|
105
|
-
rateLimitEnabled: config.rateLimitEnabled,
|
|
106
|
-
});
|
|
155
|
+
export function handleHealthCheck(res: ServerResponse): void {
|
|
156
|
+
sendJson(res, 200, { status: 'healthy' });
|
|
107
157
|
}
|
|
108
158
|
|
|
109
159
|
/**
|
|
@@ -117,11 +167,44 @@ function addRestApiCors(req: IncomingMessage, res: ServerResponse): void {
|
|
|
117
167
|
res.setHeader('Access-Control-Max-Age', '86400');
|
|
118
168
|
}
|
|
119
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Resolve the permission a REST `/api/*` route requires.
|
|
172
|
+
*
|
|
173
|
+
* Each route mirrors the `Permission.*` bit of its MCP-tool twin so the REST
|
|
174
|
+
* bridge cannot be used to bypass RBAC (finding H1 / OWASP A01:2021, BFLA).
|
|
175
|
+
* Read routes require QUERY; the service-call route requires CONTROL (matching
|
|
176
|
+
* the `callService` tool). Unknown routes return `null` — the handler falls
|
|
177
|
+
* through to a 404, which needs no permission.
|
|
178
|
+
*/
|
|
179
|
+
export function requiredRestPermission(method: string, pathname: string): number | null {
|
|
180
|
+
const m = method.toUpperCase();
|
|
181
|
+
|
|
182
|
+
if (m === 'GET') {
|
|
183
|
+
if (
|
|
184
|
+
pathname === '/api/states' ||
|
|
185
|
+
pathname === '/api/sensors' ||
|
|
186
|
+
pathname === '/api/search' ||
|
|
187
|
+
pathname === '/api/version' ||
|
|
188
|
+
/^\/api\/states\/[^/]+$/.test(pathname) ||
|
|
189
|
+
/^\/api\/entities\/[^/]+$/.test(pathname) ||
|
|
190
|
+
/^\/api\/history\/[^/]+$/.test(pathname)
|
|
191
|
+
) {
|
|
192
|
+
return Permission.QUERY;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (m === 'POST' && /^\/api\/services\/[^/]+\/[^/]+$/.test(pathname)) {
|
|
197
|
+
return Permission.CONTROL;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
120
203
|
/**
|
|
121
204
|
* Handle REST API requests for Open WebUI compatibility
|
|
122
205
|
* These endpoints translate REST calls to Home Assistant API calls
|
|
123
206
|
*/
|
|
124
|
-
async function handleRestApi(
|
|
207
|
+
export async function handleRestApi(
|
|
125
208
|
req: IncomingMessage,
|
|
126
209
|
res: ServerResponse,
|
|
127
210
|
url: string,
|
|
@@ -133,6 +216,26 @@ async function handleRestApi(
|
|
|
133
216
|
const parsedUrl = new URL(url, `http://${req.headers.host}`);
|
|
134
217
|
const pathname = parsedUrl.pathname;
|
|
135
218
|
|
|
219
|
+
// H1: enforce RBAC on REST routes before touching Home Assistant.
|
|
220
|
+
const required = requiredRestPermission(req.method ?? 'GET', pathname);
|
|
221
|
+
if (required !== null) {
|
|
222
|
+
// Fail closed: a missing mask grants nothing.
|
|
223
|
+
const userPermissions = ((req as AuthenticatedRequest).auth?.extra?.permissions as number | undefined) ?? 0;
|
|
224
|
+
if (!hasPermission(userPermissions, required)) {
|
|
225
|
+
logger.warn('REST API permission denied', {
|
|
226
|
+
method: req.method,
|
|
227
|
+
path: pathname,
|
|
228
|
+
required: getPermissionNames(required),
|
|
229
|
+
has: getPermissionNames(userPermissions),
|
|
230
|
+
});
|
|
231
|
+
sendJson(res, 403, {
|
|
232
|
+
error: 'Forbidden',
|
|
233
|
+
message: `This endpoint requires permission: ${getPermissionNames(required).join(', ')}`,
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
136
239
|
try {
|
|
137
240
|
// GET /api/states - Get all entity states
|
|
138
241
|
if (req.method === 'GET' && pathname === '/api/states') {
|
|
@@ -229,10 +332,18 @@ async function handleRestApi(
|
|
|
229
332
|
// Not found
|
|
230
333
|
sendJson(res, 404, { error: 'API endpoint not found' });
|
|
231
334
|
} catch (error) {
|
|
335
|
+
// M5: a body that exceeded the size cap surfaces as 413, not a generic 500.
|
|
336
|
+
if (errorStatusCode(error) === 413) {
|
|
337
|
+
if (!res.headersSent) {
|
|
338
|
+
sendJson(res, 413, { error: 'Payload Too Large', message: 'Request body exceeds the size limit' });
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
232
342
|
logger.error('REST API error', { error, url });
|
|
343
|
+
// M6: log the detail above; never echo raw error text to the client.
|
|
233
344
|
sendJson(res, 500, {
|
|
234
345
|
error: 'Internal server error',
|
|
235
|
-
message:
|
|
346
|
+
message: sanitizeError(error),
|
|
236
347
|
});
|
|
237
348
|
}
|
|
238
349
|
}
|
|
@@ -437,6 +548,8 @@ export async function startHttpServer(options: HttpServerOptions): Promise<void>
|
|
|
437
548
|
windowMs: config.rateLimitWindowMs,
|
|
438
549
|
maxRequests: config.rateLimitMaxRequests,
|
|
439
550
|
skipPaths: [healthPath, '/openapi.json'],
|
|
551
|
+
// M4: only trust forwarding headers from configured proxies.
|
|
552
|
+
trustedProxies: config.rateLimitTrustedProxies,
|
|
440
553
|
})
|
|
441
554
|
: null;
|
|
442
555
|
|
|
@@ -446,6 +559,9 @@ export async function startHttpServer(options: HttpServerOptions): Promise<void>
|
|
|
446
559
|
secret: config.authSecret,
|
|
447
560
|
permissions: config.permissions,
|
|
448
561
|
skipPaths: [healthPath, '/openapi.json'],
|
|
562
|
+
requireExp: config.authRequireExp,
|
|
563
|
+
issuer: config.authIssuer,
|
|
564
|
+
audience: config.authAudience,
|
|
449
565
|
});
|
|
450
566
|
|
|
451
567
|
// Initialize event subscriber if SSE events are enabled and HA is configured
|
|
@@ -485,6 +601,9 @@ export async function startHttpServer(options: HttpServerOptions): Promise<void>
|
|
|
485
601
|
// Wrap response to add Server-Timing header
|
|
486
602
|
const { res } = wrapResponseWithTiming(originalRes);
|
|
487
603
|
|
|
604
|
+
// L4: baseline security headers on every response.
|
|
605
|
+
applySecurityHeaders(res);
|
|
606
|
+
|
|
488
607
|
const url = req.url ?? '/';
|
|
489
608
|
const urlPath = url.split('?')[0];
|
|
490
609
|
|
|
@@ -519,7 +638,7 @@ export async function startHttpServer(options: HttpServerOptions): Promise<void>
|
|
|
519
638
|
try {
|
|
520
639
|
// Health check endpoint
|
|
521
640
|
if (config.httpEnableHealthcheck && url === healthPath) {
|
|
522
|
-
handleHealthCheck(res
|
|
641
|
+
handleHealthCheck(res);
|
|
523
642
|
return;
|
|
524
643
|
}
|
|
525
644
|
|
|
@@ -570,6 +689,14 @@ export async function startHttpServer(options: HttpServerOptions): Promise<void>
|
|
|
570
689
|
// Not found
|
|
571
690
|
sendJson(res, 404, { error: 'Not found' });
|
|
572
691
|
} catch (error) {
|
|
692
|
+
// M5: oversize body → 413 rather than a generic 500.
|
|
693
|
+
if (errorStatusCode(error) === 413) {
|
|
694
|
+
if (!res.headersSent) {
|
|
695
|
+
sendJson(res, 413, { error: 'Payload Too Large', message: 'Request body exceeds the size limit' });
|
|
696
|
+
}
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
573
700
|
logger.error('HTTP request error', {
|
|
574
701
|
error,
|
|
575
702
|
method: req.method,
|
|
@@ -577,14 +704,23 @@ export async function startHttpServer(options: HttpServerOptions): Promise<void>
|
|
|
577
704
|
});
|
|
578
705
|
|
|
579
706
|
if (!res.headersSent) {
|
|
707
|
+
// M6: detail is logged above; the client gets only a generic message.
|
|
580
708
|
sendJson(res, 500, {
|
|
581
709
|
error: 'Internal server error',
|
|
582
|
-
message:
|
|
710
|
+
message: sanitizeError(error),
|
|
583
711
|
});
|
|
584
712
|
}
|
|
585
713
|
}
|
|
586
714
|
});
|
|
587
715
|
|
|
716
|
+
// M5: bound how long a client may take to deliver headers/body so a slow-loris
|
|
717
|
+
// connection cannot tie up a socket indefinitely. These cap request *intake*
|
|
718
|
+
// time, not response duration, so long-lived SSE streams are unaffected.
|
|
719
|
+
server.headersTimeout = 30_000; // 30s to send all request headers
|
|
720
|
+
server.requestTimeout = 60_000; // 60s to deliver the complete request
|
|
721
|
+
server.keepAliveTimeout = 5_000; // 5s idle on a keep-alive connection
|
|
722
|
+
server.maxRequestsPerSocket = 0; // unlimited (default); explicit for clarity
|
|
723
|
+
|
|
588
724
|
// Graceful shutdown handler
|
|
589
725
|
let isShuttingDown = false;
|
|
590
726
|
|
|
@@ -11,6 +11,13 @@ export interface RateLimitConfig {
|
|
|
11
11
|
maxRequests?: number; // Maximum requests per window (default: 100)
|
|
12
12
|
skipPaths?: string[]; // Paths to skip rate limiting (e.g., health checks)
|
|
13
13
|
keyGenerator?: (req: IncomingMessage) => string; // Custom key generator
|
|
14
|
+
/**
|
|
15
|
+
* Immediate-peer IPs whose forwarding headers (CF-Connecting-IP / X-Forwarded-For /
|
|
16
|
+
* X-Real-IP) may be trusted to identify the real client (M4). Empty by default —
|
|
17
|
+
* with no trusted proxy, client-supplied headers are IGNORED and the socket
|
|
18
|
+
* address is used, so a forged header cannot evade limits or grow the bucket map.
|
|
19
|
+
*/
|
|
20
|
+
trustedProxies?: string[];
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
interface RateLimitEntry {
|
|
@@ -26,6 +33,7 @@ export class RateLimiter {
|
|
|
26
33
|
private readonly maxRequests: number;
|
|
27
34
|
private readonly skipPaths: Set<string>;
|
|
28
35
|
private readonly keyGenerator: (req: IncomingMessage) => string;
|
|
36
|
+
private readonly trustedProxies: Set<string>;
|
|
29
37
|
private readonly entries = new Map<string, RateLimitEntry>();
|
|
30
38
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
|
31
39
|
|
|
@@ -33,30 +41,54 @@ export class RateLimiter {
|
|
|
33
41
|
this.windowMs = config.windowMs ?? 60000; // 1 minute default
|
|
34
42
|
this.maxRequests = config.maxRequests ?? 100; // 100 requests per minute
|
|
35
43
|
this.skipPaths = new Set(config.skipPaths ?? ['/health', '/healthcheck']);
|
|
36
|
-
this.
|
|
44
|
+
this.trustedProxies = new Set(config.trustedProxies ?? []);
|
|
45
|
+
// Bind so a custom generator OR the default both see `this`.
|
|
46
|
+
this.keyGenerator = config.keyGenerator ?? ((req) => this.defaultKeyGenerator(req));
|
|
37
47
|
|
|
38
48
|
// Start cleanup interval to prevent memory leaks
|
|
39
49
|
this.startCleanup();
|
|
40
50
|
}
|
|
41
51
|
|
|
52
|
+
/** First value of a possibly-array header, trimmed; undefined if absent/empty. */
|
|
53
|
+
private firstHeader(value: string | string[] | undefined): string | undefined {
|
|
54
|
+
const v = Array.isArray(value) ? value[0] : value;
|
|
55
|
+
const trimmed = v?.trim();
|
|
56
|
+
return trimmed ? trimmed : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
/**
|
|
43
|
-
* Default key generator
|
|
60
|
+
* Default key generator (M4 / OWASP API4:2023).
|
|
61
|
+
*
|
|
62
|
+
* Client-supplied forwarding headers are honored ONLY when the immediate peer
|
|
63
|
+
* (`socket.remoteAddress`) is a configured trusted proxy. Otherwise we key on the
|
|
64
|
+
* socket address — an attacker cannot forge that, so they can neither evade the
|
|
65
|
+
* limit by rotating X-Forwarded-For nor exhaust memory with unbounded forged keys.
|
|
44
66
|
*/
|
|
45
67
|
private defaultKeyGenerator(req: IncomingMessage): string {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
68
|
+
const socketAddr = req.socket?.remoteAddress ?? 'unknown';
|
|
69
|
+
|
|
70
|
+
if (!this.trustedProxies.has(socketAddr)) {
|
|
71
|
+
return socketAddr;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Behind a trusted proxy: prefer Cloudflare's single-client CF-Connecting-IP,
|
|
75
|
+
// then the left-most X-Forwarded-For hop, then X-Real-IP.
|
|
76
|
+
const cf = this.firstHeader(req.headers['cf-connecting-ip']);
|
|
77
|
+
if (cf) {
|
|
78
|
+
return cf;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const xff = this.firstHeader(req.headers['x-forwarded-for']);
|
|
82
|
+
if (xff) {
|
|
83
|
+
return xff.split(',')[0].trim();
|
|
51
84
|
}
|
|
52
85
|
|
|
53
|
-
const realIp = req.headers['x-real-ip'];
|
|
86
|
+
const realIp = this.firstHeader(req.headers['x-real-ip']);
|
|
54
87
|
if (realIp) {
|
|
55
|
-
return
|
|
88
|
+
return realIp;
|
|
56
89
|
}
|
|
57
90
|
|
|
58
|
-
|
|
59
|
-
return req.socket.remoteAddress ?? 'unknown';
|
|
91
|
+
return socketAddr;
|
|
60
92
|
}
|
|
61
93
|
|
|
62
94
|
/**
|
package/src/server/stdio.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { HaClient } from '../haClient/index.js';
|
|
|
8
8
|
import type { LocalAIClient } from '../localAI/index.js';
|
|
9
9
|
import type { OmadaClient } from '../omadaClient/index.js';
|
|
10
10
|
import { logger } from '../utils/logger.js';
|
|
11
|
+
import { setLocalFullTrust } from '../tools/common.js';
|
|
11
12
|
import { createServer } from './common.js';
|
|
12
13
|
|
|
13
14
|
export interface StdioServerOptions {
|
|
@@ -19,6 +20,11 @@ export interface StdioServerOptions {
|
|
|
19
20
|
export async function startStdioServer(options: StdioServerOptions): Promise<void> {
|
|
20
21
|
logger.info('Starting stdio server');
|
|
21
22
|
|
|
23
|
+
// L3: stdio has no per-request auth; the local process owner is trusted. Grant
|
|
24
|
+
// full permissions to tool calls that arrive without an explicit mask. HTTP
|
|
25
|
+
// transports never enable this — they fail closed on a missing mask.
|
|
26
|
+
setLocalFullTrust(true);
|
|
27
|
+
|
|
22
28
|
const server = createServer(options);
|
|
23
29
|
const transport = new StdioServerTransport();
|
|
24
30
|
|
package/src/server/stream.ts
CHANGED
|
@@ -28,6 +28,66 @@ export interface StreamTransportOptions {
|
|
|
28
28
|
config: EnvironmentConfig;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export interface TransportSecurityOptions {
|
|
32
|
+
allowedOrigins: string[];
|
|
33
|
+
allowedHosts: string[];
|
|
34
|
+
enableDnsRebindingProtection: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the DNS-rebinding protection options for the Streamable HTTP transport
|
|
39
|
+
* (M3 / OWASP A05:2021).
|
|
40
|
+
*
|
|
41
|
+
* Host-header validation is **opt-in**. The SDK only validates the `Host` header
|
|
42
|
+
* when `allowedHosts` is non-empty (EXACT string match), and only validates
|
|
43
|
+
* `Origin` when `allowedOrigins` is non-empty. CORS/Origin enforcement already
|
|
44
|
+
* lives in the HTTP layer, so this helper governs ONLY the Host check and leaves
|
|
45
|
+
* `allowedOrigins` empty to avoid double-enforcing origins at the transport.
|
|
46
|
+
*
|
|
47
|
+
* - `MCP_HTTP_ALLOWED_HOSTS` UNSET → Host validation OFF (protection disabled).
|
|
48
|
+
* This preserves pre-1.3.0 behavior: an upgrade never silently 403s clients
|
|
49
|
+
* that reach the server by a host not listed in the (CORS) origins. The
|
|
50
|
+
* server's own reachable host and its allowed CORS origins are frequently
|
|
51
|
+
* different addresses, so deriving hosts from origins would break real setups.
|
|
52
|
+
* - `MCP_HTTP_ALLOWED_HOSTS` SET → enforce exactly those host[:port] values, plus
|
|
53
|
+
* loopback/bind-address conveniences so on-box health checks and local tools
|
|
54
|
+
* keep working. Any other `Host` header is rejected with a 403.
|
|
55
|
+
*/
|
|
56
|
+
export function buildTransportSecurityOptions(config: EnvironmentConfig): TransportSecurityOptions {
|
|
57
|
+
const explicitHosts = config.httpAllowedHosts ?? [];
|
|
58
|
+
|
|
59
|
+
// Opt-out by default: no explicit hosts ⇒ no Host-header validation.
|
|
60
|
+
if (explicitHosts.length === 0) {
|
|
61
|
+
return { allowedOrigins: [], allowedHosts: [], enableDnsRebindingProtection: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const port = config.httpPort ?? 3000;
|
|
65
|
+
const bindAddr = config.httpBindAddr ?? '127.0.0.1';
|
|
66
|
+
const hosts = new Set<string>();
|
|
67
|
+
|
|
68
|
+
for (const entry of explicitHosts) {
|
|
69
|
+
try {
|
|
70
|
+
// Accept a full URL form (e.g. https://example.com) → host header form.
|
|
71
|
+
hosts.add(new URL(entry).host);
|
|
72
|
+
} catch {
|
|
73
|
+
// Bare hostname / IP[:port] already in host-header form.
|
|
74
|
+
hosts.add(entry);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Always permit direct loopback / bind-address access on the listen port.
|
|
79
|
+
for (const host of [bindAddr, 'localhost', '127.0.0.1']) {
|
|
80
|
+
hosts.add(host);
|
|
81
|
+
hosts.add(`${host}:${port}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
allowedOrigins: [],
|
|
86
|
+
allowedHosts: [...hosts],
|
|
87
|
+
enableDnsRebindingProtection: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
31
91
|
/**
|
|
32
92
|
* Creates a Streamable HTTP transport
|
|
33
93
|
* This implements the MCP protocol version 2025-03-26
|
|
@@ -43,10 +103,13 @@ export function createStreamTransport(options: StreamTransportOptions): StreamTr
|
|
|
43
103
|
logger.info('Starting Streamable HTTP transport in stateless mode; Mcp-Session-Id headers are optional');
|
|
44
104
|
}
|
|
45
105
|
|
|
106
|
+
const security = buildTransportSecurityOptions(config);
|
|
107
|
+
|
|
46
108
|
const transport = new StreamableHTTPServerTransport({
|
|
47
109
|
sessionIdGenerator,
|
|
48
|
-
allowedOrigins:
|
|
49
|
-
|
|
110
|
+
allowedOrigins: security.allowedOrigins,
|
|
111
|
+
allowedHosts: security.allowedHosts,
|
|
112
|
+
enableDnsRebindingProtection: security.enableDnsRebindingProtection,
|
|
50
113
|
onsessioninitialized: (sessionId: string) => {
|
|
51
114
|
logger.info('Session initialized', { sessionId });
|
|
52
115
|
},
|
package/src/tools/common.ts
CHANGED
|
@@ -12,6 +12,24 @@ import { Permission, hasPermission, getPermissionNames } from '../permissions/in
|
|
|
12
12
|
// Re-export Permission for tool files
|
|
13
13
|
export { Permission };
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Local-trust flag for the stdio transport (L3 / OWASP A04:2021).
|
|
17
|
+
*
|
|
18
|
+
* Tool calls normally carry a permission mask in `authInfo.extra.permissions`,
|
|
19
|
+
* populated by the HTTP auth middleware (bearer mask, or 0xFF for the loopback
|
|
20
|
+
* `none` mode). When that mask is ABSENT we fail closed — deny — instead of the
|
|
21
|
+
* old default-allow `?? 0xFF`.
|
|
22
|
+
*
|
|
23
|
+
* The one legitimate no-auth path is stdio (Claude Desktop): there is no per-request
|
|
24
|
+
* auth and the process owner is the trusted local user. `startStdioServer` opts into
|
|
25
|
+
* full trust by calling `setLocalFullTrust(true)`. HTTP never enables it.
|
|
26
|
+
*/
|
|
27
|
+
let localFullTrust = false;
|
|
28
|
+
|
|
29
|
+
export function setLocalFullTrust(enabled: boolean): void {
|
|
30
|
+
localFullTrust = enabled;
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
/**
|
|
16
34
|
* Extra context passed to tool handlers
|
|
17
35
|
* Permissions are extracted from authInfo.extra.permissions (MCP SDK standard)
|
|
@@ -55,8 +73,11 @@ export function wrapToolHandler<T>(
|
|
|
55
73
|
): (args: T, extra: ToolExtra) => Promise<CallToolResult> {
|
|
56
74
|
return async (args: T, extra: ToolExtra): Promise<CallToolResult> => {
|
|
57
75
|
const sessionId = extra.sessionId ?? 'unknown-session';
|
|
58
|
-
// Extract permissions from MCP SDK authInfo.extra.permissions
|
|
59
|
-
|
|
76
|
+
// Extract permissions from MCP SDK authInfo.extra.permissions.
|
|
77
|
+
// L3: fail closed when the mask is absent — only the trusted-local stdio path
|
|
78
|
+
// (setLocalFullTrust) grants full permissions without an explicit mask.
|
|
79
|
+
const userPermissions =
|
|
80
|
+
(extra.authInfo?.extra?.permissions as number | undefined) ?? (localFullTrust ? 0xFF : 0);
|
|
60
81
|
|
|
61
82
|
// Check permissions if required
|
|
62
83
|
if (requiredPermission !== undefined && !hasPermission(userPermissions, requiredPermission)) {
|
|
@@ -12,6 +12,21 @@ export function isValidBindAddress(addr: string): boolean {
|
|
|
12
12
|
return isIPv4(addr) || isIPv6(addr);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Returns true if the address is a loopback address (localhost-only).
|
|
17
|
+
*
|
|
18
|
+
* Used to enforce that an unauthenticated server (MCP_AUTH_METHOD=none) may only
|
|
19
|
+
* be bound to a loopback interface — never to `0.0.0.0`/`::` or a LAN address.
|
|
20
|
+
*/
|
|
21
|
+
export function isLoopbackAddress(addr: string): boolean {
|
|
22
|
+
if (!addr) return false;
|
|
23
|
+
const a = addr.toLowerCase();
|
|
24
|
+
if (a === 'localhost' || a === '::1') return true;
|
|
25
|
+
// IPv4 loopback is the whole 127.0.0.0/8 block.
|
|
26
|
+
if (isIPv4(a) && a.startsWith('127.')) return true;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
/**
|
|
16
31
|
* Validates if a string is a valid hostname
|
|
17
32
|
* Allows alphanumeric characters, hyphens, and dots
|
package/src/utils/jwt.ts
CHANGED
|
@@ -9,6 +9,9 @@ export interface JwtPayload {
|
|
|
9
9
|
sub?: string;
|
|
10
10
|
exp?: number;
|
|
11
11
|
iat?: number;
|
|
12
|
+
nbf?: number;
|
|
13
|
+
iss?: string;
|
|
14
|
+
aud?: string | string[];
|
|
12
15
|
[key: string]: unknown;
|
|
13
16
|
}
|
|
14
17
|
|
|
@@ -18,6 +21,18 @@ export interface JwtResult {
|
|
|
18
21
|
error?: string;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
/** Optional claim-validation policy for verifyJwt. */
|
|
25
|
+
export interface JwtVerifyOptions {
|
|
26
|
+
/** Reject tokens that have no `exp` claim (H3). Default: false (backward-compatible). */
|
|
27
|
+
requireExp?: boolean;
|
|
28
|
+
/** Require `iss` to equal this value when set (L6). */
|
|
29
|
+
issuer?: string;
|
|
30
|
+
/** Require `aud` to include this value when set (L6). */
|
|
31
|
+
audience?: string;
|
|
32
|
+
/** Allowable clock skew in seconds for exp/nbf comparisons (L6). Default: 0. */
|
|
33
|
+
clockSkewSec?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
function b64decode(str: string): string {
|
|
22
37
|
const pad = str + '='.repeat((4 - (str.length % 4)) % 4);
|
|
23
38
|
return Buffer.from(pad.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString();
|
|
@@ -27,7 +42,7 @@ function b64encode(data: Buffer): string {
|
|
|
27
42
|
return data.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
28
43
|
}
|
|
29
44
|
|
|
30
|
-
export function verifyJwt(token: string, secret: string): JwtResult {
|
|
45
|
+
export function verifyJwt(token: string, secret: string, options: JwtVerifyOptions = {}): JwtResult {
|
|
31
46
|
const parts = token.split('.');
|
|
32
47
|
if (parts.length !== 3) {
|
|
33
48
|
return { valid: false, error: 'bad format' };
|
|
@@ -52,9 +67,37 @@ export function verifyJwt(token: string, secret: string): JwtResult {
|
|
|
52
67
|
return { valid: false, error: 'bad payload' };
|
|
53
68
|
}
|
|
54
69
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
const now = Date.now() / 1000;
|
|
71
|
+
const skew = options.clockSkewSec ?? 0;
|
|
72
|
+
|
|
73
|
+
// Expiration (H3): enforce when present; optionally require it.
|
|
74
|
+
if (payload.exp !== undefined) {
|
|
75
|
+
if (typeof payload.exp !== 'number' || payload.exp < now - skew) {
|
|
76
|
+
return { valid: false, error: 'expired' };
|
|
77
|
+
}
|
|
78
|
+
} else if (options.requireExp) {
|
|
79
|
+
return { valid: false, error: 'missing exp' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Not-before (L6)
|
|
83
|
+
if (payload.nbf !== undefined) {
|
|
84
|
+
if (typeof payload.nbf !== 'number' || payload.nbf > now + skew) {
|
|
85
|
+
return { valid: false, error: 'not yet valid' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Issuer (L6): only checked when an expected issuer is configured.
|
|
90
|
+
if (options.issuer !== undefined && payload.iss !== options.issuer) {
|
|
91
|
+
return { valid: false, error: 'bad issuer' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Audience (L6): only checked when an expected audience is configured.
|
|
95
|
+
if (options.audience !== undefined) {
|
|
96
|
+
const aud = payload.aud;
|
|
97
|
+
const ok = Array.isArray(aud) ? aud.includes(options.audience) : aud === options.audience;
|
|
98
|
+
if (!ok) {
|
|
99
|
+
return { valid: false, error: 'bad audience' };
|
|
100
|
+
}
|
|
58
101
|
}
|
|
59
102
|
|
|
60
103
|
return { valid: true, payload };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error sanitization for client-facing responses (M6 / OWASP A09:2021).
|
|
3
|
+
*
|
|
4
|
+
* Raw `error.message` values routinely embed infrastructure detail — internal
|
|
5
|
+
* hostnames, ports, filesystem paths, upstream stack fragments, occasionally
|
|
6
|
+
* tokens. None of that belongs in an HTTP response body. Callers should log the
|
|
7
|
+
* full error server-side (via `logger.error`) and send the return of this helper
|
|
8
|
+
* to the client instead.
|
|
9
|
+
*
|
|
10
|
+
* The function intentionally ignores the error's own text: returning a constant
|
|
11
|
+
* generic string is the whole point — there is no "safe subset" of an arbitrary
|
|
12
|
+
* upstream message we can trust to forward.
|
|
13
|
+
*/
|
|
14
|
+
const GENERIC_MESSAGE = 'Internal server error';
|
|
15
|
+
|
|
16
|
+
export function sanitizeError(_error: unknown, fallback: string = GENERIC_MESSAGE): string {
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|