@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.
- package/.env.example +1 -1
- package/README.md +11 -2
- 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 +6 -0
- package/dist/omadaClient/index.js +15 -0
- package/dist/omadaClient/index.js.map +1 -1
- package/dist/omadaClient/macGroups.d.ts +58 -0
- package/dist/omadaClient/macGroups.js +124 -0
- package/dist/omadaClient/macGroups.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/index.js +6 -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 +8 -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,170 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SEC-CONFIG — Security Misconfiguration (OWASP API8:2023 / A05:2021).
|
|
3
|
-
*
|
|
4
|
-
* H5 Fail-closed bind: MCP_AUTH_METHOD=none must not be combined with a
|
|
5
|
-
* non-loopback bind address — that exposes an unauthenticated server.
|
|
6
|
-
* M1 docker-compose.yml must not hard-code a wildcard `MCP_HTTP_ALLOWED_ORIGINS=*`.
|
|
7
|
-
* M3 DNS-rebinding Host validation is OPT-IN via MCP_HTTP_ALLOWED_HOSTS: OFF when
|
|
8
|
-
* unset (no silent upgrade break), enforcing exactly the listed host[:port]
|
|
9
|
-
* (plus loopback) when set. Default origins stay full scheme://host:port forms
|
|
10
|
-
* so the separate Origin/CORS layer matches real Origin headers.
|
|
11
|
-
* M10 Dockerfile must run as a non-root USER.
|
|
12
|
-
* L4 HTTP responses must carry baseline security headers.
|
|
13
|
-
* L7 .env.example must use the real var (MCP_AUTH_SECRET, not MCP_AUTH_TOKEN)
|
|
14
|
-
* and ship secure defaults.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { describe, it, expect } from 'vitest';
|
|
18
|
-
import { readFileSync } from 'node:fs';
|
|
19
|
-
import { resolve } from 'node:path';
|
|
20
|
-
import { loadConfig } from '../../src/config.js';
|
|
21
|
-
import { buildTransportSecurityOptions } from '../../src/server/stream.js';
|
|
22
|
-
import { applySecurityHeaders } from '../../src/server/http.js';
|
|
23
|
-
|
|
24
|
-
const repoRoot = process.cwd();
|
|
25
|
-
const read = (rel: string): string => readFileSync(resolve(repoRoot, rel), 'utf8');
|
|
26
|
-
|
|
27
|
-
const SECRET = 'test-secret-key-32chars-minimum!';
|
|
28
|
-
|
|
29
|
-
describe('SEC-CONFIG H5: refuse auth=none on a non-loopback bind', () => {
|
|
30
|
-
it('throws when MCP_AUTH_METHOD defaults to none and bind is 0.0.0.0', () => {
|
|
31
|
-
expect(() => loadConfig({ MCP_HTTP_BIND_ADDR: '0.0.0.0' } as NodeJS.ProcessEnv)).toThrow(
|
|
32
|
-
/none/i
|
|
33
|
-
);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('allows none with an explicit loopback bind', () => {
|
|
37
|
-
const cfg = loadConfig({ MCP_HTTP_BIND_ADDR: '127.0.0.1' } as NodeJS.ProcessEnv);
|
|
38
|
-
expect(cfg.authMethod).toBe('none');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('allows none with the default (loopback) bind', () => {
|
|
42
|
-
const cfg = loadConfig({} as NodeJS.ProcessEnv);
|
|
43
|
-
expect(cfg.authMethod).toBe('none');
|
|
44
|
-
expect(cfg.httpBindAddr).toBe('127.0.0.1');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('allows bearer on a public bind when a strong secret is set', () => {
|
|
48
|
-
const cfg = loadConfig({
|
|
49
|
-
MCP_HTTP_BIND_ADDR: '0.0.0.0',
|
|
50
|
-
MCP_AUTH_METHOD: 'bearer',
|
|
51
|
-
MCP_AUTH_SECRET: SECRET,
|
|
52
|
-
} as NodeJS.ProcessEnv);
|
|
53
|
-
expect(cfg.authMethod).toBe('bearer');
|
|
54
|
-
expect(cfg.httpBindAddr).toBe('0.0.0.0');
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe('SEC-CONFIG M1: compose has no wildcard origin', () => {
|
|
59
|
-
it('docker-compose.yml does not set MCP_HTTP_ALLOWED_ORIGINS=*', () => {
|
|
60
|
-
const compose = read('docker-compose.yml');
|
|
61
|
-
expect(compose).not.toMatch(/MCP_HTTP_ALLOWED_ORIGINS\s*[:=]\s*\*/);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
describe('SEC-CONFIG M3: opt-in DNS-rebinding Host validation', () => {
|
|
66
|
-
const base = {
|
|
67
|
-
MCP_HTTP_BIND_ADDR: '0.0.0.0',
|
|
68
|
-
MCP_AUTH_METHOD: 'bearer',
|
|
69
|
-
MCP_AUTH_SECRET: SECRET,
|
|
70
|
-
} as NodeJS.ProcessEnv;
|
|
71
|
-
|
|
72
|
-
it('default config origins are full scheme://host:port forms', () => {
|
|
73
|
-
const cfg = loadConfig({} as NodeJS.ProcessEnv);
|
|
74
|
-
expect(cfg.httpAllowedOrigins?.some((o) => /^https?:\/\/[^/]+:\d+$/.test(o))).toBe(true);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('leaves Host validation OFF when MCP_HTTP_ALLOWED_HOSTS is unset (no upgrade break)', () => {
|
|
78
|
-
const cfg = loadConfig({} as NodeJS.ProcessEnv);
|
|
79
|
-
const opts = buildTransportSecurityOptions(cfg);
|
|
80
|
-
expect(opts.enableDnsRebindingProtection).toBe(false);
|
|
81
|
-
expect(opts.allowedHosts).toEqual([]);
|
|
82
|
-
// Origin/CORS is enforced by the HTTP layer, not the transport.
|
|
83
|
-
expect(opts.allowedOrigins).toEqual([]);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('does NOT derive Host allowlist from CORS origins', () => {
|
|
87
|
-
// Origins are client/CORS addresses; the server host is frequently different.
|
|
88
|
-
// Setting origins alone must not turn on (or seed) Host validation.
|
|
89
|
-
const cfg = loadConfig({
|
|
90
|
-
...base,
|
|
91
|
-
MCP_HTTP_ALLOWED_ORIGINS: 'https://client.example.com:5678',
|
|
92
|
-
} as NodeJS.ProcessEnv);
|
|
93
|
-
const opts = buildTransportSecurityOptions(cfg);
|
|
94
|
-
expect(opts.enableDnsRebindingProtection).toBe(false);
|
|
95
|
-
expect(opts.allowedHosts).toEqual([]);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('enforces exactly the configured hosts (plus loopback) when MCP_HTTP_ALLOWED_HOSTS is set', () => {
|
|
99
|
-
const cfg = loadConfig({
|
|
100
|
-
...base,
|
|
101
|
-
MCP_HTTP_ALLOWED_HOSTS: 'mcp.example.com,192.168.0.18:3000',
|
|
102
|
-
} as NodeJS.ProcessEnv);
|
|
103
|
-
const opts = buildTransportSecurityOptions(cfg);
|
|
104
|
-
expect(opts.enableDnsRebindingProtection).toBe(true);
|
|
105
|
-
expect(opts.allowedHosts).toContain('mcp.example.com');
|
|
106
|
-
expect(opts.allowedHosts).toContain('192.168.0.18:3000');
|
|
107
|
-
// Loopback/bind conveniences are always added so on-box access keeps working.
|
|
108
|
-
expect(opts.allowedHosts).toContain('localhost:3000');
|
|
109
|
-
expect(opts.allowedHosts).toContain('0.0.0.0:3000');
|
|
110
|
-
// A host the operator did NOT list is absent → the SDK 403s it.
|
|
111
|
-
expect(opts.allowedHosts).not.toContain('evil.attacker.test');
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('accepts a full-URL host entry and reduces it to host[:port] form', () => {
|
|
115
|
-
const cfg = loadConfig({
|
|
116
|
-
...base,
|
|
117
|
-
MCP_HTTP_ALLOWED_HOSTS: 'https://mcp.example.com',
|
|
118
|
-
} as NodeJS.ProcessEnv);
|
|
119
|
-
const opts = buildTransportSecurityOptions(cfg);
|
|
120
|
-
expect(opts.allowedHosts).toContain('mcp.example.com');
|
|
121
|
-
// Origins are still not enforced at the transport.
|
|
122
|
-
expect(opts.allowedOrigins).toEqual([]);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe('SEC-CONFIG M10: Dockerfile runs as non-root', () => {
|
|
127
|
-
it('Dockerfile declares a non-root USER', () => {
|
|
128
|
-
const dockerfile = read('Dockerfile');
|
|
129
|
-
const userLines = dockerfile.split('\n').filter((l) => /^\s*USER\s+/i.test(l));
|
|
130
|
-
expect(userLines.length).toBeGreaterThan(0);
|
|
131
|
-
// The last USER directive in effect must not be root.
|
|
132
|
-
const last = userLines[userLines.length - 1].trim().replace(/^USER\s+/i, '');
|
|
133
|
-
expect(last).not.toMatch(/^root\b/);
|
|
134
|
-
expect(last).not.toMatch(/^0\b/);
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
describe('SEC-CONFIG L4: baseline security headers', () => {
|
|
139
|
-
it('applySecurityHeaders sets nosniff, frame-deny, and a referrer policy', () => {
|
|
140
|
-
const set: Record<string, string> = {};
|
|
141
|
-
const res = {
|
|
142
|
-
setHeader(name: string, value: string) {
|
|
143
|
-
set[name] = value;
|
|
144
|
-
},
|
|
145
|
-
} as unknown as import('node:http').ServerResponse;
|
|
146
|
-
|
|
147
|
-
applySecurityHeaders(res);
|
|
148
|
-
|
|
149
|
-
expect(set['X-Content-Type-Options']).toBe('nosniff');
|
|
150
|
-
expect(set['X-Frame-Options']).toBe('DENY');
|
|
151
|
-
expect(set['Referrer-Policy']).toBeDefined();
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
describe('SEC-CONFIG L7: .env.example uses correct var + secure defaults', () => {
|
|
156
|
-
const env = read('.env.example');
|
|
157
|
-
|
|
158
|
-
it('uses MCP_AUTH_SECRET, never MCP_AUTH_TOKEN', () => {
|
|
159
|
-
expect(env).toMatch(/MCP_AUTH_SECRET/);
|
|
160
|
-
expect(env).not.toMatch(/MCP_AUTH_TOKEN/);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('defaults HA_STRICT_SSL to true', () => {
|
|
164
|
-
expect(env).toMatch(/^HA_STRICT_SSL=true\s*$/m);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('defaults MCP_AUTH_METHOD to bearer', () => {
|
|
168
|
-
expect(env).toMatch(/^MCP_AUTH_METHOD=bearer\s*$/m);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SEC-CRYPTO — Cryptographic / transport-security findings.
|
|
3
|
-
*
|
|
4
|
-
* OWASP API8:2023 Security Misconfiguration / A02:2021 Cryptographic Failures.
|
|
5
|
-
*
|
|
6
|
-
* H2 Per-client TLS scoping. The Omada and HA clients must NOT toggle the
|
|
7
|
-
* process-global `NODE_TLS_REJECT_UNAUTHORIZED` env var to talk to a
|
|
8
|
-
* self-signed controller — that disables certificate validation for EVERY
|
|
9
|
-
* concurrent outbound request in the process (a TOCTOU race that silently
|
|
10
|
-
* drops TLS verification for unrelated HA/AI traffic). The insecure mode
|
|
11
|
-
* must be scoped to the single client via a per-instance undici dispatcher.
|
|
12
|
-
*
|
|
13
|
-
* L5 SSE client IDs must be generated with a CSPRNG (randomUUID), not
|
|
14
|
-
* Math.random(), so they are not predictable/guessable.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
18
|
-
import { fetch as undiciFetch } from 'undici';
|
|
19
|
-
import { RequestHandler } from '../../src/omadaClient/request.js';
|
|
20
|
-
import { AuthManager } from '../../src/omadaClient/auth.js';
|
|
21
|
-
import { createTlsDispatcher } from '../../src/utils/tlsDispatcher.js';
|
|
22
|
-
import { generateClientId } from '../../src/server/eventSubscription.js';
|
|
23
|
-
|
|
24
|
-
// Partial-mock undici: real Agent (createTlsDispatcher needs it), mocked fetch —
|
|
25
|
-
// dispatcher-carrying requests must route here (THE PAIRING RULE, see below).
|
|
26
|
-
vi.mock('undici', async (importOriginal) => {
|
|
27
|
-
const actual = await importOriginal<typeof import('undici')>();
|
|
28
|
-
return { ...actual, fetch: vi.fn() };
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
const ENV_KEY = 'NODE_TLS_REJECT_UNAUTHORIZED';
|
|
32
|
-
|
|
33
|
-
function jsonResponse(body: unknown): Response {
|
|
34
|
-
return new Response(JSON.stringify(body), {
|
|
35
|
-
status: 200,
|
|
36
|
-
headers: { 'content-type': 'application/json' },
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe('SEC-CRYPTO H2: per-client TLS scoping (no global env mutation)', () => {
|
|
41
|
-
const realFetch = global.fetch;
|
|
42
|
-
|
|
43
|
-
afterEach(() => {
|
|
44
|
-
global.fetch = realFetch;
|
|
45
|
-
vi.restoreAllMocks();
|
|
46
|
-
vi.mocked(undiciFetch).mockReset();
|
|
47
|
-
delete process.env[ENV_KEY];
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('Omada request with strictSsl=false never mutates global NODE_TLS_REJECT_UNAUTHORIZED', async () => {
|
|
51
|
-
let envDuringFetch: string | undefined = 'SENTINEL';
|
|
52
|
-
let dispatcherDuringFetch: unknown;
|
|
53
|
-
|
|
54
|
-
// Dispatcher requests route through UNDICI's fetch (pairing rule) — mock it.
|
|
55
|
-
vi.mocked(undiciFetch).mockImplementation((async (_url: unknown, init: unknown) => {
|
|
56
|
-
envDuringFetch = process.env[ENV_KEY];
|
|
57
|
-
dispatcherDuringFetch = (init as { dispatcher?: unknown }).dispatcher;
|
|
58
|
-
return jsonResponse({ errorCode: 0, result: {} });
|
|
59
|
-
}) as unknown as typeof undiciFetch);
|
|
60
|
-
const globalFetchSpy = vi.fn();
|
|
61
|
-
global.fetch = globalFetchSpy as unknown as typeof fetch;
|
|
62
|
-
|
|
63
|
-
const auth = {
|
|
64
|
-
getAccessToken: async () => 'access-token',
|
|
65
|
-
clearToken: () => {},
|
|
66
|
-
} as unknown as AuthManager;
|
|
67
|
-
|
|
68
|
-
const handler = new RequestHandler(
|
|
69
|
-
{ baseUrl: 'https://omada.example:8043', strictSsl: false },
|
|
70
|
-
auth
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
await handler.get('/openapi/v1/anything');
|
|
74
|
-
|
|
75
|
-
// The env var must NOT be set at any point — it stays undefined throughout.
|
|
76
|
-
expect(envDuringFetch).toBeUndefined();
|
|
77
|
-
expect(process.env[ENV_KEY]).toBeUndefined();
|
|
78
|
-
// Insecure TLS must be carried by a per-call dispatcher instead.
|
|
79
|
-
expect(dispatcherDuringFetch).toBeDefined();
|
|
80
|
-
// THE PAIRING RULE (v1.5.5 incident regression): a dispatcher built from
|
|
81
|
-
// the npm undici package must go to undici's OWN fetch — never Node's
|
|
82
|
-
// built-in fetch, whose bundled undici drifts across majors (Node 26:
|
|
83
|
-
// "invalid onError method" -> "fetch failed" crash-loop).
|
|
84
|
-
expect(globalFetchSpy).not.toHaveBeenCalled();
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('Omada request with strictSsl=true sends no insecure dispatcher (built-in fetch)', async () => {
|
|
88
|
-
let dispatcherDuringFetch: unknown = 'SENTINEL';
|
|
89
|
-
|
|
90
|
-
global.fetch = vi.fn(async (_url: unknown, init: unknown) => {
|
|
91
|
-
dispatcherDuringFetch = (init as { dispatcher?: unknown }).dispatcher;
|
|
92
|
-
return jsonResponse({ errorCode: 0, result: {} });
|
|
93
|
-
}) as unknown as typeof fetch;
|
|
94
|
-
|
|
95
|
-
const auth = {
|
|
96
|
-
getAccessToken: async () => 'access-token',
|
|
97
|
-
clearToken: () => {},
|
|
98
|
-
} as unknown as AuthManager;
|
|
99
|
-
|
|
100
|
-
const handler = new RequestHandler(
|
|
101
|
-
{ baseUrl: 'https://omada.example:8043', strictSsl: true },
|
|
102
|
-
auth
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
await handler.get('/openapi/v1/anything');
|
|
106
|
-
|
|
107
|
-
expect(dispatcherDuringFetch).toBeUndefined();
|
|
108
|
-
expect(process.env[ENV_KEY]).toBeUndefined();
|
|
109
|
-
// No dispatcher -> Node's built-in fetch, undici's stays untouched.
|
|
110
|
-
expect(vi.mocked(undiciFetch)).not.toHaveBeenCalled();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('Omada auth with strictSsl=false never mutates global NODE_TLS_REJECT_UNAUTHORIZED', async () => {
|
|
114
|
-
let envDuringFetch: string | undefined = 'SENTINEL';
|
|
115
|
-
let dispatcherDuringFetch: unknown;
|
|
116
|
-
|
|
117
|
-
vi.mocked(undiciFetch).mockImplementation((async (_url: unknown, init: unknown) => {
|
|
118
|
-
envDuringFetch = process.env[ENV_KEY];
|
|
119
|
-
dispatcherDuringFetch = (init as { dispatcher?: unknown }).dispatcher;
|
|
120
|
-
return jsonResponse({
|
|
121
|
-
errorCode: 0,
|
|
122
|
-
result: { accessToken: 'a', refreshToken: 'r', expiresIn: 3600 },
|
|
123
|
-
});
|
|
124
|
-
}) as unknown as typeof undiciFetch);
|
|
125
|
-
const globalFetchSpy = vi.fn();
|
|
126
|
-
global.fetch = globalFetchSpy as unknown as typeof fetch;
|
|
127
|
-
|
|
128
|
-
const auth = new AuthManager({
|
|
129
|
-
baseUrl: 'https://omada.example:8043',
|
|
130
|
-
clientId: 'id',
|
|
131
|
-
clientSecret: 'secret',
|
|
132
|
-
omadacId: 'omadac',
|
|
133
|
-
strictSsl: false,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
const token = await auth.getAccessToken();
|
|
137
|
-
|
|
138
|
-
expect(token).toBe('a');
|
|
139
|
-
expect(envDuringFetch).toBeUndefined();
|
|
140
|
-
expect(process.env[ENV_KEY]).toBeUndefined();
|
|
141
|
-
expect(dispatcherDuringFetch).toBeDefined();
|
|
142
|
-
expect(globalFetchSpy).not.toHaveBeenCalled();
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('plain-http base with strictSsl=false carries NO dispatcher (v1.5.5 NAS config)', async () => {
|
|
146
|
-
// The exact production config that crash-looped v1.5.5: http HA +
|
|
147
|
-
// HA_STRICT_SSL=false. TLS relaxation is meaningless without TLS, so no
|
|
148
|
-
// dispatcher is built at all -> built-in fetch -> immune to undici drift.
|
|
149
|
-
expect(createTlsDispatcher(false, 'http://192.168.0.19:8123')).toBeUndefined();
|
|
150
|
-
expect(createTlsDispatcher(false, 'HTTP://UPPER.example')).toBeUndefined();
|
|
151
|
-
// https targets still get the relaxing dispatcher.
|
|
152
|
-
expect(createTlsDispatcher(false, 'https://omada.example')).toBeDefined();
|
|
153
|
-
// And with no baseUrl hint, behavior is unchanged (dispatcher created).
|
|
154
|
-
expect(createTlsDispatcher(false)).toBeDefined();
|
|
155
|
-
expect(createTlsDispatcher(true, 'https://omada.example')).toBeUndefined();
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe('SEC-CRYPTO L5: SSE client IDs use a CSPRNG', () => {
|
|
160
|
-
it('generateClientId returns an unpredictable UUID, not a Math.random/timestamp string', () => {
|
|
161
|
-
const id = generateClientId();
|
|
162
|
-
// RFC 4122 v4 UUID shape.
|
|
163
|
-
expect(id).toMatch(
|
|
164
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
165
|
-
);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('generateClientId returns distinct values across calls', () => {
|
|
169
|
-
const ids = new Set(Array.from({ length: 100 }, () => generateClientId()));
|
|
170
|
-
expect(ids.size).toBe(100);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SEC-DEPS — Vulnerable & Outdated Components (OWASP A06:2021)
|
|
3
|
-
*
|
|
4
|
-
* Finding M9: production dependency tree shipped known-vulnerable transitive
|
|
5
|
-
* packages (hono / qs / path-to-regexp via the MCP SDK, plus a direct `ws`).
|
|
6
|
-
*
|
|
7
|
-
* These tests are the durable CI gate for that finding:
|
|
8
|
-
* 1. `npm audit --omit=dev` must report ZERO high/critical advisories in the
|
|
9
|
-
* PRODUCTION dependency tree (dev-only tooling is out of scope). A small,
|
|
10
|
-
* explicitly-documented allowlist exists for unavoidable transitive advisories
|
|
11
|
-
* that have no upstream fix yet — each entry must be justified in a comment.
|
|
12
|
-
* 2. After bumping the SDK/ws, re-assert the per-request transport invariant so a
|
|
13
|
-
* dependency upgrade can never silently change how transports are constructed.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { describe, it, expect } from 'vitest';
|
|
17
|
-
import { execFileSync } from 'node:child_process';
|
|
18
|
-
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Advisory IDs (GHSA / npm) we knowingly accept because there is no fixed
|
|
22
|
-
* upstream release reachable through our dependency tree. MUST stay empty unless
|
|
23
|
-
* a transitive advisory is genuinely unfixable — add the ID with a justification.
|
|
24
|
-
*/
|
|
25
|
-
const HIGH_CRITICAL_ALLOWLIST = new Set<string | number>([
|
|
26
|
-
// (intentionally empty — all known high/critical advisories are fixed)
|
|
27
|
-
]);
|
|
28
|
-
|
|
29
|
-
interface AuditAdvisory {
|
|
30
|
-
severity: string;
|
|
31
|
-
source?: number;
|
|
32
|
-
url?: string;
|
|
33
|
-
title?: string;
|
|
34
|
-
name?: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface AuditReport {
|
|
38
|
-
vulnerabilities?: Record<
|
|
39
|
-
string,
|
|
40
|
-
{
|
|
41
|
-
severity: string;
|
|
42
|
-
via: Array<string | AuditAdvisory>;
|
|
43
|
-
}
|
|
44
|
-
>;
|
|
45
|
-
metadata?: {
|
|
46
|
-
vulnerabilities?: Record<string, number>;
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Run `npm audit --omit=dev --json` and return the parsed report. npm audit exits
|
|
52
|
-
* NON-zero when advisories are present, so we capture stdout from the thrown error
|
|
53
|
-
* too. Returns null when the registry is unreachable (offline dev) so the gate
|
|
54
|
-
* skips rather than producing a false failure.
|
|
55
|
-
*/
|
|
56
|
-
function runProdAudit(): AuditReport | null {
|
|
57
|
-
let stdout: string;
|
|
58
|
-
try {
|
|
59
|
-
stdout = execFileSync('npm', ['audit', '--omit=dev', '--json'], {
|
|
60
|
-
encoding: 'utf8',
|
|
61
|
-
shell: process.platform === 'win32', // npm is npm.cmd on Windows
|
|
62
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
63
|
-
maxBuffer: 32 * 1024 * 1024,
|
|
64
|
-
});
|
|
65
|
-
} catch (err) {
|
|
66
|
-
const e = err as { stdout?: string; message?: string };
|
|
67
|
-
if (typeof e.stdout === 'string' && e.stdout.trim().startsWith('{')) {
|
|
68
|
-
stdout = e.stdout;
|
|
69
|
-
} else {
|
|
70
|
-
// Could not run audit (offline / registry down) — signal skip.
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
return JSON.parse(stdout) as AuditReport;
|
|
77
|
-
} catch {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
describe('SEC-DEPS — Vulnerable & Outdated Components (M9)', () => {
|
|
83
|
-
describe('production audit gate', () => {
|
|
84
|
-
it('reports zero high/critical advisories in the production tree', () => {
|
|
85
|
-
const report = runProdAudit();
|
|
86
|
-
if (report === null) {
|
|
87
|
-
// Registry unreachable — do not fail the suite offline.
|
|
88
|
-
console.warn('SEC-DEPS: npm audit unavailable (offline?), skipping gate assertion');
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const offenders: string[] = [];
|
|
93
|
-
for (const [pkg, vuln] of Object.entries(report.vulnerabilities ?? {})) {
|
|
94
|
-
const sev = vuln.severity?.toLowerCase();
|
|
95
|
-
if (sev !== 'high' && sev !== 'critical') {
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
// Allow only if EVERY advisory behind this package is allowlisted.
|
|
99
|
-
const advisoryIds = vuln.via
|
|
100
|
-
.filter((v): v is AuditAdvisory => typeof v === 'object')
|
|
101
|
-
.map((v) => v.source)
|
|
102
|
-
.filter((s): s is number => typeof s === 'number');
|
|
103
|
-
const allAllowlisted =
|
|
104
|
-
advisoryIds.length > 0 && advisoryIds.every((id) => HIGH_CRITICAL_ALLOWLIST.has(id));
|
|
105
|
-
if (!allAllowlisted) {
|
|
106
|
-
offenders.push(`${pkg} (${sev}; advisories: ${advisoryIds.join(', ') || 'n/a'})`);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
expect(offenders, `Unfixed high/critical prod advisories:\n${offenders.join('\n')}`).toEqual(
|
|
111
|
-
[]
|
|
112
|
-
);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('reports zero high/critical via the audit metadata summary', () => {
|
|
116
|
-
const report = runProdAudit();
|
|
117
|
-
if (report === null) {
|
|
118
|
-
console.warn('SEC-DEPS: npm audit unavailable (offline?), skipping metadata assertion');
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
const counts = report.metadata?.vulnerabilities ?? {};
|
|
122
|
-
const high = counts.high ?? 0;
|
|
123
|
-
const critical = counts.critical ?? 0;
|
|
124
|
-
// With an empty allowlist the metadata summary must also be clean.
|
|
125
|
-
if (HIGH_CRITICAL_ALLOWLIST.size === 0) {
|
|
126
|
-
expect(high + critical).toBe(0);
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
describe('per-request transport invariant (post-upgrade)', () => {
|
|
132
|
-
it('SDK still exposes a constructable StreamableHTTPServerTransport', () => {
|
|
133
|
-
expect(typeof NodeStreamableHTTPServerTransport).toBe('function');
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('constructs an independent transport per call (stateless mode)', () => {
|
|
137
|
-
const make = (): NodeStreamableHTTPServerTransport =>
|
|
138
|
-
new NodeStreamableHTTPServerTransport({
|
|
139
|
-
sessionIdGenerator: undefined, // stateless: one transport per request
|
|
140
|
-
enableDnsRebindingProtection: true,
|
|
141
|
-
allowedHosts: ['localhost:3000'],
|
|
142
|
-
allowedOrigins: ['http://localhost:3000'],
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
const a = make();
|
|
146
|
-
const b = make();
|
|
147
|
-
expect(a).toBeInstanceOf(NodeStreamableHTTPServerTransport);
|
|
148
|
-
expect(b).toBeInstanceOf(NodeStreamableHTTPServerTransport);
|
|
149
|
-
expect(a).not.toBe(b); // distinct instances — no shared session state
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
});
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SEC-INJ — Injection (path / parameter)
|
|
3
|
-
* OWASP A03:2021 (Injection); overlaps SEC-SSRF (A10:2021).
|
|
4
|
-
*
|
|
5
|
-
* Finding covered:
|
|
6
|
-
* H4 — Home Assistant API paths interpolate user input without encoding, so an
|
|
7
|
-
* entity_id / domain / service containing "/", "..", "?" or "#" could escape
|
|
8
|
-
* the intended /api/<resource> path or inject query parameters.
|
|
9
|
-
*
|
|
10
|
-
* Secure behavior asserted:
|
|
11
|
-
* - Call sites percent-encode each user-controlled path segment.
|
|
12
|
-
* - The request layer refuses any path that resolves outside /api/ (traversal guard).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
16
|
-
import { RequestHandler } from '../../src/haClient/request.js';
|
|
17
|
-
import { StateOperations } from '../../src/haClient/states.js';
|
|
18
|
-
import { ServiceOperations } from '../../src/haClient/services.js';
|
|
19
|
-
|
|
20
|
-
function makeHandler() {
|
|
21
|
-
return new RequestHandler({
|
|
22
|
-
baseUrl: 'http://ha.test:8123',
|
|
23
|
-
token: 'tok',
|
|
24
|
-
timeout: 30000,
|
|
25
|
-
strictSsl: true,
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Capture the URL string passed to fetch and return an empty JSON response. */
|
|
30
|
-
function stubFetch() {
|
|
31
|
-
const fetchMock = vi.fn(async () => ({
|
|
32
|
-
ok: true,
|
|
33
|
-
status: 200,
|
|
34
|
-
statusText: 'OK',
|
|
35
|
-
headers: { get: () => 'application/json' },
|
|
36
|
-
json: async () => ({}),
|
|
37
|
-
text: async () => '',
|
|
38
|
-
}));
|
|
39
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
40
|
-
return fetchMock;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
describe('SEC-INJ — Injection (H4: HA API path)', () => {
|
|
44
|
-
beforeEach(() => {
|
|
45
|
-
stubFetch();
|
|
46
|
-
});
|
|
47
|
-
afterEach(() => {
|
|
48
|
-
vi.unstubAllGlobals();
|
|
49
|
-
vi.restoreAllMocks();
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe('request-layer traversal guard', () => {
|
|
53
|
-
it('rejects a path that escapes /api via ".." before making any request', async () => {
|
|
54
|
-
const handler = makeHandler();
|
|
55
|
-
await expect(handler.get('/states/../../secret')).rejects.toThrow();
|
|
56
|
-
expect(fetch).not.toHaveBeenCalled();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('allows a normal /api path', async () => {
|
|
60
|
-
const handler = makeHandler();
|
|
61
|
-
await handler.get('/states');
|
|
62
|
-
expect(fetch).toHaveBeenCalledOnce();
|
|
63
|
-
const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
64
|
-
expect(new URL(calledUrl).pathname).toBe('/api/states');
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe('call-site encoding — StateOperations.getState', () => {
|
|
69
|
-
it('encodes a malicious entity_id so it cannot escape /api/states', async () => {
|
|
70
|
-
const states = new StateOperations(makeHandler());
|
|
71
|
-
await states.getState('x/../../admin');
|
|
72
|
-
|
|
73
|
-
const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
74
|
-
const { pathname } = new URL(calledUrl);
|
|
75
|
-
// The whole id stays a single encoded segment under /api/states/.
|
|
76
|
-
expect(pathname.startsWith('/api/states/')).toBe(true);
|
|
77
|
-
expect(pathname).not.toContain('/admin');
|
|
78
|
-
expect(calledUrl).toContain('%2F'); // the slashes were encoded
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('encodes "?" so it cannot inject query parameters', async () => {
|
|
82
|
-
const states = new StateOperations(makeHandler());
|
|
83
|
-
await states.getState('light.x?evil=1');
|
|
84
|
-
|
|
85
|
-
const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
86
|
-
expect(new URL(calledUrl).searchParams.get('evil')).toBeNull();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('passes a normal entity_id through unchanged', async () => {
|
|
90
|
-
const states = new StateOperations(makeHandler());
|
|
91
|
-
await states.getState('light.kitchen');
|
|
92
|
-
|
|
93
|
-
const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
94
|
-
expect(new URL(calledUrl).pathname).toBe('/api/states/light.kitchen');
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe('call-site encoding — ServiceOperations.callService', () => {
|
|
99
|
-
it('encodes domain and service segments', async () => {
|
|
100
|
-
const services = new ServiceOperations(makeHandler());
|
|
101
|
-
await services.callService({ domain: 'light', service: 'turn_on/../../admin' });
|
|
102
|
-
|
|
103
|
-
const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
104
|
-
const { pathname } = new URL(calledUrl);
|
|
105
|
-
expect(pathname.startsWith('/api/services/light/')).toBe(true);
|
|
106
|
-
expect(pathname).not.toContain('/admin');
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('passes a normal service call through unchanged', async () => {
|
|
110
|
-
const services = new ServiceOperations(makeHandler());
|
|
111
|
-
await services.callService({ domain: 'light', service: 'turn_on' });
|
|
112
|
-
|
|
113
|
-
const calledUrl = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
114
|
-
expect(new URL(calledUrl).pathname).toBe('/api/services/light/turn_on');
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
});
|