@coffeerunhobby/mcp-ha-connect 1.2.0 → 1.3.1
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/permissions/index.js +20 -5
- package/dist/permissions/index.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 +66 -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/permissions/index.ts +22 -5
- 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/permissions/index.test.ts +21 -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/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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-client TLS dispatcher helper (SEC-CRYPTO / H2).
|
|
3
|
+
*
|
|
4
|
+
* When a client must talk to a host with a self-signed certificate, the ONLY
|
|
5
|
+
* acceptable way to relax certificate validation is to scope it to that single
|
|
6
|
+
* client. The previous implementation toggled the process-global
|
|
7
|
+
* `NODE_TLS_REJECT_UNAUTHORIZED` env var around each `fetch` call, which
|
|
8
|
+
* disables TLS verification for EVERY concurrent outbound request in the
|
|
9
|
+
* process for the duration of the call — a TOCTOU race that silently strips
|
|
10
|
+
* certificate validation from unrelated Home Assistant / AI traffic.
|
|
11
|
+
*
|
|
12
|
+
* Node's global `fetch` (undici) ignores the legacy `agent` option; the
|
|
13
|
+
* supported mechanism is a per-call `dispatcher`. We build one undici Agent per
|
|
14
|
+
* client instance and pass it on every request, leaving the process-global TLS
|
|
15
|
+
* policy untouched.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Agent, type Dispatcher } from 'undici';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `fetch` init augmented with undici's per-request dispatcher option, which is
|
|
22
|
+
* not present in the lib.dom / @types/node `RequestInit` typings.
|
|
23
|
+
*/
|
|
24
|
+
export type FetchInitWithDispatcher = RequestInit & { dispatcher?: Dispatcher };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build an undici dispatcher that disables TLS certificate validation, scoped
|
|
28
|
+
* to a single client instance. Returns `undefined` when `strictSsl` is not
|
|
29
|
+
* explicitly disabled, so strict clients carry no dispatcher and use Node's
|
|
30
|
+
* default (validating) global agent.
|
|
31
|
+
*
|
|
32
|
+
* @param strictSsl - When `false`, certificate validation is disabled for this
|
|
33
|
+
* client only. Any other value (including `undefined`) keeps validation on.
|
|
34
|
+
*/
|
|
35
|
+
export function createTlsDispatcher(strictSsl: boolean | undefined): Dispatcher | undefined {
|
|
36
|
+
if (strictSsl === false) {
|
|
37
|
+
return new Agent({ connect: { rejectUnauthorized: false } });
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -211,6 +211,9 @@ describeFeature(feature, ({ Scenario }) => {
|
|
|
211
211
|
HA_URL: 'http://homeassistant.local:8123',
|
|
212
212
|
HA_TOKEN: 'test-token',
|
|
213
213
|
MCP_HTTP_BIND_ADDR: '192.168.1.100',
|
|
214
|
+
// H5: a non-loopback bind requires authentication.
|
|
215
|
+
MCP_AUTH_METHOD: 'bearer',
|
|
216
|
+
MCP_AUTH_SECRET: 'test-secret-key-32chars-minimum!',
|
|
214
217
|
};
|
|
215
218
|
});
|
|
216
219
|
|
package/tests/config.test.ts
CHANGED
|
@@ -175,6 +175,9 @@ describe('loadConfig', () => {
|
|
|
175
175
|
HA_URL: 'http://homeassistant.10.0.0.19.nip.io:8123',
|
|
176
176
|
HA_TOKEN: 'test-token-12345',
|
|
177
177
|
MCP_HTTP_BIND_ADDR: '192.168.1.100',
|
|
178
|
+
// H5: a non-loopback bind requires authentication.
|
|
179
|
+
MCP_AUTH_METHOD: 'bearer',
|
|
180
|
+
MCP_AUTH_SECRET: 'test-secret-key-32chars-minimum!',
|
|
178
181
|
};
|
|
179
182
|
|
|
180
183
|
const config = loadConfig(env);
|
|
@@ -143,6 +143,27 @@ describe('getUserPermissions', () => {
|
|
|
143
143
|
const configWithDefault = { ...config, defaultRole: 'READONLY' as const };
|
|
144
144
|
expect(getUserPermissions('unknown', configWithDefault)).toBe(Role.READONLY);
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
it('should resolve a lowercase defaultRole (case-insensitive)', () => {
|
|
148
|
+
// Real-world config commonly uses lowercase ("operator"); it must resolve to
|
|
149
|
+
// the OPERATOR mask, not silently fall through to an empty/undefined mask.
|
|
150
|
+
const cfg = { users: [], defaultRole: 'operator' as unknown as 'OPERATOR' };
|
|
151
|
+
expect(getUserPermissions('unlisted-sub', cfg)).toBe(Role.OPERATOR);
|
|
152
|
+
expect(getUserPermissions(undefined, cfg)).toBe(Role.OPERATOR);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should resolve a lowercase per-user role (case-insensitive)', () => {
|
|
156
|
+
const cfg = {
|
|
157
|
+
users: [{ sub: 'claude', role: 'admin' as unknown as 'ADMIN' }],
|
|
158
|
+
defaultRole: 'NONE' as const,
|
|
159
|
+
};
|
|
160
|
+
expect(getUserPermissions('claude', cfg)).toBe(Role.ADMIN);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should fail closed to NONE for an unrecognized role name', () => {
|
|
164
|
+
const cfg = { users: [], defaultRole: 'wizard' as unknown as 'ADMIN' };
|
|
165
|
+
expect(getUserPermissions('anyone', cfg)).toBe(Role.NONE);
|
|
166
|
+
});
|
|
146
167
|
});
|
|
147
168
|
|
|
148
169
|
describe('parsePermissionsConfig', () => {
|
|
@@ -9,13 +9,13 @@ import { registerAllResources } from '../../src/resources/index.js';
|
|
|
9
9
|
|
|
10
10
|
// Create mock server that captures registered resources
|
|
11
11
|
function createMockServer() {
|
|
12
|
-
const resources = new Map<string, { config: unknown; handler:
|
|
12
|
+
const resources = new Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }>();
|
|
13
13
|
return {
|
|
14
|
-
resource: vi.fn((name: string, uriOrTemplate: unknown, config: unknown, handler:
|
|
14
|
+
resource: vi.fn((name: string, uriOrTemplate: unknown, config: unknown, handler: (...args: unknown[]) => unknown) => {
|
|
15
15
|
resources.set(name, { config, handler });
|
|
16
16
|
}),
|
|
17
17
|
resources,
|
|
18
|
-
} as unknown as McpServer & { resources: Map<string, { config: unknown; handler:
|
|
18
|
+
} as unknown as McpServer & { resources: Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }> };
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
// Create mock HaClient
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-AC — Access Control / Authorization
|
|
3
|
+
* OWASP A01:2021 (Broken Access Control), API1/API5:2023 (BOLA/BFLA)
|
|
4
|
+
*
|
|
5
|
+
* Findings covered:
|
|
6
|
+
* H1 — REST /api/* routes bypass RBAC (authenticated but no permission check).
|
|
7
|
+
*
|
|
8
|
+
* These tests assert the SECURE behavior: every REST route is gated on the same
|
|
9
|
+
* Permission.* bit as its MCP-tool twin, and a token lacking that bit is rejected
|
|
10
|
+
* with 403 BEFORE any Home Assistant call is made.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
14
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
15
|
+
import type { HaClient } from '../../src/haClient/index.js';
|
|
16
|
+
import { Permission } from '../../src/permissions/index.js';
|
|
17
|
+
import { requiredRestPermission, handleRestApi } from '../../src/server/http.js';
|
|
18
|
+
import { handleEventSubscription, getClientCount } from '../../src/server/eventSubscription.js';
|
|
19
|
+
import type { EventSubscriber } from '../../src/haClient/events.js';
|
|
20
|
+
import type { AuthenticatedRequest } from '../../src/server/auth.js';
|
|
21
|
+
|
|
22
|
+
/** Build a mock request carrying a permission mask in auth.extra. */
|
|
23
|
+
function mockReq(
|
|
24
|
+
method: string,
|
|
25
|
+
url: string,
|
|
26
|
+
permissions: number,
|
|
27
|
+
body?: unknown
|
|
28
|
+
): AuthenticatedRequest {
|
|
29
|
+
const listeners: Record<string, ((arg?: unknown) => void)[]> = {};
|
|
30
|
+
const req = {
|
|
31
|
+
method,
|
|
32
|
+
url,
|
|
33
|
+
headers: { host: 'mcp.test:3000' },
|
|
34
|
+
auth: { token: 't', clientId: 'u', scopes: [], extra: { permissions } },
|
|
35
|
+
on(event: string, cb: (arg?: unknown) => void) {
|
|
36
|
+
(listeners[event] ??= []).push(cb);
|
|
37
|
+
// Drive the body stream synchronously on the next tick for POST parseBody().
|
|
38
|
+
if (event === 'end') {
|
|
39
|
+
queueMicrotask(() => {
|
|
40
|
+
if (body !== undefined) {
|
|
41
|
+
for (const d of listeners['data'] ?? []) d(Buffer.from(JSON.stringify(body)));
|
|
42
|
+
}
|
|
43
|
+
for (const e of listeners['end'] ?? []) e();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return req;
|
|
47
|
+
},
|
|
48
|
+
} as unknown as AuthenticatedRequest;
|
|
49
|
+
return req;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Capture status code and JSON body written to the response. */
|
|
53
|
+
function mockRes(): ServerResponse & { _status?: number; _body?: unknown } {
|
|
54
|
+
const res = {
|
|
55
|
+
statusCode: 200,
|
|
56
|
+
headersSent: false,
|
|
57
|
+
setHeader: vi.fn(),
|
|
58
|
+
end: vi.fn(function (this: { _body?: unknown }, payload?: unknown) {
|
|
59
|
+
if (typeof payload === 'string') {
|
|
60
|
+
try {
|
|
61
|
+
this._body = JSON.parse(payload);
|
|
62
|
+
} catch {
|
|
63
|
+
this._body = payload;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return this;
|
|
67
|
+
}),
|
|
68
|
+
} as unknown as ServerResponse & { _status?: number; _body?: unknown };
|
|
69
|
+
// mirror statusCode into _status when sendJson sets it
|
|
70
|
+
Object.defineProperty(res, '_status', {
|
|
71
|
+
get() {
|
|
72
|
+
return res.statusCode;
|
|
73
|
+
},
|
|
74
|
+
configurable: true,
|
|
75
|
+
});
|
|
76
|
+
return res;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('SEC-AC — Access Control', () => {
|
|
80
|
+
describe('H1: REST route → permission mapping (mirrors MCP-tool twins)', () => {
|
|
81
|
+
it('maps read routes to QUERY', () => {
|
|
82
|
+
expect(requiredRestPermission('GET', '/api/states')).toBe(Permission.QUERY);
|
|
83
|
+
expect(requiredRestPermission('GET', '/api/states/light.kitchen')).toBe(Permission.QUERY);
|
|
84
|
+
expect(requiredRestPermission('GET', '/api/sensors')).toBe(Permission.QUERY);
|
|
85
|
+
expect(requiredRestPermission('GET', '/api/entities/light')).toBe(Permission.QUERY);
|
|
86
|
+
expect(requiredRestPermission('GET', '/api/search')).toBe(Permission.QUERY);
|
|
87
|
+
expect(requiredRestPermission('GET', '/api/history/sensor.temp')).toBe(Permission.QUERY);
|
|
88
|
+
expect(requiredRestPermission('GET', '/api/version')).toBe(Permission.QUERY);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('maps the service-call route to CONTROL', () => {
|
|
92
|
+
expect(requiredRestPermission('POST', '/api/services/light/turn_on')).toBe(Permission.CONTROL);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns null for unknown routes (fall through to 404, no auth needed)', () => {
|
|
96
|
+
expect(requiredRestPermission('GET', '/api/unknown')).toBeNull();
|
|
97
|
+
expect(requiredRestPermission('DELETE', '/api/states')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('H1: enforcement — insufficient permission is rejected with 403', () => {
|
|
102
|
+
it('blocks GET /api/states without QUERY and never calls the client', async () => {
|
|
103
|
+
const client = { getStates: vi.fn().mockResolvedValue([]) } as unknown as HaClient;
|
|
104
|
+
const req = mockReq('GET', '/api/states', Permission.CONTROL); // CONTROL, not QUERY
|
|
105
|
+
const res = mockRes();
|
|
106
|
+
|
|
107
|
+
await handleRestApi(req, res, '/api/states', client);
|
|
108
|
+
|
|
109
|
+
expect(res.statusCode).toBe(403);
|
|
110
|
+
expect(client.getStates).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('blocks POST /api/services without CONTROL and never calls the service', async () => {
|
|
114
|
+
const client = { callService: vi.fn().mockResolvedValue({ ok: true }) } as unknown as HaClient;
|
|
115
|
+
const req = mockReq('POST', '/api/services/light/turn_on', Permission.QUERY, {
|
|
116
|
+
entity_id: 'light.kitchen',
|
|
117
|
+
});
|
|
118
|
+
const res = mockRes();
|
|
119
|
+
|
|
120
|
+
await handleRestApi(req, res, '/api/services/light/turn_on', client);
|
|
121
|
+
|
|
122
|
+
expect(res.statusCode).toBe(403);
|
|
123
|
+
expect(client.callService).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('H1: enforcement — sufficient permission is allowed', () => {
|
|
128
|
+
it('allows GET /api/states with QUERY', async () => {
|
|
129
|
+
const client = { getStates: vi.fn().mockResolvedValue([{ entity_id: 'light.x' }]) } as unknown as HaClient;
|
|
130
|
+
const req = mockReq('GET', '/api/states', Permission.QUERY);
|
|
131
|
+
const res = mockRes();
|
|
132
|
+
|
|
133
|
+
await handleRestApi(req, res, '/api/states', client);
|
|
134
|
+
|
|
135
|
+
expect(res.statusCode).toBe(200);
|
|
136
|
+
expect(client.getStates).toHaveBeenCalledOnce();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('allows POST /api/services with CONTROL', async () => {
|
|
140
|
+
const client = { callService: vi.fn().mockResolvedValue({ ok: true }) } as unknown as HaClient;
|
|
141
|
+
const req = mockReq('POST', '/api/services/light/turn_on', Permission.CONTROL, {
|
|
142
|
+
entity_id: 'light.kitchen',
|
|
143
|
+
});
|
|
144
|
+
const res = mockRes();
|
|
145
|
+
|
|
146
|
+
await handleRestApi(req, res, '/api/services/light/turn_on', client);
|
|
147
|
+
|
|
148
|
+
expect(res.statusCode).toBe(200);
|
|
149
|
+
expect(client.callService).toHaveBeenCalledOnce();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('treats SUPERUSER (0xFF) as allowed everywhere', async () => {
|
|
153
|
+
const client = { getStates: vi.fn().mockResolvedValue([]) } as unknown as HaClient;
|
|
154
|
+
const req = mockReq('GET', '/api/states', 0xff);
|
|
155
|
+
const res = mockRes();
|
|
156
|
+
|
|
157
|
+
await handleRestApi(req, res, '/api/states', client);
|
|
158
|
+
|
|
159
|
+
expect(res.statusCode).toBe(200);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('L2: SSE /subscribe_events requires QUERY permission', () => {
|
|
164
|
+
/** Minimal SSE-capable response mock. */
|
|
165
|
+
function sseRes() {
|
|
166
|
+
return {
|
|
167
|
+
writableEnded: false,
|
|
168
|
+
writeHead: vi.fn(),
|
|
169
|
+
write: vi.fn(),
|
|
170
|
+
end: vi.fn(),
|
|
171
|
+
} as unknown as ServerResponse & { writeHead: ReturnType<typeof vi.fn> };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Request mock that captures the 'close' handler so we can tear down timers. */
|
|
175
|
+
function sseReq(permissions: number) {
|
|
176
|
+
const handlers: Record<string, (arg?: unknown) => void> = {};
|
|
177
|
+
const req = {
|
|
178
|
+
url: '/subscribe_events',
|
|
179
|
+
headers: {},
|
|
180
|
+
auth: { token: 't', clientId: 'u', scopes: [], extra: { permissions } },
|
|
181
|
+
on(event: string, cb: (arg?: unknown) => void) {
|
|
182
|
+
handlers[event] = cb;
|
|
183
|
+
return req;
|
|
184
|
+
},
|
|
185
|
+
} as unknown as AuthenticatedRequest;
|
|
186
|
+
return { req, handlers };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
it('rejects a subscriber lacking QUERY with 403 and creates no client', async () => {
|
|
190
|
+
const before = getClientCount();
|
|
191
|
+
const { req } = sseReq(Permission.CONTROL); // CONTROL, not QUERY
|
|
192
|
+
const res = sseRes();
|
|
193
|
+
const subscriber = { isConnected: vi.fn() } as unknown as EventSubscriber;
|
|
194
|
+
|
|
195
|
+
await handleEventSubscription(req, res, subscriber);
|
|
196
|
+
|
|
197
|
+
expect(res.writeHead).toHaveBeenCalledWith(403, expect.anything());
|
|
198
|
+
expect(getClientCount()).toBe(before); // no leak
|
|
199
|
+
expect(subscriber.isConnected).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('allows a subscriber with QUERY to open the stream', async () => {
|
|
203
|
+
const { req, handlers } = sseReq(Permission.QUERY);
|
|
204
|
+
const res = sseRes();
|
|
205
|
+
const subscriber = {
|
|
206
|
+
isConnected: vi.fn().mockReturnValue(true),
|
|
207
|
+
subscribeEventType: vi.fn().mockResolvedValue('sub-1'),
|
|
208
|
+
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
209
|
+
} as unknown as EventSubscriber;
|
|
210
|
+
|
|
211
|
+
await handleEventSubscription(req, res, subscriber);
|
|
212
|
+
|
|
213
|
+
// 200 stream opened, not 403
|
|
214
|
+
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
|
215
|
+
'Content-Type': 'text/event-stream',
|
|
216
|
+
}));
|
|
217
|
+
expect(subscriber.subscribeEventType).toHaveBeenCalled();
|
|
218
|
+
|
|
219
|
+
// tear down the keep-alive interval the handler registered
|
|
220
|
+
handlers['close']?.();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-AUTHN — Authentication & Session
|
|
3
|
+
* OWASP A07:2021 (Identification & Authentication Failures), API2:2023
|
|
4
|
+
*
|
|
5
|
+
* Findings covered:
|
|
6
|
+
* H3 — tokens without `exp` never expire; expiry is optional and unenforceable.
|
|
7
|
+
* M8 — JWT secret strength is not enforced at startup.
|
|
8
|
+
* L6 — no `iss` / `aud` / `nbf` / clock-skew validation.
|
|
9
|
+
*
|
|
10
|
+
* Secure behavior asserted here:
|
|
11
|
+
* - verifyJwt can REQUIRE exp (opt-in), validate nbf, and check iss/aud when configured.
|
|
12
|
+
* - The default (no options) stays backward-compatible: a token without exp is accepted.
|
|
13
|
+
* - loadConfig refuses a bearer secret shorter than 32 chars.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import { createJwt, verifyJwt } from '../../src/utils/jwt.js';
|
|
18
|
+
import { loadConfig } from '../../src/config.js';
|
|
19
|
+
|
|
20
|
+
const SECRET = 'test-secret-key-32chars-minimum!';
|
|
21
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
22
|
+
|
|
23
|
+
describe('SEC-AUTHN — Authentication', () => {
|
|
24
|
+
describe('H3: enforce token expiry (opt-in via requireExp)', () => {
|
|
25
|
+
it('rejects a token without exp when requireExp is set', () => {
|
|
26
|
+
const token = createJwt({ sub: 'service' }, SECRET);
|
|
27
|
+
const result = verifyJwt(token, SECRET, { requireExp: true });
|
|
28
|
+
expect(result.valid).toBe(false);
|
|
29
|
+
expect(result.error).toMatch(/exp/i);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('accepts a token with a future exp when requireExp is set', () => {
|
|
33
|
+
const token = createJwt({ sub: 'u', exp: now() + 3600 }, SECRET);
|
|
34
|
+
expect(verifyJwt(token, SECRET, { requireExp: true }).valid).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('still rejects an expired token when requireExp is set', () => {
|
|
38
|
+
const token = createJwt({ sub: 'u', exp: now() - 100 }, SECRET);
|
|
39
|
+
expect(verifyJwt(token, SECRET, { requireExp: true }).valid).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('default (no options) remains backward-compatible: accepts no-exp token', () => {
|
|
43
|
+
const token = createJwt({ sub: 'service' }, SECRET);
|
|
44
|
+
expect(verifyJwt(token, SECRET).valid).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('L6: nbf (not-before) validation', () => {
|
|
49
|
+
it('rejects a token whose nbf is in the future', () => {
|
|
50
|
+
const token = createJwt({ sub: 'u', nbf: now() + 3600 }, SECRET);
|
|
51
|
+
const result = verifyJwt(token, SECRET);
|
|
52
|
+
expect(result.valid).toBe(false);
|
|
53
|
+
expect(result.error).toMatch(/not yet valid|nbf/i);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('accepts a token whose nbf is in the past', () => {
|
|
57
|
+
const token = createJwt({ sub: 'u', nbf: now() - 100 }, SECRET);
|
|
58
|
+
expect(verifyJwt(token, SECRET).valid).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('tolerates small clock skew on nbf', () => {
|
|
62
|
+
const token = createJwt({ sub: 'u', nbf: now() + 30 }, SECRET);
|
|
63
|
+
expect(verifyJwt(token, SECRET, { clockSkewSec: 60 }).valid).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('L6: issuer / audience validation (only when configured)', () => {
|
|
68
|
+
it('rejects a token with the wrong issuer', () => {
|
|
69
|
+
const token = createJwt({ sub: 'u', iss: 'evil' }, SECRET);
|
|
70
|
+
const result = verifyJwt(token, SECRET, { issuer: 'mcp-ha-connect' });
|
|
71
|
+
expect(result.valid).toBe(false);
|
|
72
|
+
expect(result.error).toMatch(/issuer/i);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('accepts a token with the expected issuer', () => {
|
|
76
|
+
const token = createJwt({ sub: 'u', iss: 'mcp-ha-connect' }, SECRET);
|
|
77
|
+
expect(verifyJwt(token, SECRET, { issuer: 'mcp-ha-connect' }).valid).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects a token whose audience does not match', () => {
|
|
81
|
+
const token = createJwt({ sub: 'u', aud: 'other' }, SECRET);
|
|
82
|
+
const result = verifyJwt(token, SECRET, { audience: 'mcp' });
|
|
83
|
+
expect(result.valid).toBe(false);
|
|
84
|
+
expect(result.error).toMatch(/audience/i);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('accepts a token whose audience array contains the expected value', () => {
|
|
88
|
+
const token = createJwt({ sub: 'u', aud: ['mcp', 'other'] }, SECRET);
|
|
89
|
+
expect(verifyJwt(token, SECRET, { audience: 'mcp' }).valid).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('ignores iss/aud when not configured (backward-compatible)', () => {
|
|
93
|
+
const token = createJwt({ sub: 'u', iss: 'whatever', aud: 'whatever' }, SECRET);
|
|
94
|
+
expect(verifyJwt(token, SECRET).valid).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('M8: bearer secret strength enforced at startup', () => {
|
|
99
|
+
const base = { MCP_SERVER_USE_HTTP: 'true', MCP_AUTH_METHOD: 'bearer' };
|
|
100
|
+
|
|
101
|
+
it('rejects a bearer secret shorter than 32 characters', () => {
|
|
102
|
+
expect(() =>
|
|
103
|
+
loadConfig({ ...base, MCP_AUTH_SECRET: 'too-short' } as NodeJS.ProcessEnv)
|
|
104
|
+
).toThrow(/32|secret/i);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('accepts a bearer secret of at least 32 characters', () => {
|
|
108
|
+
expect(() =>
|
|
109
|
+
loadConfig({ ...base, MCP_AUTH_SECRET: 'a'.repeat(32) } as NodeJS.ProcessEnv)
|
|
110
|
+
).not.toThrow();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('does not require a secret when auth method is none', () => {
|
|
114
|
+
expect(() => loadConfig({ MCP_AUTH_METHOD: 'none' } as NodeJS.ProcessEnv)).not.toThrow();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|