@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.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.
Files changed (281) hide show
  1. package/.env.example +1 -1
  2. package/README.md +11 -2
  3. package/dist/omadaClient/client.d.ts +6 -1
  4. package/dist/omadaClient/client.js +12 -1
  5. package/dist/omadaClient/client.js.map +1 -1
  6. package/dist/omadaClient/diagnose.d.ts +68 -0
  7. package/dist/omadaClient/diagnose.js +164 -0
  8. package/dist/omadaClient/diagnose.js.map +1 -0
  9. package/dist/omadaClient/index.d.ts +6 -0
  10. package/dist/omadaClient/index.js +15 -0
  11. package/dist/omadaClient/index.js.map +1 -1
  12. package/dist/omadaClient/macGroups.d.ts +58 -0
  13. package/dist/omadaClient/macGroups.js +124 -0
  14. package/dist/omadaClient/macGroups.js.map +1 -0
  15. package/dist/tools/infra/actions.d.ts +1 -1
  16. package/dist/tools/infra/actions.js +1 -1
  17. package/dist/tools/omada/index.js +6 -0
  18. package/dist/tools/omada/index.js.map +1 -1
  19. package/dist/tools/omada/macGroups.d.ts +19 -0
  20. package/dist/tools/omada/macGroups.js +77 -0
  21. package/dist/tools/omada/macGroups.js.map +1 -0
  22. package/dist/tools/omada/namespace.js +8 -0
  23. package/dist/tools/omada/namespace.js.map +1 -1
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +5 -1
  27. package/.codex/review-rules.md +0 -16
  28. package/.dockerignore +0 -8
  29. package/.gitattributes +0 -30
  30. package/.github/workflows/ci.yml +0 -193
  31. package/AGENTS.md +0 -389
  32. package/Dockerfile +0 -50
  33. package/docker-compose.yml +0 -23
  34. package/docs/CHANGELOG.md +0 -341
  35. package/docs/COVERAGE.md +0 -219
  36. package/docs/QUICK_START.md +0 -240
  37. package/eslint.config.js +0 -33
  38. package/n8n/ai-agent-safety-monitor.json +0 -355
  39. package/openapi.json +0 -1259
  40. package/scripts/check-version-sync.mjs +0 -69
  41. package/scripts/generate-jwt.ts +0 -121
  42. package/src/config.ts +0 -440
  43. package/src/haClient/automations.ts +0 -261
  44. package/src/haClient/calendars.ts +0 -94
  45. package/src/haClient/config.ts +0 -52
  46. package/src/haClient/devices.ts +0 -653
  47. package/src/haClient/entities.ts +0 -128
  48. package/src/haClient/events.ts +0 -689
  49. package/src/haClient/history.ts +0 -73
  50. package/src/haClient/index.ts +0 -336
  51. package/src/haClient/request.ts +0 -142
  52. package/src/haClient/services.ts +0 -45
  53. package/src/haClient/states.ts +0 -60
  54. package/src/haClient/updates.ts +0 -58
  55. package/src/haClient/wsCommand.ts +0 -44
  56. package/src/index.ts +0 -164
  57. package/src/localAI/index.ts +0 -88
  58. package/src/localAI/prompts.ts +0 -50
  59. package/src/localAI/providers/base.ts +0 -120
  60. package/src/localAI/providers/ollama.ts +0 -78
  61. package/src/localAI/providers/openai.ts +0 -86
  62. package/src/localAI/types.ts +0 -67
  63. package/src/omadaClient/auth.ts +0 -178
  64. package/src/omadaClient/client.ts +0 -337
  65. package/src/omadaClient/device.ts +0 -118
  66. package/src/omadaClient/index.ts +0 -310
  67. package/src/omadaClient/network.ts +0 -284
  68. package/src/omadaClient/request.ts +0 -365
  69. package/src/omadaClient/security.ts +0 -51
  70. package/src/omadaClient/site.ts +0 -344
  71. package/src/ownerQuestions/service.ts +0 -536
  72. package/src/permissions/index.ts +0 -143
  73. package/src/resources/index.ts +0 -213
  74. package/src/server/auth.ts +0 -175
  75. package/src/server/chatFace.ts +0 -789
  76. package/src/server/chatSlice.ts +0 -57
  77. package/src/server/common.ts +0 -170
  78. package/src/server/eventSubscription.ts +0 -343
  79. package/src/server/http.ts +0 -626
  80. package/src/server/instructions.ts +0 -97
  81. package/src/server/rateLimiter.ts +0 -227
  82. package/src/server/stdio.ts +0 -41
  83. package/src/server/stream.ts +0 -205
  84. package/src/tools/ai/analyzeSensors.ts +0 -33
  85. package/src/tools/ai/index.ts +0 -20
  86. package/src/tools/common.ts +0 -442
  87. package/src/tools/homeassistant/askOwner.ts +0 -133
  88. package/src/tools/homeassistant/automationControls.ts +0 -66
  89. package/src/tools/homeassistant/calendar.ts +0 -97
  90. package/src/tools/homeassistant/callService.ts +0 -25
  91. package/src/tools/homeassistant/controlClimate.ts +0 -68
  92. package/src/tools/homeassistant/controlCover.ts +0 -48
  93. package/src/tools/homeassistant/controlFan.ts +0 -44
  94. package/src/tools/homeassistant/controlLight.ts +0 -38
  95. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  96. package/src/tools/homeassistant/createAutomation.ts +0 -35
  97. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  98. package/src/tools/homeassistant/entityAction.ts +0 -35
  99. package/src/tools/homeassistant/entityGuard.ts +0 -28
  100. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  101. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  102. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  103. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  104. package/src/tools/homeassistant/getHistory.ts +0 -24
  105. package/src/tools/homeassistant/getState.ts +0 -24
  106. package/src/tools/homeassistant/getStates.ts +0 -46
  107. package/src/tools/homeassistant/getVersion.ts +0 -21
  108. package/src/tools/homeassistant/index.ts +0 -121
  109. package/src/tools/homeassistant/listAutomations.ts +0 -35
  110. package/src/tools/homeassistant/listEntities.ts +0 -44
  111. package/src/tools/homeassistant/listPersons.ts +0 -46
  112. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  113. package/src/tools/homeassistant/searchEntities.ts +0 -58
  114. package/src/tools/homeassistant/sendNotification.ts +0 -179
  115. package/src/tools/homeassistant/systemTools.ts +0 -52
  116. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  117. package/src/tools/index.ts +0 -78
  118. package/src/tools/infra/actions.ts +0 -95
  119. package/src/tools/infra/index.ts +0 -138
  120. package/src/tools/omada/blockClient.ts +0 -28
  121. package/src/tools/omada/cyclePoePort.ts +0 -32
  122. package/src/tools/omada/deleteClient.ts +0 -27
  123. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  124. package/src/tools/omada/getClient.ts +0 -14
  125. package/src/tools/omada/getDevice.ts +0 -14
  126. package/src/tools/omada/getFirewallSetting.ts +0 -14
  127. package/src/tools/omada/getInternetInfo.ts +0 -14
  128. package/src/tools/omada/getLanNetworkList.ts +0 -14
  129. package/src/tools/omada/getLanProfileList.ts +0 -14
  130. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  131. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  132. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  133. package/src/tools/omada/getSsidDetail.ts +0 -22
  134. package/src/tools/omada/getSsidList.ts +0 -21
  135. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  136. package/src/tools/omada/getThreatList.ts +0 -42
  137. package/src/tools/omada/getWlanGroupList.ts +0 -14
  138. package/src/tools/omada/graph.ts +0 -187
  139. package/src/tools/omada/index.ts +0 -153
  140. package/src/tools/omada/listBlockedClients.ts +0 -17
  141. package/src/tools/omada/listClients.ts +0 -14
  142. package/src/tools/omada/listClientsActivity.ts +0 -25
  143. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  144. package/src/tools/omada/listDevices.ts +0 -14
  145. package/src/tools/omada/listDevicesStats.ts +0 -41
  146. package/src/tools/omada/listMostActiveClients.ts +0 -14
  147. package/src/tools/omada/listSites.ts +0 -18
  148. package/src/tools/omada/namespace.ts +0 -1164
  149. package/src/tools/omada/searchDevices.ts +0 -20
  150. package/src/tools/omada/setClientRateLimit.ts +0 -26
  151. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  152. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  153. package/src/tools/omada/setSsidEnabled.ts +0 -38
  154. package/src/tools/omada/unblockClient.ts +0 -26
  155. package/src/tools/types.ts +0 -11
  156. package/src/tools/utils/pagination-schema.ts +0 -27
  157. package/src/types/ai/index.ts +0 -11
  158. package/src/types/ai/types.ts +0 -67
  159. package/src/types/homeassistant/automations.ts +0 -43
  160. package/src/types/homeassistant/calendars.ts +0 -26
  161. package/src/types/homeassistant/config.ts +0 -14
  162. package/src/types/homeassistant/entities.ts +0 -14
  163. package/src/types/homeassistant/errors.ts +0 -33
  164. package/src/types/homeassistant/haConfig.ts +0 -7
  165. package/src/types/homeassistant/history.ts +0 -13
  166. package/src/types/homeassistant/index.ts +0 -46
  167. package/src/types/homeassistant/request.ts +0 -6
  168. package/src/types/homeassistant/serviceCall.ts +0 -18
  169. package/src/types/homeassistant/states.ts +0 -58
  170. package/src/types/homeassistant/updates.ts +0 -28
  171. package/src/types/index.ts +0 -12
  172. package/src/types/omada/activeClientInfo.ts +0 -17
  173. package/src/types/omada/clientActivity.ts +0 -32
  174. package/src/types/omada/clientBlockStatus.ts +0 -11
  175. package/src/types/omada/clientPastConnection.ts +0 -82
  176. package/src/types/omada/index.ts +0 -14
  177. package/src/types/omada/omadaApiResponse.ts +0 -5
  178. package/src/types/omada/omadaClientInfo.ts +0 -11
  179. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  180. package/src/types/omada/omadaDeviceStats.ts +0 -31
  181. package/src/types/omada/omadaSiteSummary.ts +0 -5
  182. package/src/types/omada/oswStackDetail.ts +0 -11
  183. package/src/types/omada/paginatedResult.ts +0 -6
  184. package/src/types/omada/rateLimitProfile.ts +0 -58
  185. package/src/types/omada/threatInfo.ts +0 -38
  186. package/src/types/omada/tokenResult.ts +0 -6
  187. package/src/types.ts +0 -2
  188. package/src/utils/config-validations.ts +0 -68
  189. package/src/utils/jwt.ts +0 -112
  190. package/src/utils/logger.ts +0 -121
  191. package/src/utils/plainText.ts +0 -48
  192. package/src/utils/redact.ts +0 -59
  193. package/src/utils/sanitizeError.ts +0 -18
  194. package/src/utils/tlsDispatcher.ts +0 -69
  195. package/src/version.ts +0 -5
  196. package/tests/bdd/automation-creation.spec.ts +0 -214
  197. package/tests/bdd/automation-management.spec.ts +0 -267
  198. package/tests/bdd/calendar.spec.ts +0 -190
  199. package/tests/bdd/configuration.spec.ts +0 -283
  200. package/tests/bdd/device-control.spec.ts +0 -344
  201. package/tests/bdd/entity-listing.spec.ts +0 -208
  202. package/tests/bdd/entity-search.spec.ts +0 -183
  203. package/tests/bdd/history-retrieval.spec.ts +0 -195
  204. package/tests/bdd/notifications.spec.ts +0 -238
  205. package/tests/bdd/service-calls.spec.ts +0 -144
  206. package/tests/bdd/state-retrieval.spec.ts +0 -204
  207. package/tests/bench/hotpaths.bench.ts +0 -68
  208. package/tests/config.test.ts +0 -355
  209. package/tests/features/automation-creation.feature +0 -36
  210. package/tests/features/automation-management.feature +0 -54
  211. package/tests/features/calendar.feature +0 -37
  212. package/tests/features/configuration.feature +0 -70
  213. package/tests/features/device-control.feature +0 -100
  214. package/tests/features/entity-listing.feature +0 -51
  215. package/tests/features/entity-search.feature +0 -44
  216. package/tests/features/history-retrieval.feature +0 -38
  217. package/tests/features/notifications.feature +0 -37
  218. package/tests/features/service-calls.feature +0 -35
  219. package/tests/features/state-retrieval.feature +0 -37
  220. package/tests/fixtures/openapi-default-slice.json +0 -290
  221. package/tests/haClient/automationTrace.test.ts +0 -59
  222. package/tests/haClient/automations.test.ts +0 -149
  223. package/tests/haClient/config.test.ts +0 -117
  224. package/tests/haClient/entities.test.ts +0 -188
  225. package/tests/haClient/events.test.ts +0 -455
  226. package/tests/haClient/eventsTls.test.ts +0 -52
  227. package/tests/haClient/history.test.ts +0 -228
  228. package/tests/haClient/index.test.ts +0 -243
  229. package/tests/haClient/request.test.ts +0 -217
  230. package/tests/haClient/services.test.ts +0 -120
  231. package/tests/haClient/states.test.ts +0 -119
  232. package/tests/haClient/updates.test.ts +0 -193
  233. package/tests/index.test.ts +0 -160
  234. package/tests/integration/homeassistant.test.ts +0 -144
  235. package/tests/integration/omada.test.ts +0 -167
  236. package/tests/integration/server.test.ts +0 -151
  237. package/tests/localAI/index.test.ts +0 -177
  238. package/tests/localAI/ollama.test.ts +0 -187
  239. package/tests/localAI/openai.test.ts +0 -257
  240. package/tests/omadaClient/actions.test.ts +0 -161
  241. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  242. package/tests/omadaClient/ntp.test.ts +0 -381
  243. package/tests/omadaClient/site.test.ts +0 -37
  244. package/tests/ownerQuestions/service.test.ts +0 -499
  245. package/tests/ownerQuestions/tools.test.ts +0 -186
  246. package/tests/permissions/index.test.ts +0 -217
  247. package/tests/resources/index.test.ts +0 -288
  248. package/tests/scripts/generateJwt.test.ts +0 -44
  249. package/tests/security/accessControl.test.ts +0 -223
  250. package/tests/security/authentication.test.ts +0 -117
  251. package/tests/security/configuration.test.ts +0 -170
  252. package/tests/security/cryptoTransport.test.ts +0 -172
  253. package/tests/security/dependencies.test.ts +0 -152
  254. package/tests/security/injection.test.ts +0 -117
  255. package/tests/security/insecureDesign.test.ts +0 -121
  256. package/tests/security/logging.test.ts +0 -131
  257. package/tests/security/resourceConsumption.test.ts +0 -142
  258. package/tests/server/auth.test.ts +0 -249
  259. package/tests/server/chatFace.test.ts +0 -320
  260. package/tests/server/common.test.ts +0 -84
  261. package/tests/server/http.test.ts +0 -561
  262. package/tests/server/instructions.test.ts +0 -152
  263. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  264. package/tests/server/stdio.test.ts +0 -99
  265. package/tests/server/stream.test.ts +0 -285
  266. package/tests/server/toolVisibility.http.test.ts +0 -111
  267. package/tests/server/toolVisibility.test.ts +0 -129
  268. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  269. package/tests/tools/common.test.ts +0 -123
  270. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  271. package/tests/tools/infra/invokeAction.test.ts +0 -280
  272. package/tests/tools/omada/graph.test.ts +0 -385
  273. package/tests/tools/omada/handlers.test.ts +0 -897
  274. package/tests/tools/omada/namespace.test.ts +0 -483
  275. package/tests/tools/registry.test.ts +0 -168
  276. package/tests/utils/config-validations.test.ts +0 -352
  277. package/tests/utils/jwt.test.ts +0 -115
  278. package/tests/utils/logger.test.ts +0 -327
  279. package/tests/utils/redact.test.ts +0 -105
  280. package/tsconfig.json +0 -24
  281. package/vitest.config.ts +0 -38
@@ -1,121 +0,0 @@
1
- /**
2
- * SEC-DESIGN — Insecure Design / Fail-open
3
- * OWASP A04:2021 (Insecure Design).
4
- *
5
- * Findings covered:
6
- * L3 — default-allow `?? 0xFF` fallbacks. A tool invocation that arrives with no
7
- * permission mask was granted FULL permissions. Secure behavior: fail closed
8
- * (deny) unless the transport is the trusted-local stdio path, which opts in
9
- * explicitly via setLocalFullTrust().
10
- * L1 — automation/script CREATION is effectively arbitrary HA template/shell exec.
11
- * Policy: it must remain behind a privileged bit (CONFIGURE), never a read or
12
- * plain-control bit. This guard locks that contract so it can't silently
13
- * regress to a lower tier.
14
- */
15
-
16
- import { describe, it, expect, vi, afterEach } from 'vitest';
17
- import type { ToolExtra } from '../../src/tools/common.js';
18
- import { wrapToolHandler, toToolResult, setLocalFullTrust } from '../../src/tools/common.js';
19
- import { Permission, Role, hasPermission } from '../../src/permissions/index.js';
20
-
21
- /** Build a ToolExtra; pass `undefined` to omit the permission mask entirely. */
22
- function extraWith(permissions?: number): ToolExtra {
23
- // SDK v2 context shape: auth under http.authInfo; omitted entirely when absent.
24
- return {
25
- sessionId: 'test-session',
26
- mcpReq: { id: 1, signal: new AbortController().signal },
27
- ...(permissions !== undefined
28
- ? { http: { authInfo: { token: 't', clientId: 'c', scopes: [], extra: { permissions } } } }
29
- : {}),
30
- } as unknown as ToolExtra;
31
- }
32
-
33
- describe('SEC-DESIGN — Insecure Design / Fail-open', () => {
34
- // The local-trust flag is process-global; never let a test leak it.
35
- afterEach(() => setLocalFullTrust(false));
36
-
37
- describe('L3: missing permission mask fails closed', () => {
38
- it('denies a privileged tool when no authInfo is present (HTTP anomaly)', async () => {
39
- const handler = vi.fn().mockResolvedValue(toToolResult('ok'));
40
- const wrapped = wrapToolHandler('dangerTool', handler, Permission.ADMIN);
41
-
42
- const result = await wrapped({}, extraWith(undefined));
43
-
44
- expect(handler).not.toHaveBeenCalled();
45
- expect(result.isError).toBe(true);
46
- expect((result.content[0] as { text: string }).text).toContain('Permission denied');
47
- });
48
-
49
- it('denies even a QUERY-only tool when no mask is present', async () => {
50
- const handler = vi.fn().mockResolvedValue(toToolResult('ok'));
51
- const wrapped = wrapToolHandler('readTool', handler, Permission.QUERY);
52
-
53
- const result = await wrapped({}, extraWith(undefined));
54
-
55
- expect(handler).not.toHaveBeenCalled();
56
- expect(result.isError).toBe(true);
57
- });
58
-
59
- it('still enforces an explicit insufficient mask (regression guard)', async () => {
60
- const handler = vi.fn().mockResolvedValue(toToolResult('ok'));
61
- const wrapped = wrapToolHandler('dangerTool', handler, Permission.ADMIN);
62
-
63
- const result = await wrapped({}, extraWith(Permission.QUERY));
64
-
65
- expect(handler).not.toHaveBeenCalled();
66
- expect(result.isError).toBe(true);
67
- });
68
- });
69
-
70
- describe('L3: stdio local-trust opt-in', () => {
71
- it('grants full permissions for no-auth calls ONLY when local trust is enabled', async () => {
72
- const handler = vi.fn().mockResolvedValue(toToolResult('ok'));
73
- const wrapped = wrapToolHandler('dangerTool', handler, Permission.ADMIN);
74
-
75
- setLocalFullTrust(true);
76
- const result = await wrapped({}, extraWith(undefined));
77
-
78
- expect(handler).toHaveBeenCalledOnce();
79
- expect(result.isError).toBe(false);
80
- });
81
-
82
- it('reverts to fail-closed once local trust is turned back off', async () => {
83
- const handler = vi.fn().mockResolvedValue(toToolResult('ok'));
84
- const wrapped = wrapToolHandler('dangerTool', handler, Permission.ADMIN);
85
-
86
- setLocalFullTrust(true);
87
- setLocalFullTrust(false);
88
- const result = await wrapped({}, extraWith(undefined));
89
-
90
- expect(handler).not.toHaveBeenCalled();
91
- expect(result.isError).toBe(true);
92
- });
93
- });
94
-
95
- describe('L1: automation/script creation stays behind a privileged bit', () => {
96
- it('CONFIGURE (the createAutomation gate) is NOT granted by read/operator roles', () => {
97
- // createAutomation is registered with Permission.CONFIGURE. Document & lock that
98
- // this bit represents code-exec-equivalent power: it must not leak into the
99
- // low-privilege roles a typical read/control token would carry.
100
- expect(hasPermission(Role.READONLY, Permission.CONFIGURE)).toBe(false);
101
- expect(hasPermission(Role.OPERATOR, Permission.CONFIGURE)).toBe(false);
102
-
103
- // Only the explicitly-privileged roles include it.
104
- expect(hasPermission(Role.CONTRIBUTOR, Permission.CONFIGURE)).toBe(true);
105
- expect(hasPermission(Role.ADMIN, Permission.CONFIGURE)).toBe(true);
106
- });
107
-
108
- it('a CONFIGURE gate denies an OPERATOR token but allows a CONTRIBUTOR token', async () => {
109
- const handler = vi.fn().mockResolvedValue(toToolResult('created'));
110
- const wrapped = wrapToolHandler('createAutomation', handler, Permission.CONFIGURE);
111
-
112
- const denied = await wrapped({}, extraWith(Role.OPERATOR));
113
- expect(handler).not.toHaveBeenCalled();
114
- expect(denied.isError).toBe(true);
115
-
116
- const allowed = await wrapped({}, extraWith(Role.CONTRIBUTOR));
117
- expect(handler).toHaveBeenCalledOnce();
118
- expect(allowed.isError).toBe(false);
119
- });
120
- });
121
- });
@@ -1,131 +0,0 @@
1
- /**
2
- * SEC-LOG — Logging & Information Disclosure
3
- * OWASP A09:2021 (Security Logging and Monitoring Failures) / sensitive data exposure.
4
- *
5
- * Findings covered:
6
- * M6 — error responses leak the raw internal `error.message` to clients
7
- * (may contain internal hostnames, file paths, tokens, stack detail).
8
- * M7 — the unauthenticated `/health` endpoint discloses version, auth method,
9
- * and internal AI provider URLs.
10
- *
11
- * These tests assert the SECURE behavior: clients receive a generic error message
12
- * (the detail is logged server-side only), and `/health` returns liveness only.
13
- */
14
-
15
- import { describe, it, expect, vi } from 'vitest';
16
- import type { IncomingMessage, ServerResponse } from 'node:http';
17
- import type { HaClient } from '../../src/haClient/index.js';
18
- import { sanitizeError } from '../../src/utils/sanitizeError.js';
19
- import { handleRestApi, handleHealthCheck } from '../../src/server/http.js';
20
- import type { AuthenticatedRequest } from '../../src/server/auth.js';
21
-
22
- /** An error whose message embeds infrastructure detail we must never echo. */
23
- const LEAKY = new Error('connect ECONNREFUSED 10.0.0.19:8123 (/volume1/docker/secret)');
24
-
25
- /** Build a mock request carrying a full permission mask. */
26
- function mockReq(method: string, url: string): AuthenticatedRequest {
27
- const listeners: Record<string, ((arg?: unknown) => void)[]> = {};
28
- const req = {
29
- method,
30
- url,
31
- headers: { host: 'mcp.test:3000' },
32
- auth: { token: 't', clientId: 'u', scopes: [], extra: { permissions: 0xff } },
33
- on(event: string, cb: (arg?: unknown) => void) {
34
- (listeners[event] ??= []).push(cb);
35
- if (event === 'end') {
36
- queueMicrotask(() => {
37
- for (const e of listeners['end'] ?? []) e();
38
- });
39
- }
40
- return req;
41
- },
42
- } as unknown as AuthenticatedRequest;
43
- return req;
44
- }
45
-
46
- /** Capture status code and JSON body written to the response. */
47
- function mockRes(): ServerResponse & { _body?: unknown } {
48
- const res = {
49
- statusCode: 200,
50
- headersSent: false,
51
- setHeader: vi.fn(),
52
- end: vi.fn(function (this: { _body?: unknown }, payload?: unknown) {
53
- if (typeof payload === 'string') {
54
- try {
55
- this._body = JSON.parse(payload);
56
- } catch {
57
- this._body = payload;
58
- }
59
- }
60
- return this;
61
- }),
62
- } as unknown as ServerResponse & { _body?: unknown };
63
- return res;
64
- }
65
-
66
- describe('SEC-LOG — Logging & Information Disclosure', () => {
67
- describe('M6: sanitizeError never echoes internal detail', () => {
68
- it('returns a non-empty generic string', () => {
69
- const out = sanitizeError(LEAKY);
70
- expect(typeof out).toBe('string');
71
- expect(out.length).toBeGreaterThan(0);
72
- });
73
-
74
- it('strips internal hostnames, ports and file paths from Error messages', () => {
75
- const out = sanitizeError(LEAKY);
76
- expect(out).not.toContain('10.0.0.19');
77
- expect(out).not.toContain('8123');
78
- expect(out).not.toContain('/volume1');
79
- expect(out).not.toContain('ECONNREFUSED');
80
- });
81
-
82
- it('does not echo raw non-Error values', () => {
83
- const out = sanitizeError('raw secret /volume1/docker/.env token=abc123');
84
- expect(out).not.toContain('/volume1');
85
- expect(out).not.toContain('abc123');
86
- });
87
-
88
- it('supports a caller-supplied generic fallback', () => {
89
- expect(sanitizeError(LEAKY, 'Upstream request failed')).toBe('Upstream request failed');
90
- });
91
- });
92
-
93
- describe('M6: handleRestApi 500 path returns a generic message', () => {
94
- it('does not leak the upstream error detail to the client', async () => {
95
- const client = { getStates: vi.fn().mockRejectedValue(LEAKY) } as unknown as HaClient;
96
- const req = mockReq('GET', '/api/states');
97
- const res = mockRes();
98
-
99
- await handleRestApi(req, res, '/api/states', client);
100
-
101
- expect(res.statusCode).toBe(500);
102
- const body = JSON.stringify(res._body);
103
- expect(body).not.toContain('10.0.0.19');
104
- expect(body).not.toContain('/volume1');
105
- expect(body).not.toContain('ECONNREFUSED');
106
- });
107
- });
108
-
109
- describe('M7: /health discloses liveness only', () => {
110
- it('returns status only — no version, auth method, or internal AI URLs', () => {
111
- const res = mockRes();
112
-
113
- handleHealthCheck(res);
114
-
115
- const body = res._body as Record<string, unknown>;
116
- expect(body).toEqual({ status: 'healthy' });
117
-
118
- // Defense in depth: the serialized body carries none of the old internals.
119
- const serialized = JSON.stringify(body);
120
- expect(serialized).not.toContain('version');
121
- expect(serialized).not.toContain('authMethod');
122
- expect(serialized).not.toContain('aiUrl');
123
- expect(serialized).not.toContain('aiProvider');
124
- expect(body).not.toHaveProperty('rateLimitEnabled');
125
- expect(body).not.toHaveProperty('eventsConnectedClients');
126
- });
127
- });
128
- });
129
-
130
- // Avoid unused-type lint on the IncomingMessage import in some configs.
131
- export type _LogTestReq = IncomingMessage;
@@ -1,142 +0,0 @@
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
- it('rejects a malformed JSON body with a client-side 400, not a 500', async () => {
134
- // A body the client mis-escaped (e.g. bash-style `\"` from a Windows shell)
135
- // arrives as invalid JSON. It is the client's fault, so parseBody tags it 400
136
- // and the request handler surfaces "400 Bad Request" instead of a generic 500.
137
- const req = streamingReq([Buffer.from('{\\"jsonrpc\\":\\"2.0\\"}')]);
138
-
139
- await expect(parseBody(req)).rejects.toMatchObject({ statusCode: 400 });
140
- });
141
- });
142
- });
@@ -1,249 +0,0 @@
1
- /**
2
- * JWT authentication middleware tests
3
- */
4
-
5
- import { describe, it, expect, vi, beforeEach } from 'vitest';
6
- import type { IncomingMessage, ServerResponse } from 'node:http';
7
- import { createAuthMiddleware } from '../../src/server/auth.js';
8
- import { createJwt } from '../../src/utils/jwt.js';
9
- import { logger } from '../../src/utils/logger.js';
10
-
11
- vi.mock('../../src/utils/logger.js');
12
-
13
- const SECRET = 'test-secret-key-32chars-minimum!';
14
-
15
- describe('Auth Middleware', () => {
16
- let mockReq: Partial<IncomingMessage>;
17
- let mockRes: Partial<ServerResponse>;
18
- let responseData: { statusCode?: number; headers: Record<string, string>; body?: string };
19
-
20
- beforeEach(() => {
21
- responseData = { headers: {} };
22
-
23
- mockReq = {
24
- url: '/api/states',
25
- method: 'GET',
26
- headers: {},
27
- socket: { remoteAddress: '127.0.0.1' } as any,
28
- };
29
-
30
- mockRes = {
31
- statusCode: 200,
32
- setHeader: vi.fn((key: string, value: string) => {
33
- responseData.headers[key] = value;
34
- return mockRes as ServerResponse;
35
- }),
36
- end: vi.fn((body?: string) => {
37
- responseData.body = body;
38
- return mockRes as ServerResponse;
39
- }) as unknown as ServerResponse['end'],
40
- };
41
-
42
- Object.defineProperty(mockRes, 'statusCode', {
43
- get: () => responseData.statusCode,
44
- set: (val) => { responseData.statusCode = val; },
45
- });
46
- });
47
-
48
- describe('No Auth', () => {
49
- it('should allow all requests when method is none', () => {
50
- const middleware = createAuthMiddleware({ method: 'none' });
51
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
52
-
53
- expect(result).toBe(true);
54
- expect(mockRes.end).not.toHaveBeenCalled();
55
- });
56
- });
57
-
58
- describe('Bearer Auth', () => {
59
- it('should allow request with valid token', () => {
60
- const token = createJwt({ sub: 'user1' }, SECRET);
61
- mockReq.headers = { authorization: `Bearer ${token}` };
62
- const middleware = createAuthMiddleware({ method: 'bearer', secret: SECRET });
63
-
64
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
65
-
66
- expect(result).toBe(true);
67
- expect(mockRes.end).not.toHaveBeenCalled();
68
- });
69
-
70
- it('should attach AuthInfo to request with payload in extra', () => {
71
- const token = createJwt({ sub: 'admin', role: 'superuser' }, SECRET);
72
- mockReq.headers = { authorization: `Bearer ${token}` };
73
- const middleware = createAuthMiddleware({ method: 'bearer', secret: SECRET });
74
-
75
- middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
76
-
77
- const authReq = mockReq as IncomingMessage & { auth?: { clientId?: string; extra?: { payload?: { sub?: string; role?: string } } } };
78
- // AuthInfo.clientId is set to JWT sub
79
- expect(authReq.auth?.clientId).toBe('admin');
80
- // JWT payload is in authInfo.extra.payload
81
- expect(authReq.auth?.extra?.payload?.sub).toBe('admin');
82
- expect(authReq.auth?.extra?.payload?.role).toBe('superuser');
83
- });
84
-
85
- it('should reject request without Authorization header', () => {
86
- mockReq.headers = {};
87
- const middleware = createAuthMiddleware({ method: 'bearer', secret: SECRET });
88
-
89
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
90
-
91
- expect(result).toBe(false);
92
- expect(responseData.statusCode).toBe(401);
93
- expect(responseData.headers['WWW-Authenticate']).toBe('Bearer');
94
- expect(responseData.body).toContain('Missing Authorization header');
95
- });
96
-
97
- it('should reject request with invalid signature', () => {
98
- const token = createJwt({ sub: 'user1' }, 'wrong-secret');
99
- mockReq.headers = { authorization: `Bearer ${token}` };
100
- const middleware = createAuthMiddleware({ method: 'bearer', secret: SECRET });
101
-
102
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
103
-
104
- expect(result).toBe(false);
105
- expect(responseData.statusCode).toBe(401);
106
- expect(responseData.body).toContain('bad signature');
107
- });
108
-
109
- it('should reject expired token', () => {
110
- const token = createJwt({ sub: 'user1', exp: Math.floor(Date.now() / 1000) - 60 }, SECRET);
111
- mockReq.headers = { authorization: `Bearer ${token}` };
112
- const middleware = createAuthMiddleware({ method: 'bearer', secret: SECRET });
113
-
114
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
115
-
116
- expect(result).toBe(false);
117
- expect(responseData.statusCode).toBe(401);
118
- expect(responseData.body).toContain('expired');
119
- });
120
-
121
- it('should reject malformed Authorization header', () => {
122
- mockReq.headers = { authorization: 'Basic dXNlcjpwYXNz' };
123
- const middleware = createAuthMiddleware({ method: 'bearer', secret: SECRET });
124
-
125
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
126
-
127
- expect(result).toBe(false);
128
- expect(responseData.statusCode).toBe(401);
129
- expect(responseData.body).toContain('Expected: Bearer');
130
- });
131
-
132
- it('should return 500 if bearer auth enabled but no secret configured', () => {
133
- const token = createJwt({ sub: 'user1' }, SECRET);
134
- mockReq.headers = { authorization: `Bearer ${token}` };
135
- const middleware = createAuthMiddleware({ method: 'bearer', secret: undefined });
136
-
137
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
138
-
139
- expect(result).toBe(false);
140
- expect(responseData.statusCode).toBe(500);
141
- expect(responseData.body).toContain('misconfiguration');
142
- });
143
- });
144
-
145
- describe('Skip Paths', () => {
146
- it('should skip auth for exact path match', () => {
147
- mockReq.url = '/health';
148
- mockReq.headers = {};
149
- const middleware = createAuthMiddleware({
150
- method: 'bearer',
151
- secret: SECRET,
152
- skipPaths: ['/health'],
153
- });
154
-
155
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
156
-
157
- expect(result).toBe(true);
158
- });
159
-
160
- it('should skip auth for path prefix', () => {
161
- mockReq.url = '/health/detailed';
162
- mockReq.headers = {};
163
- const middleware = createAuthMiddleware({
164
- method: 'bearer',
165
- secret: SECRET,
166
- skipPaths: ['/health'],
167
- });
168
-
169
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
170
-
171
- expect(result).toBe(true);
172
- });
173
-
174
- it('should require auth for non-skipped paths', () => {
175
- mockReq.url = '/api/states';
176
- mockReq.headers = {};
177
- const middleware = createAuthMiddleware({
178
- method: 'bearer',
179
- secret: SECRET,
180
- skipPaths: ['/health'],
181
- });
182
-
183
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
184
-
185
- expect(result).toBe(false);
186
- expect(responseData.statusCode).toBe(401);
187
- });
188
-
189
- it('should handle query strings in URL', () => {
190
- mockReq.url = '/health?check=true';
191
- mockReq.headers = {};
192
- const middleware = createAuthMiddleware({
193
- method: 'bearer',
194
- secret: SECRET,
195
- skipPaths: ['/health'],
196
- });
197
-
198
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
199
-
200
- expect(result).toBe(true);
201
- });
202
- });
203
-
204
- describe('Bearer Payload Claims', () => {
205
- it('should allow token with future exp', () => {
206
- const token = createJwt({ sub: 'user1', exp: Math.floor(Date.now() / 1000) + 3600 }, SECRET);
207
- mockReq.headers = { authorization: `Bearer ${token}` };
208
- const middleware = createAuthMiddleware({ method: 'bearer', secret: SECRET });
209
-
210
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
211
-
212
- expect(result).toBe(true);
213
- });
214
-
215
- it('should allow token without exp (no expiration)', () => {
216
- const token = createJwt({ sub: 'service-account' }, SECRET);
217
- mockReq.headers = { authorization: `Bearer ${token}` };
218
- const middleware = createAuthMiddleware({ method: 'bearer', secret: SECRET });
219
-
220
- const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
221
-
222
- expect(result).toBe(true);
223
- });
224
- });
225
- });
226
-
227
- describe('Auth Middleware - permissive defaultRole warning', () => {
228
- beforeEach(() => {
229
- vi.mocked(logger.warn).mockClear();
230
- });
231
-
232
- const warnedAboutDefaultRole = (): boolean =>
233
- vi.mocked(logger.warn).mock.calls.some(([msg]) => String(msg).includes('defaultRole'));
234
-
235
- it('warns at creation when bearer auth has a defaultRole that grants access', () => {
236
- createAuthMiddleware({ method: 'bearer', secret: SECRET, permissions: { users: [], defaultRole: 'OPERATOR' } });
237
- expect(warnedAboutDefaultRole()).toBe(true);
238
- });
239
-
240
- it('stays silent when defaultRole is NONE', () => {
241
- createAuthMiddleware({ method: 'bearer', secret: SECRET, permissions: { users: [], defaultRole: 'NONE' } });
242
- expect(warnedAboutDefaultRole()).toBe(false);
243
- });
244
-
245
- it('stays silent when auth is not bearer (defaultRole is never applied)', () => {
246
- createAuthMiddleware({ method: 'none', permissions: { users: [], defaultRole: 'OPERATOR' } });
247
- expect(warnedAboutDefaultRole()).toBe(false);
248
- });
249
- });