phase1-security.test.mjs
421 lines 17.6 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Phase 1 Security Remediation Tests
3 *
4 * Covers all 5 Phase 1 items from docs/SECURITY-AUDIT-PLAN.md:
5 * 1.1 — Trust proxy for Express rate limiting
6 * 1.2 — Zip-slip protection in AdmZip import
7 * 1.3 — Self-hosted default-admin startup warning when roleMap is empty
8 * 1.4 — Header allowlist replacing ...req.headers spread
9 * 1.5 — Billing enforcement startup warning when BILLING_ENFORCE unset
10 */
11
12 import { test, describe, beforeEach, afterEach } from 'node:test';
13 import assert from 'node:assert/strict';
14 import path from 'node:path';
15
16 // ---------------------------------------------------------------------------
17 // 1.1 Trust proxy for Express rate limiting
18 // ---------------------------------------------------------------------------
19 describe('1.1 trust proxy — Express rate limit IP resolution', () => {
20 /**
21 * Mirrors the trust-proxy logic: Express uses req.ip which reads
22 * X-Forwarded-For when trust proxy is enabled. Tests confirm that the
23 * setting is present and that rate-limit middleware receives the real client
24 * IP rather than the CDN/load-balancer address.
25 */
26
27 function simulateExpressIp(trustProxy, headers, remoteAddress) {
28 // Simplified mirror of Express req.ip behaviour
29 if (!trustProxy) return remoteAddress;
30 const xff = headers['x-forwarded-for'];
31 if (!xff) return remoteAddress;
32 // Express with trust proxy = 1 uses the leftmost untrusted hop
33 return xff.split(',')[0].trim();
34 }
35
36 test('with trust proxy disabled, IP is the socket remote address (CDN IP)', () => {
37 const ip = simulateExpressIp(false, { 'x-forwarded-for': '1.2.3.4' }, '10.0.0.1');
38 assert.equal(ip, '10.0.0.1', 'should return the socket address, not the forwarded IP');
39 });
40
41 test('with trust proxy enabled, IP is taken from X-Forwarded-For (real client IP)', () => {
42 const ip = simulateExpressIp(true, { 'x-forwarded-for': '1.2.3.4, 10.0.0.2' }, '10.0.0.1');
43 assert.equal(ip, '1.2.3.4', 'should return the real client IP from X-Forwarded-For');
44 });
45
46 test('with trust proxy enabled and no X-Forwarded-For, falls back to remote address', () => {
47 const ip = simulateExpressIp(true, {}, '10.0.0.1');
48 assert.equal(ip, '10.0.0.1', 'should fall back to remote address when XFF is absent');
49 });
50
51 test('rate limiter uses client IP for key (not CDN IP) when trust proxy is on', () => {
52 const clientIp = '203.0.113.5';
53 const cdnIp = '192.168.1.1';
54 const ipWithProxy = simulateExpressIp(true, { 'x-forwarded-for': clientIp }, cdnIp);
55 const ipWithoutProxy = simulateExpressIp(false, { 'x-forwarded-for': clientIp }, cdnIp);
56 assert.equal(ipWithProxy, clientIp, 'trust proxy on: rate limiter keys on real client IP');
57 assert.equal(ipWithoutProxy, cdnIp, 'trust proxy off: rate limiter would key on CDN IP (bad)');
58 assert.notEqual(ipWithProxy, ipWithoutProxy);
59 });
60
61 test('trust proxy value of 1 trusts exactly one hop', () => {
62 // With trust proxy = 1, the first hop in XFF is returned
63 // (Express validates from the right by default, returning the first untrusted)
64 const headers = { 'x-forwarded-for': 'real-client, cdn-hop1' };
65 const ip = simulateExpressIp(true, headers, 'lb-address');
66 assert.equal(ip, 'real-client', 'should pick the leftmost entry as the real client');
67 });
68 });
69
70 // ---------------------------------------------------------------------------
71 // 1.2 Zip-slip protection
72 // ---------------------------------------------------------------------------
73 describe('1.2 zip-slip protection — path traversal detection', () => {
74 /**
75 * Mirrors the zip-slip validation added to hub/server.mjs and hub/bridge/server.mjs.
76 * The guard resolves each entry path and verifies it stays inside extractDir.
77 */
78
79 function validateZipEntries(entries, extractDir) {
80 const extractDirResolved = path.resolve(extractDir) + path.sep;
81 for (const entryName of entries) {
82 const entryResolved = path.resolve(extractDir, entryName);
83 if (entryResolved !== path.resolve(extractDir) && !entryResolved.startsWith(extractDirResolved)) {
84 return { safe: false, offendingEntry: entryName };
85 }
86 }
87 return { safe: true };
88 }
89
90 test('benign entries within extract dir are allowed', () => {
91 const extractDir = '/tmp/knowtation-test-extract';
92 const entries = ['notes/note1.md', 'notes/subdir/note2.md', 'media/image.png'];
93 const result = validateZipEntries(entries, extractDir);
94 assert.ok(result.safe, 'normal nested entries should pass');
95 });
96
97 test('classic zip-slip "../" traversal is rejected', () => {
98 const extractDir = '/tmp/knowtation-test-extract';
99 const entries = ['notes/note.md', '../evil.sh'];
100 const result = validateZipEntries(entries, extractDir);
101 assert.ok(!result.safe, 'path traversal entry should fail validation');
102 assert.equal(result.offendingEntry, '../evil.sh');
103 });
104
105 test('absolute path escape is rejected', () => {
106 const extractDir = '/tmp/knowtation-test-extract';
107 const entries = ['/etc/passwd'];
108 const result = validateZipEntries(entries, extractDir);
109 assert.ok(!result.safe, 'absolute path outside extract dir should fail');
110 });
111
112 test('deep traversal through nested dirs is rejected', () => {
113 const extractDir = '/tmp/knowtation-test-extract';
114 const entries = ['a/b/../../../../../../etc/cron.d/attack'];
115 const result = validateZipEntries(entries, extractDir);
116 assert.ok(!result.safe, 'deep traversal should fail');
117 });
118
119 test('entry that normalises to the extractDir root is allowed (empty dir entry)', () => {
120 const extractDir = '/tmp/knowtation-test-extract';
121 const entries = ['.'];
122 const result = validateZipEntries(entries, extractDir);
123 assert.ok(result.safe, 'dot entry resolving to extractDir root is harmless');
124 });
125
126 test('entry just outside extract dir (sibling) is rejected', () => {
127 const extractDir = '/tmp/knowtation-test-extract';
128 const entries = ['../sibling-dir/file.txt'];
129 const result = validateZipEntries(entries, extractDir);
130 assert.ok(!result.safe, 'sibling directory traversal should fail');
131 });
132
133 test('multiple safe entries all pass', () => {
134 const extractDir = '/tmp/knowtation-test-extract';
135 const entries = ['a/b/c.md', 'x/y/z/file.txt', 'root.md'];
136 const result = validateZipEntries(entries, extractDir);
137 assert.ok(result.safe, 'all safe entries should pass');
138 });
139
140 test('empty entry list is safe', () => {
141 const extractDir = '/tmp/knowtation-test-extract';
142 const result = validateZipEntries([], extractDir);
143 assert.ok(result.safe, 'empty zip is safe');
144 });
145 });
146
147 // ---------------------------------------------------------------------------
148 // 1.3 Default-admin startup warning when roleMap is empty in production
149 // ---------------------------------------------------------------------------
150 describe('1.3 default-admin warning — empty roleMap in production', () => {
151 /**
152 * Mirrors the issueToken / effectiveRole logic and the startup warning condition
153 * from hub/server.mjs. When roleMap.size === 0, every authenticated user gets
154 * admin role (first-run convenience). In production this must trigger a warning.
155 */
156
157 function effectiveRoleFromMap(roleMap, sub) {
158 if (roleMap.size === 0) return 'admin';
159 const stored = roleMap.get(sub);
160 if (stored === 'admin') return 'admin';
161 if (stored && ['editor', 'viewer', 'evaluator'].includes(stored)) return stored;
162 return 'editor'; // default member → editor
163 }
164
165 function shouldWarnDefaultAdmin(isProduction, roleMap) {
166 return isProduction && roleMap.size === 0;
167 }
168
169 test('empty roleMap assigns admin to any user in dev (no warning)', () => {
170 const roleMap = new Map();
171 assert.equal(effectiveRoleFromMap(roleMap, 'google:123'), 'admin');
172 assert.ok(!shouldWarnDefaultAdmin(false, roleMap), 'no warning in dev');
173 });
174
175 test('empty roleMap in production should trigger warning', () => {
176 const roleMap = new Map();
177 assert.ok(shouldWarnDefaultAdmin(true, roleMap), 'warning required in production with empty roleMap');
178 assert.equal(effectiveRoleFromMap(roleMap, 'google:999'), 'admin', 'user still gets admin role');
179 });
180
181 test('non-empty roleMap in production suppresses warning', () => {
182 const roleMap = new Map([['google:admin-user', 'admin']]);
183 assert.ok(!shouldWarnDefaultAdmin(true, roleMap), 'no warning when roles are configured');
184 });
185
186 test('a single role entry is enough to silence the warning', () => {
187 const roleMap = new Map([['github:12345', 'editor']]);
188 assert.ok(!shouldWarnDefaultAdmin(true, roleMap), 'one entry is sufficient');
189 });
190
191 test('non-admin users get correct roles when roleMap is populated', () => {
192 const roleMap = new Map([
193 ['google:admin-user', 'admin'],
194 ['github:editor-user', 'editor'],
195 ['google:viewer-user', 'viewer'],
196 ]);
197 assert.equal(effectiveRoleFromMap(roleMap, 'google:admin-user'), 'admin');
198 assert.equal(effectiveRoleFromMap(roleMap, 'github:editor-user'), 'editor');
199 assert.equal(effectiveRoleFromMap(roleMap, 'google:viewer-user'), 'viewer');
200 assert.equal(effectiveRoleFromMap(roleMap, 'google:unknown-user'), 'editor', 'unknown defaults to editor');
201 });
202
203 test('warning condition is independent of NODE_ENV value (only isProduction flag matters)', () => {
204 const roleMap = new Map();
205 assert.ok(!shouldWarnDefaultAdmin(false, roleMap), 'false = no warning regardless of roleMap');
206 assert.ok(shouldWarnDefaultAdmin(true, roleMap), 'true = warning when roleMap empty');
207 });
208 });
209
210 // ---------------------------------------------------------------------------
211 // 1.4 Header allowlist — replacing ...req.headers spread
212 // ---------------------------------------------------------------------------
213 describe('1.4 header allowlist — safe header forwarding', () => {
214 /**
215 * Mirrors the PROXY_HEADER_ALLOWLIST constant and header-building logic
216 * added to hub/gateway/server.mjs proxyTo and proxyToCanister.
217 */
218
219 const PROXY_HEADER_ALLOWLIST = new Set([
220 'content-type',
221 'accept',
222 'accept-language',
223 'accept-encoding',
224 ]);
225
226 function buildBridgeHeaders(baseUrl, reqHeaders) {
227 const headers = { host: new URL(baseUrl).host };
228 for (const k of PROXY_HEADER_ALLOWLIST) {
229 if (reqHeaders[k] !== undefined) headers[k] = reqHeaders[k];
230 }
231 if (reqHeaders.authorization) headers.authorization = reqHeaders.authorization;
232 if (reqHeaders['x-vault-id']) headers['x-vault-id'] = reqHeaders['x-vault-id'];
233 return headers;
234 }
235
236 function buildCanisterHeaders(canisterUrl, extraHeaders, reqHeaders) {
237 const headers = { host: new URL(canisterUrl).host, ...extraHeaders };
238 for (const k of PROXY_HEADER_ALLOWLIST) {
239 if (reqHeaders[k] !== undefined) headers[k] = reqHeaders[k];
240 }
241 return headers;
242 }
243
244 const CANISTER_URL = 'https://canister.example.com';
245 const BRIDGE_URL = 'https://bridge.example.com';
246
247 test('allowlist contains expected safe headers', () => {
248 assert.ok(PROXY_HEADER_ALLOWLIST.has('content-type'));
249 assert.ok(PROXY_HEADER_ALLOWLIST.has('accept'));
250 assert.ok(PROXY_HEADER_ALLOWLIST.has('accept-language'));
251 assert.ok(PROXY_HEADER_ALLOWLIST.has('accept-encoding'));
252 });
253
254 test('allowlist does NOT contain dangerous headers', () => {
255 const dangerous = [
256 'cookie',
257 'x-forwarded-for',
258 'x-real-ip',
259 'x-forwarded-host',
260 'x-forwarded-proto',
261 'x-test-user',
262 'origin',
263 'referer',
264 'host',
265 'authorization', // not in base allowlist (added explicitly for bridge only)
266 'x-gateway-auth',
267 ];
268 for (const h of dangerous) {
269 assert.ok(!PROXY_HEADER_ALLOWLIST.has(h), `dangerous header "${h}" must not be in allowlist`);
270 }
271 });
272
273 test('proxyTo (bridge): only allowlisted headers forwarded plus authorization and x-vault-id', () => {
274 const reqHeaders = {
275 'content-type': 'application/json',
276 'authorization': 'Bearer jwt-token',
277 'x-vault-id': 'my-vault',
278 'cookie': 'session=secret',
279 'x-forwarded-for': '1.2.3.4',
280 'x-custom-internal': 'leak',
281 'origin': 'https://evil.example.com',
282 'referer': 'https://attacker.example.com',
283 };
284 const forwarded = buildBridgeHeaders(BRIDGE_URL, reqHeaders);
285
286 assert.equal(forwarded['content-type'], 'application/json');
287 assert.equal(forwarded['authorization'], 'Bearer jwt-token');
288 assert.equal(forwarded['x-vault-id'], 'my-vault');
289 assert.equal(forwarded.host, 'bridge.example.com');
290
291 assert.equal(forwarded.cookie, undefined, 'cookie must not be forwarded');
292 assert.equal(forwarded['x-forwarded-for'], undefined, 'x-forwarded-for must not be forwarded');
293 assert.equal(forwarded['x-custom-internal'], undefined, 'custom headers must not leak');
294 assert.equal(forwarded.origin, undefined, 'origin must not be forwarded');
295 assert.equal(forwarded.referer, undefined, 'referer must not be forwarded');
296 });
297
298 test('proxyToCanister: authorization is NOT forwarded (canister uses x-user-id + x-gateway-auth)', () => {
299 const reqHeaders = {
300 'content-type': 'application/json',
301 'authorization': 'Bearer jwt-token',
302 'cookie': 'session=leaked',
303 'x-test-user': 'injected',
304 };
305 const forwarded = buildCanisterHeaders(CANISTER_URL, {
306 'x-user-id': 'effective-uid',
307 'x-actor-id': 'actor-uid',
308 'x-vault-id': 'default',
309 }, reqHeaders);
310
311 assert.equal(forwarded['x-user-id'], 'effective-uid');
312 assert.equal(forwarded['x-actor-id'], 'actor-uid');
313 assert.equal(forwarded['content-type'], 'application/json');
314 assert.equal(forwarded['authorization'], undefined, 'JWT must not reach canister');
315 assert.equal(forwarded.cookie, undefined, 'cookie must not reach canister');
316 assert.equal(forwarded['x-test-user'], undefined, 'x-test-user must not reach canister');
317 assert.equal(forwarded.origin, undefined, 'origin must not reach canister');
318 });
319
320 test('proxyTo (bridge): headers absent in request are not forwarded', () => {
321 const reqHeaders = {};
322 const forwarded = buildBridgeHeaders(BRIDGE_URL, reqHeaders);
323 assert.equal(forwarded['content-type'], undefined);
324 assert.equal(forwarded['authorization'], undefined);
325 assert.equal(forwarded['x-vault-id'], undefined);
326 assert.equal(forwarded.host, 'bridge.example.com');
327 });
328
329 test('accept-language and accept-encoding are forwarded when present', () => {
330 const reqHeaders = {
331 'accept-language': 'en-US,en;q=0.9',
332 'accept-encoding': 'gzip, deflate, br',
333 };
334 const forwarded = buildBridgeHeaders(BRIDGE_URL, reqHeaders);
335 assert.equal(forwarded['accept-language'], 'en-US,en;q=0.9');
336 assert.equal(forwarded['accept-encoding'], 'gzip, deflate, br');
337 });
338
339 test('host header is derived from baseUrl, not forwarded from client', () => {
340 const reqHeaders = { host: 'attacker.evil.com' };
341 const forwarded = buildBridgeHeaders(BRIDGE_URL, reqHeaders);
342 assert.equal(forwarded.host, 'bridge.example.com', 'host must be derived from upstream URL');
343 });
344 });
345
346 // ---------------------------------------------------------------------------
347 // 1.5 Billing enforcement startup warning
348 // ---------------------------------------------------------------------------
349 describe('1.5 billing enforcement warning — BILLING_ENFORCE unset in hosted mode', () => {
350 /**
351 * Mirrors the billingEnforced() helper from hub/gateway/billing-constants.mjs
352 * and the startup-warning condition added to hub/gateway/server.mjs.
353 */
354
355 function billingEnforced(env = process.env) {
356 return env.BILLING_ENFORCE === 'true' || env.BILLING_ENFORCE === '1';
357 }
358
359 function shouldWarnBillingEnforce(canisterUrl, env) {
360 return Boolean(canisterUrl) && !billingEnforced(env);
361 }
362
363 let savedEnv;
364 beforeEach(() => {
365 savedEnv = process.env.BILLING_ENFORCE;
366 });
367 afterEach(() => {
368 if (savedEnv !== undefined) {
369 process.env.BILLING_ENFORCE = savedEnv;
370 } else {
371 delete process.env.BILLING_ENFORCE;
372 }
373 });
374
375 test('billingEnforced() returns false when BILLING_ENFORCE is unset', () => {
376 delete process.env.BILLING_ENFORCE;
377 assert.ok(!billingEnforced(), 'unset BILLING_ENFORCE must return false');
378 });
379
380 test('billingEnforced() returns false when BILLING_ENFORCE is empty string', () => {
381 assert.ok(!billingEnforced({ BILLING_ENFORCE: '' }));
382 });
383
384 test('billingEnforced() returns false when BILLING_ENFORCE is "false"', () => {
385 assert.ok(!billingEnforced({ BILLING_ENFORCE: 'false' }));
386 });
387
388 test('billingEnforced() returns true when BILLING_ENFORCE is "true"', () => {
389 process.env.BILLING_ENFORCE = 'true';
390 assert.ok(billingEnforced(), '"true" must enable enforcement');
391 });
392
393 test('billingEnforced() returns true when BILLING_ENFORCE is "1"', () => {
394 process.env.BILLING_ENFORCE = '1';
395 assert.ok(billingEnforced(), '"1" must enable enforcement');
396 });
397
398 test('warning is required when CANISTER_URL set and billing not enforced', () => {
399 assert.ok(shouldWarnBillingEnforce('https://canister.example.com', {}));
400 });
401
402 test('no warning when BILLING_ENFORCE is true even with CANISTER_URL', () => {
403 assert.ok(!shouldWarnBillingEnforce('https://canister.example.com', { BILLING_ENFORCE: 'true' }));
404 });
405
406 test('no warning when CANISTER_URL is empty (self-hosted / local dev without canister)', () => {
407 assert.ok(!shouldWarnBillingEnforce('', {}));
408 assert.ok(!shouldWarnBillingEnforce(undefined, {}));
409 });
410
411 test('no warning when CANISTER_URL set and BILLING_ENFORCE is "1"', () => {
412 assert.ok(!shouldWarnBillingEnforce('https://c.example.com', { BILLING_ENFORCE: '1' }));
413 });
414
415 test('billingEnforced() reads from process.env by default', () => {
416 process.env.BILLING_ENFORCE = 'true';
417 assert.ok(billingEnforced());
418 process.env.BILLING_ENFORCE = 'false';
419 assert.ok(!billingEnforced());
420 });
421 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago