@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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-DOS — Unrestricted Resource Consumption
|
|
3
|
+
* OWASP A04 (Rate Limiting) / API4:2023 (Unrestricted Resource Consumption).
|
|
4
|
+
*
|
|
5
|
+
* Findings covered:
|
|
6
|
+
* M4 — the rate limiter keyed on the spoofable X-Forwarded-For / X-Real-IP
|
|
7
|
+
* headers, letting an attacker forge a new IP per request to evade limits
|
|
8
|
+
* AND grow the bucket Map without bound. Secure behavior: trust forwarding
|
|
9
|
+
* headers ONLY when the immediate peer is a configured trusted proxy;
|
|
10
|
+
* otherwise key on the socket address.
|
|
11
|
+
* M5 — request bodies had no size cap and the server set no slowloris timeouts.
|
|
12
|
+
* Secure behavior: parseBody rejects oversize bodies (413) and destroys the
|
|
13
|
+
* socket; the server is created with explicit request/headers/keep-alive
|
|
14
|
+
* timeouts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
18
|
+
import type { IncomingMessage } from 'node:http';
|
|
19
|
+
import { RateLimiter } from '../../src/server/rateLimiter.js';
|
|
20
|
+
import { parseBody, MAX_BODY_BYTES } from '../../src/server/http.js';
|
|
21
|
+
|
|
22
|
+
/** Build a fake request with controllable headers and socket address. */
|
|
23
|
+
function fakeReq(opts: {
|
|
24
|
+
url?: string;
|
|
25
|
+
remoteAddress?: string;
|
|
26
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
27
|
+
}): IncomingMessage {
|
|
28
|
+
return {
|
|
29
|
+
url: opts.url ?? '/mcp',
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: opts.headers ?? {},
|
|
32
|
+
socket: { remoteAddress: opts.remoteAddress ?? '203.0.113.7' },
|
|
33
|
+
} as unknown as IncomingMessage;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('SEC-DOS — Unrestricted Resource Consumption', () => {
|
|
37
|
+
describe('M4: rate limiter ignores spoofable forwarding headers by default', () => {
|
|
38
|
+
it('keys on the socket address, not a forged X-Forwarded-For', () => {
|
|
39
|
+
const rl = new RateLimiter({ maxRequests: 2, windowMs: 60000 });
|
|
40
|
+
|
|
41
|
+
// Same socket, attacker rotates X-Forwarded-For each request to dodge limits.
|
|
42
|
+
const mk = (xff: string) =>
|
|
43
|
+
fakeReq({ remoteAddress: '198.51.100.5', headers: { 'x-forwarded-for': xff } });
|
|
44
|
+
|
|
45
|
+
expect(rl.check(mk('1.1.1.1')).allowed).toBe(true);
|
|
46
|
+
expect(rl.check(mk('2.2.2.2')).allowed).toBe(true);
|
|
47
|
+
// Third request from the same socket must be blocked despite a fresh XFF.
|
|
48
|
+
expect(rl.check(mk('3.3.3.3')).allowed).toBe(false);
|
|
49
|
+
|
|
50
|
+
rl.stop();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not create unbounded buckets from forged headers', () => {
|
|
54
|
+
const rl = new RateLimiter({ maxRequests: 1000, windowMs: 60000 });
|
|
55
|
+
for (let i = 0; i < 50; i++) {
|
|
56
|
+
rl.check(fakeReq({ remoteAddress: '198.51.100.9', headers: { 'x-forwarded-for': `10.0.0.${i}` } }));
|
|
57
|
+
}
|
|
58
|
+
// All 50 requests shared ONE socket → exactly one bucket, not 50.
|
|
59
|
+
expect(rl.getStats().totalEntries).toBe(1);
|
|
60
|
+
rl.stop();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('honors CF-Connecting-IP only when the peer is a trusted proxy', () => {
|
|
64
|
+
const rl = new RateLimiter({
|
|
65
|
+
maxRequests: 100,
|
|
66
|
+
windowMs: 60000,
|
|
67
|
+
trustedProxies: ['198.51.100.1'],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Peer IS the trusted proxy → distinct client IPs get distinct buckets.
|
|
71
|
+
rl.check(fakeReq({ remoteAddress: '198.51.100.1', headers: { 'cf-connecting-ip': 'a.a.a.a' } }));
|
|
72
|
+
rl.check(fakeReq({ remoteAddress: '198.51.100.1', headers: { 'cf-connecting-ip': 'b.b.b.b' } }));
|
|
73
|
+
expect(rl.getStats().totalEntries).toBe(2);
|
|
74
|
+
|
|
75
|
+
rl.stop();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('ignores forwarding headers when the peer is NOT trusted', () => {
|
|
79
|
+
const rl = new RateLimiter({
|
|
80
|
+
maxRequests: 100,
|
|
81
|
+
windowMs: 60000,
|
|
82
|
+
trustedProxies: ['198.51.100.1'],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Peer is some random host claiming to be a proxy → headers ignored, one bucket.
|
|
86
|
+
rl.check(fakeReq({ remoteAddress: '203.0.113.50', headers: { 'cf-connecting-ip': 'a.a.a.a' } }));
|
|
87
|
+
rl.check(fakeReq({ remoteAddress: '203.0.113.50', headers: { 'cf-connecting-ip': 'b.b.b.b' } }));
|
|
88
|
+
expect(rl.getStats().totalEntries).toBe(1);
|
|
89
|
+
|
|
90
|
+
rl.stop();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('M5: request body size cap', () => {
|
|
95
|
+
/** Minimal EventEmitter-like request that streams the given chunks. */
|
|
96
|
+
function streamingReq(chunks: Buffer[]): IncomingMessage & { destroy: ReturnType<typeof vi.fn> } {
|
|
97
|
+
const handlers: Record<string, ((arg?: unknown) => void)[]> = {};
|
|
98
|
+
const req = {
|
|
99
|
+
destroy: vi.fn(),
|
|
100
|
+
on(event: string, cb: (arg?: unknown) => void) {
|
|
101
|
+
(handlers[event] ??= []).push(cb);
|
|
102
|
+
if (event === 'end') {
|
|
103
|
+
queueMicrotask(() => {
|
|
104
|
+
for (const chunk of chunks) {
|
|
105
|
+
for (const d of handlers['data'] ?? []) d(chunk);
|
|
106
|
+
}
|
|
107
|
+
for (const e of handlers['end'] ?? []) e();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return req;
|
|
111
|
+
},
|
|
112
|
+
} as unknown as IncomingMessage & { destroy: ReturnType<typeof vi.fn> };
|
|
113
|
+
return req;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it('exposes a sane default body cap (1 MB)', () => {
|
|
117
|
+
expect(MAX_BODY_BYTES).toBe(1024 * 1024);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('parses a small JSON body normally', async () => {
|
|
121
|
+
const req = streamingReq([Buffer.from(JSON.stringify({ hello: 'world' }))]);
|
|
122
|
+
await expect(parseBody(req)).resolves.toEqual({ hello: 'world' });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('rejects an oversize body with a 413 error and destroys the socket', async () => {
|
|
126
|
+
const big = Buffer.alloc(2048, 0x61); // 2 KB
|
|
127
|
+
const req = streamingReq([big]);
|
|
128
|
+
|
|
129
|
+
await expect(parseBody(req, 1024)).rejects.toMatchObject({ statusCode: 413 });
|
|
130
|
+
expect(req.destroy).toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -9,13 +9,13 @@ import { registerAnalyzeSensorsTool } from '../../../src/tools/ai/analyzeSensors
|
|
|
9
9
|
|
|
10
10
|
// Create mock server that captures registered handlers
|
|
11
11
|
function createMockServer() {
|
|
12
|
-
const handlers = new Map<string, { config: unknown; handler:
|
|
12
|
+
const handlers = new Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }>();
|
|
13
13
|
return {
|
|
14
|
-
registerTool: vi.fn((name: string, config: unknown, handler:
|
|
14
|
+
registerTool: vi.fn((name: string, config: unknown, handler: (...args: unknown[]) => unknown) => {
|
|
15
15
|
handlers.set(name, { config, handler });
|
|
16
16
|
}),
|
|
17
17
|
handlers,
|
|
18
|
-
} as unknown as McpServer & { handlers: Map<string, { config: unknown; handler:
|
|
18
|
+
} as unknown as McpServer & { handlers: Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }> };
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
// Create mock LocalAIClient
|
|
@@ -76,16 +76,17 @@ describe('wrapToolHandler permission checks', () => {
|
|
|
76
76
|
expect(result.isError).toBe(false);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
it('should
|
|
79
|
+
it('should fail closed (deny) when authInfo is not set (L3)', async () => {
|
|
80
80
|
const handler = vi.fn().mockResolvedValue(toToolResult('success'));
|
|
81
81
|
const wrapped = wrapToolHandler('testTool', handler, Permission.ADMIN);
|
|
82
82
|
|
|
83
|
-
//
|
|
83
|
+
// A missing permission mask must DENY, not default-allow. The trusted-local
|
|
84
|
+
// stdio path opts into full trust separately (see insecureDesign.test.ts).
|
|
84
85
|
const extra = createExtra(undefined);
|
|
85
86
|
const result = await wrapped({}, extra);
|
|
86
87
|
|
|
87
|
-
expect(handler).toHaveBeenCalled();
|
|
88
|
-
expect(result.isError).toBe(
|
|
88
|
+
expect(handler).not.toHaveBeenCalled();
|
|
89
|
+
expect(result.isError).toBe(true);
|
|
89
90
|
});
|
|
90
91
|
|
|
91
92
|
it('should allow access when no permission required', async () => {
|
|
@@ -82,9 +82,9 @@ function parseResult(result: { content: { text: string }[] }): unknown {
|
|
|
82
82
|
|
|
83
83
|
// Mock server that captures registered handlers
|
|
84
84
|
function createMockServer() {
|
|
85
|
-
const handlers: Map<string,
|
|
85
|
+
const handlers: Map<string, (...args: unknown[]) => unknown> = new Map();
|
|
86
86
|
return {
|
|
87
|
-
registerTool: vi.fn((name: string, _config: any, handler:
|
|
87
|
+
registerTool: vi.fn((name: string, _config: any, handler: (...args: unknown[]) => unknown) => {
|
|
88
88
|
handlers.set(name, handler);
|
|
89
89
|
}),
|
|
90
90
|
getHandler: (name: string) => handlers.get(name),
|
|
@@ -36,13 +36,13 @@ import { registerGetFirewallSettingTool } from '../../../src/tools/omada/getFire
|
|
|
36
36
|
|
|
37
37
|
// Create mock server that captures registered handlers
|
|
38
38
|
function createMockServer() {
|
|
39
|
-
const handlers = new Map<string, { config: unknown; handler:
|
|
39
|
+
const handlers = new Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }>();
|
|
40
40
|
return {
|
|
41
|
-
registerTool: vi.fn((name: string, config: unknown, handler:
|
|
41
|
+
registerTool: vi.fn((name: string, config: unknown, handler: (...args: unknown[]) => unknown) => {
|
|
42
42
|
handlers.set(name, { config, handler });
|
|
43
43
|
}),
|
|
44
44
|
handlers,
|
|
45
|
-
} as unknown as McpServer & { handlers: Map<string, { config: unknown; handler:
|
|
45
|
+
} as unknown as McpServer & { handlers: Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }> };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Create mock OmadaClient
|