@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
@@ -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, getClientCount } from './eventSubscription.js';
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
- * Parse JSON body from request
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
- function parseBody(req: IncomingMessage): Promise<unknown> {
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
- let body = '';
61
- req.on('data', (chunk) => (body += chunk.toString()));
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 (!body) {
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(body));
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, config: EnvironmentConfig, aiEnabled: boolean, eventsEnabled: boolean): void {
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: error instanceof Error ? error.message : String(error),
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, config, !!aiClient, !!eventSubscriber);
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: error instanceof Error ? error.message : String(error),
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.keyGenerator = config.keyGenerator ?? this.defaultKeyGenerator;
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 - uses IP address
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
- // Try to get real IP from common proxy headers
47
- const forwardedFor = req.headers['x-forwarded-for'];
48
- if (forwardedFor) {
49
- const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
50
- return ips.split(',')[0].trim();
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 Array.isArray(realIp) ? realIp[0] : realIp;
88
+ return realIp;
56
89
  }
57
90
 
58
- // Fall back to socket remote address
59
- return req.socket.remoteAddress ?? 'unknown';
91
+ return socketAddr;
60
92
  }
61
93
 
62
94
  /**
@@ -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
 
@@ -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: config.httpAllowedOrigins,
49
- enableDnsRebindingProtection: true,
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
  },
@@ -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
- const userPermissions = (extra.authInfo?.extra?.permissions as number | undefined) ?? 0xFF;
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
- // Check expiration
56
- if (payload.exp && payload.exp < Date.now() / 1000) {
57
- return { valid: false, error: 'expired' };
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
+ }