@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.4.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 +1 -1
- package/README.md +24 -2
- package/dist/omadaClient/accessControl.d.ts +290 -0
- package/dist/omadaClient/accessControl.js +509 -0
- package/dist/omadaClient/accessControl.js.map +1 -0
- package/dist/omadaClient/client.d.ts +6 -1
- package/dist/omadaClient/client.js +12 -1
- package/dist/omadaClient/client.js.map +1 -1
- package/dist/omadaClient/diagnose.d.ts +68 -0
- package/dist/omadaClient/diagnose.js +164 -0
- package/dist/omadaClient/diagnose.js.map +1 -0
- package/dist/omadaClient/index.d.ts +9 -0
- package/dist/omadaClient/index.js +19 -0
- package/dist/omadaClient/index.js.map +1 -1
- package/dist/omadaClient/macGroups.d.ts +58 -0
- package/dist/omadaClient/macGroups.js +110 -0
- package/dist/omadaClient/macGroups.js.map +1 -0
- package/dist/omadaClient/serialize.d.ts +13 -0
- package/dist/omadaClient/serialize.js +29 -0
- package/dist/omadaClient/serialize.js.map +1 -0
- package/dist/omadaClient/timeRange.d.ts +41 -0
- package/dist/omadaClient/timeRange.js +109 -0
- package/dist/omadaClient/timeRange.js.map +1 -0
- package/dist/tools/infra/actions.d.ts +1 -1
- package/dist/tools/infra/actions.js +1 -1
- package/dist/tools/omada/accessControl.d.ts +151 -0
- package/dist/tools/omada/accessControl.js +158 -0
- package/dist/tools/omada/accessControl.js.map +1 -0
- package/dist/tools/omada/index.js +9 -0
- package/dist/tools/omada/index.js.map +1 -1
- package/dist/tools/omada/macGroups.d.ts +19 -0
- package/dist/tools/omada/macGroups.js +77 -0
- package/dist/tools/omada/macGroups.js.map +1 -0
- package/dist/tools/omada/namespace.js +16 -0
- package/dist/tools/omada/namespace.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -1
- package/.codex/review-rules.md +0 -16
- package/.dockerignore +0 -8
- package/.gitattributes +0 -30
- package/.github/workflows/ci.yml +0 -193
- package/AGENTS.md +0 -389
- package/Dockerfile +0 -50
- package/docker-compose.yml +0 -23
- package/docs/CHANGELOG.md +0 -341
- package/docs/COVERAGE.md +0 -219
- package/docs/QUICK_START.md +0 -240
- package/eslint.config.js +0 -33
- package/n8n/ai-agent-safety-monitor.json +0 -355
- package/openapi.json +0 -1259
- package/scripts/check-version-sync.mjs +0 -69
- package/scripts/generate-jwt.ts +0 -121
- package/src/config.ts +0 -440
- package/src/haClient/automations.ts +0 -261
- package/src/haClient/calendars.ts +0 -94
- package/src/haClient/config.ts +0 -52
- package/src/haClient/devices.ts +0 -653
- package/src/haClient/entities.ts +0 -128
- package/src/haClient/events.ts +0 -689
- package/src/haClient/history.ts +0 -73
- package/src/haClient/index.ts +0 -336
- package/src/haClient/request.ts +0 -142
- package/src/haClient/services.ts +0 -45
- package/src/haClient/states.ts +0 -60
- package/src/haClient/updates.ts +0 -58
- package/src/haClient/wsCommand.ts +0 -44
- package/src/index.ts +0 -164
- package/src/localAI/index.ts +0 -88
- package/src/localAI/prompts.ts +0 -50
- package/src/localAI/providers/base.ts +0 -120
- package/src/localAI/providers/ollama.ts +0 -78
- package/src/localAI/providers/openai.ts +0 -86
- package/src/localAI/types.ts +0 -67
- package/src/omadaClient/auth.ts +0 -178
- package/src/omadaClient/client.ts +0 -337
- package/src/omadaClient/device.ts +0 -118
- package/src/omadaClient/index.ts +0 -310
- package/src/omadaClient/network.ts +0 -284
- package/src/omadaClient/request.ts +0 -365
- package/src/omadaClient/security.ts +0 -51
- package/src/omadaClient/site.ts +0 -344
- package/src/ownerQuestions/service.ts +0 -536
- package/src/permissions/index.ts +0 -143
- package/src/resources/index.ts +0 -213
- package/src/server/auth.ts +0 -175
- package/src/server/chatFace.ts +0 -789
- package/src/server/chatSlice.ts +0 -57
- package/src/server/common.ts +0 -170
- package/src/server/eventSubscription.ts +0 -343
- package/src/server/http.ts +0 -626
- package/src/server/instructions.ts +0 -97
- package/src/server/rateLimiter.ts +0 -227
- package/src/server/stdio.ts +0 -41
- package/src/server/stream.ts +0 -205
- package/src/tools/ai/analyzeSensors.ts +0 -33
- package/src/tools/ai/index.ts +0 -20
- package/src/tools/common.ts +0 -442
- package/src/tools/homeassistant/askOwner.ts +0 -133
- package/src/tools/homeassistant/automationControls.ts +0 -66
- package/src/tools/homeassistant/calendar.ts +0 -97
- package/src/tools/homeassistant/callService.ts +0 -25
- package/src/tools/homeassistant/controlClimate.ts +0 -68
- package/src/tools/homeassistant/controlCover.ts +0 -48
- package/src/tools/homeassistant/controlFan.ts +0 -44
- package/src/tools/homeassistant/controlLight.ts +0 -38
- package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
- package/src/tools/homeassistant/createAutomation.ts +0 -35
- package/src/tools/homeassistant/deleteAutomation.ts +0 -24
- package/src/tools/homeassistant/entityAction.ts +0 -35
- package/src/tools/homeassistant/entityGuard.ts +0 -28
- package/src/tools/homeassistant/getAllSensors.ts +0 -46
- package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
- package/src/tools/homeassistant/getDomainSummary.ts +0 -29
- package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
- package/src/tools/homeassistant/getHistory.ts +0 -24
- package/src/tools/homeassistant/getState.ts +0 -24
- package/src/tools/homeassistant/getStates.ts +0 -46
- package/src/tools/homeassistant/getVersion.ts +0 -21
- package/src/tools/homeassistant/index.ts +0 -121
- package/src/tools/homeassistant/listAutomations.ts +0 -35
- package/src/tools/homeassistant/listEntities.ts +0 -44
- package/src/tools/homeassistant/listPersons.ts +0 -46
- package/src/tools/homeassistant/sceneAndScript.ts +0 -53
- package/src/tools/homeassistant/searchEntities.ts +0 -58
- package/src/tools/homeassistant/sendNotification.ts +0 -179
- package/src/tools/homeassistant/systemTools.ts +0 -52
- package/src/tools/homeassistant/triggerAutomation.ts +0 -24
- package/src/tools/index.ts +0 -78
- package/src/tools/infra/actions.ts +0 -95
- package/src/tools/infra/index.ts +0 -138
- package/src/tools/omada/blockClient.ts +0 -28
- package/src/tools/omada/cyclePoePort.ts +0 -32
- package/src/tools/omada/deleteClient.ts +0 -27
- package/src/tools/omada/disableClientRateLimit.ts +0 -23
- package/src/tools/omada/getClient.ts +0 -14
- package/src/tools/omada/getDevice.ts +0 -14
- package/src/tools/omada/getFirewallSetting.ts +0 -14
- package/src/tools/omada/getInternetInfo.ts +0 -14
- package/src/tools/omada/getLanNetworkList.ts +0 -14
- package/src/tools/omada/getLanProfileList.ts +0 -14
- package/src/tools/omada/getPortForwardingStatus.ts +0 -26
- package/src/tools/omada/getRateLimitProfiles.ts +0 -14
- package/src/tools/omada/getSiteNtpStatus.ts +0 -14
- package/src/tools/omada/getSsidDetail.ts +0 -22
- package/src/tools/omada/getSsidList.ts +0 -21
- package/src/tools/omada/getSwitchStackDetail.ts +0 -14
- package/src/tools/omada/getThreatList.ts +0 -42
- package/src/tools/omada/getWlanGroupList.ts +0 -14
- package/src/tools/omada/graph.ts +0 -187
- package/src/tools/omada/index.ts +0 -153
- package/src/tools/omada/listBlockedClients.ts +0 -17
- package/src/tools/omada/listClients.ts +0 -14
- package/src/tools/omada/listClientsActivity.ts +0 -25
- package/src/tools/omada/listClientsPastConnections.ts +0 -44
- package/src/tools/omada/listDevices.ts +0 -14
- package/src/tools/omada/listDevicesStats.ts +0 -41
- package/src/tools/omada/listMostActiveClients.ts +0 -14
- package/src/tools/omada/listSites.ts +0 -18
- package/src/tools/omada/namespace.ts +0 -1164
- package/src/tools/omada/searchDevices.ts +0 -20
- package/src/tools/omada/setClientRateLimit.ts +0 -26
- package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
- package/src/tools/omada/setSiteNtpServers.ts +0 -36
- package/src/tools/omada/setSsidEnabled.ts +0 -38
- package/src/tools/omada/unblockClient.ts +0 -26
- package/src/tools/types.ts +0 -11
- package/src/tools/utils/pagination-schema.ts +0 -27
- package/src/types/ai/index.ts +0 -11
- package/src/types/ai/types.ts +0 -67
- package/src/types/homeassistant/automations.ts +0 -43
- package/src/types/homeassistant/calendars.ts +0 -26
- package/src/types/homeassistant/config.ts +0 -14
- package/src/types/homeassistant/entities.ts +0 -14
- package/src/types/homeassistant/errors.ts +0 -33
- package/src/types/homeassistant/haConfig.ts +0 -7
- package/src/types/homeassistant/history.ts +0 -13
- package/src/types/homeassistant/index.ts +0 -46
- package/src/types/homeassistant/request.ts +0 -6
- package/src/types/homeassistant/serviceCall.ts +0 -18
- package/src/types/homeassistant/states.ts +0 -58
- package/src/types/homeassistant/updates.ts +0 -28
- package/src/types/index.ts +0 -12
- package/src/types/omada/activeClientInfo.ts +0 -17
- package/src/types/omada/clientActivity.ts +0 -32
- package/src/types/omada/clientBlockStatus.ts +0 -11
- package/src/types/omada/clientPastConnection.ts +0 -82
- package/src/types/omada/index.ts +0 -14
- package/src/types/omada/omadaApiResponse.ts +0 -5
- package/src/types/omada/omadaClientInfo.ts +0 -11
- package/src/types/omada/omadaDeviceInfo.ts +0 -11
- package/src/types/omada/omadaDeviceStats.ts +0 -31
- package/src/types/omada/omadaSiteSummary.ts +0 -5
- package/src/types/omada/oswStackDetail.ts +0 -11
- package/src/types/omada/paginatedResult.ts +0 -6
- package/src/types/omada/rateLimitProfile.ts +0 -58
- package/src/types/omada/threatInfo.ts +0 -38
- package/src/types/omada/tokenResult.ts +0 -6
- package/src/types.ts +0 -2
- package/src/utils/config-validations.ts +0 -68
- package/src/utils/jwt.ts +0 -112
- package/src/utils/logger.ts +0 -121
- package/src/utils/plainText.ts +0 -48
- package/src/utils/redact.ts +0 -59
- package/src/utils/sanitizeError.ts +0 -18
- package/src/utils/tlsDispatcher.ts +0 -69
- package/src/version.ts +0 -5
- package/tests/bdd/automation-creation.spec.ts +0 -214
- package/tests/bdd/automation-management.spec.ts +0 -267
- package/tests/bdd/calendar.spec.ts +0 -190
- package/tests/bdd/configuration.spec.ts +0 -283
- package/tests/bdd/device-control.spec.ts +0 -344
- package/tests/bdd/entity-listing.spec.ts +0 -208
- package/tests/bdd/entity-search.spec.ts +0 -183
- package/tests/bdd/history-retrieval.spec.ts +0 -195
- package/tests/bdd/notifications.spec.ts +0 -238
- package/tests/bdd/service-calls.spec.ts +0 -144
- package/tests/bdd/state-retrieval.spec.ts +0 -204
- package/tests/bench/hotpaths.bench.ts +0 -68
- package/tests/config.test.ts +0 -355
- package/tests/features/automation-creation.feature +0 -36
- package/tests/features/automation-management.feature +0 -54
- package/tests/features/calendar.feature +0 -37
- package/tests/features/configuration.feature +0 -70
- package/tests/features/device-control.feature +0 -100
- package/tests/features/entity-listing.feature +0 -51
- package/tests/features/entity-search.feature +0 -44
- package/tests/features/history-retrieval.feature +0 -38
- package/tests/features/notifications.feature +0 -37
- package/tests/features/service-calls.feature +0 -35
- package/tests/features/state-retrieval.feature +0 -37
- package/tests/fixtures/openapi-default-slice.json +0 -290
- package/tests/haClient/automationTrace.test.ts +0 -59
- package/tests/haClient/automations.test.ts +0 -149
- package/tests/haClient/config.test.ts +0 -117
- package/tests/haClient/entities.test.ts +0 -188
- package/tests/haClient/events.test.ts +0 -455
- package/tests/haClient/eventsTls.test.ts +0 -52
- package/tests/haClient/history.test.ts +0 -228
- package/tests/haClient/index.test.ts +0 -243
- package/tests/haClient/request.test.ts +0 -217
- package/tests/haClient/services.test.ts +0 -120
- package/tests/haClient/states.test.ts +0 -119
- package/tests/haClient/updates.test.ts +0 -193
- package/tests/index.test.ts +0 -160
- package/tests/integration/homeassistant.test.ts +0 -144
- package/tests/integration/omada.test.ts +0 -167
- package/tests/integration/server.test.ts +0 -151
- package/tests/localAI/index.test.ts +0 -177
- package/tests/localAI/ollama.test.ts +0 -187
- package/tests/localAI/openai.test.ts +0 -257
- package/tests/omadaClient/actions.test.ts +0 -161
- package/tests/omadaClient/clientBlocks.test.ts +0 -170
- package/tests/omadaClient/ntp.test.ts +0 -381
- package/tests/omadaClient/site.test.ts +0 -37
- package/tests/ownerQuestions/service.test.ts +0 -499
- package/tests/ownerQuestions/tools.test.ts +0 -186
- package/tests/permissions/index.test.ts +0 -217
- package/tests/resources/index.test.ts +0 -288
- package/tests/scripts/generateJwt.test.ts +0 -44
- package/tests/security/accessControl.test.ts +0 -223
- package/tests/security/authentication.test.ts +0 -117
- package/tests/security/configuration.test.ts +0 -170
- package/tests/security/cryptoTransport.test.ts +0 -172
- package/tests/security/dependencies.test.ts +0 -152
- package/tests/security/injection.test.ts +0 -117
- package/tests/security/insecureDesign.test.ts +0 -121
- package/tests/security/logging.test.ts +0 -131
- package/tests/security/resourceConsumption.test.ts +0 -142
- package/tests/server/auth.test.ts +0 -249
- package/tests/server/chatFace.test.ts +0 -320
- package/tests/server/common.test.ts +0 -84
- package/tests/server/http.test.ts +0 -561
- package/tests/server/instructions.test.ts +0 -152
- package/tests/server/memoizeJsonSchema.test.ts +0 -71
- package/tests/server/stdio.test.ts +0 -99
- package/tests/server/stream.test.ts +0 -285
- package/tests/server/toolVisibility.http.test.ts +0 -111
- package/tests/server/toolVisibility.test.ts +0 -129
- package/tests/tools/ai/analyzeSensors.test.ts +0 -227
- package/tests/tools/common.test.ts +0 -123
- package/tests/tools/homeassistant/handlers.test.ts +0 -872
- package/tests/tools/infra/invokeAction.test.ts +0 -280
- package/tests/tools/omada/graph.test.ts +0 -385
- package/tests/tools/omada/handlers.test.ts +0 -897
- package/tests/tools/omada/namespace.test.ts +0 -483
- package/tests/tools/registry.test.ts +0 -168
- package/tests/utils/config-validations.test.ts +0 -352
- package/tests/utils/jwt.test.ts +0 -115
- package/tests/utils/logger.test.ts +0 -327
- package/tests/utils/redact.test.ts +0 -105
- package/tsconfig.json +0 -24
- 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
|
-
});
|