phase3-security.test.mjs
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2
feat(auth): persistent login system + C7 session introspection
Human
minor
⚠ breaking
16 days ago
| 1 | /** |
| 2 | * Phase 3 Security Remediation Tests |
| 3 | * |
| 4 | * Covers all 6 Phase 3 items from docs/SECURITY-AUDIT-PLAN.md: |
| 5 | * 3.1 — JWT token-in-URL: OAuth redirect uses URL fragment (#token=); gateway JWT expiry shortened from 7d |
| 6 | * 3.2 — Image proxy: short-lived HMAC-signed token replaces full JWT in ?token= query param |
| 7 | * 3.3 — Bridge write routes: requireBridgeEditorOrAdmin guards all mutation endpoints |
| 8 | * 3.4 — MCP in-memory refresh token store: periodic sweep for expired entries |
| 9 | * 3.5 — CORS on canister: corsHeaders() locks origin when gateway_auth_secret is set (Motoko structural) |
| 10 | * 3.6 — path-to-regexp ReDoS CVE resolved (npm audit passes) |
| 11 | */ |
| 12 | |
| 13 | import { test, describe } from 'node:test'; |
| 14 | import assert from 'node:assert/strict'; |
| 15 | import fs from 'node:fs'; |
| 16 | import path from 'node:path'; |
| 17 | import crypto from 'node:crypto'; |
| 18 | import { fileURLToPath } from 'node:url'; |
| 19 | |
| 20 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 21 | const ROOT = path.resolve(__dirname, '..'); |
| 22 | |
| 23 | // --------------------------------------------------------------------------- |
| 24 | // 3.1 JWT token-in-URL: fragment-based redirect + shortened expiry |
| 25 | // --------------------------------------------------------------------------- |
| 26 | describe('3.1 JWT token-in-URL: OAuth redirects use fragment, gateway expiry shortened', () => { |
| 27 | let gatewaySource; |
| 28 | let selfHostedSource; |
| 29 | |
| 30 | const loadGateway = () => { |
| 31 | if (!gatewaySource) gatewaySource = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8'); |
| 32 | return gatewaySource; |
| 33 | }; |
| 34 | const loadSelfHosted = () => { |
| 35 | if (!selfHostedSource) selfHostedSource = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8'); |
| 36 | return selfHostedSource; |
| 37 | }; |
| 38 | |
| 39 | test('gateway postLoginRedirect uses # fragment, not ?token= query param', () => { |
| 40 | const src = loadGateway(); |
| 41 | assert.ok(src.includes('/hub/#'), 'postLoginRedirect must redirect to #fragment'); |
| 42 | const fnBlock = src.slice(src.indexOf('function postLoginRedirect')); |
| 43 | const fnEnd = fnBlock.indexOf('\n}'); |
| 44 | const fnBody = fnBlock.slice(0, fnEnd); |
| 45 | assert.ok(!fnBody.includes('?token='), 'postLoginRedirect must NOT use ?token= query'); |
| 46 | }); |
| 47 | |
| 48 | test('gateway JWT_EXPIRY default is no longer 7d', () => { |
| 49 | const src = loadGateway(); |
| 50 | const match = src.match(/JWT_EXPIRY\s*=\s*process\.env\.HUB_JWT_EXPIRY\s*\|\|\s*'([^']+)'/); |
| 51 | assert.ok(match, 'JWT_EXPIRY constant must exist with default'); |
| 52 | assert.notEqual(match[1], '7d', 'default JWT_EXPIRY must not be 7d'); |
| 53 | assert.equal(match[1], '24h', 'default JWT_EXPIRY should be 24h'); |
| 54 | }); |
| 55 | |
| 56 | test('self-hosted handleAuthCallback uses # fragment, not ?token= query param', () => { |
| 57 | const src = loadSelfHosted(); |
| 58 | assert.ok( |
| 59 | src.includes('/#token=') || src.includes("'/#token='"), |
| 60 | 'self-hosted redirect must use # fragment for token' |
| 61 | ); |
| 62 | const postRedirectBlock = src.slice(src.indexOf('function handleAuthCallback')); |
| 63 | assert.ok( |
| 64 | !postRedirectBlock.includes('/?token='), |
| 65 | 'handleAuthCallback must NOT use ?token= query param' |
| 66 | ); |
| 67 | }); |
| 68 | }); |
| 69 | |
| 70 | // --------------------------------------------------------------------------- |
| 71 | // 3.2 Image proxy: short-lived HMAC-signed token |
| 72 | // --------------------------------------------------------------------------- |
| 73 | describe('3.2 Image proxy: HMAC-signed token replaces full JWT in query param', () => { |
| 74 | const SECRET = 'test-secret-key-for-phase3-tests'; |
| 75 | const UID = 'google:123456'; |
| 76 | |
| 77 | function signImageProxyToken(secret, uid) { |
| 78 | const TTL = 300; |
| 79 | const exp = Math.floor(Date.now() / 1000) + TTL; |
| 80 | const payload = `img\0${uid}\0${exp}`; |
| 81 | const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url'); |
| 82 | return `${exp}.${Buffer.from(uid).toString('base64url')}.${sig}`; |
| 83 | } |
| 84 | |
| 85 | function verifyImageProxyToken(secret, token) { |
| 86 | if (typeof token !== 'string') return null; |
| 87 | const parts = token.split('.'); |
| 88 | if (parts.length !== 3) return null; |
| 89 | const [expStr, uidB64, sig] = parts; |
| 90 | const exp = parseInt(expStr, 10); |
| 91 | if (!exp || Math.floor(Date.now() / 1000) > exp) return null; |
| 92 | let uid; |
| 93 | try { uid = Buffer.from(uidB64, 'base64url').toString(); } catch (_) { return null; } |
| 94 | if (!uid) return null; |
| 95 | const payload = `img\0${uid}\0${exp}`; |
| 96 | const expected = crypto.createHmac('sha256', secret).update(payload).digest('base64url'); |
| 97 | const sigBuf = Buffer.from(sig); |
| 98 | const expectedBuf = Buffer.from(expected); |
| 99 | if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) return null; |
| 100 | return uid; |
| 101 | } |
| 102 | |
| 103 | test('signImageProxyToken produces a 3-part dot-separated token', () => { |
| 104 | const token = signImageProxyToken(SECRET, UID); |
| 105 | const parts = token.split('.'); |
| 106 | assert.equal(parts.length, 3, 'token must have 3 parts: exp.uid_b64.sig'); |
| 107 | }); |
| 108 | |
| 109 | test('verifyImageProxyToken returns uid for a valid token', () => { |
| 110 | const token = signImageProxyToken(SECRET, UID); |
| 111 | const result = verifyImageProxyToken(SECRET, token); |
| 112 | assert.equal(result, UID); |
| 113 | }); |
| 114 | |
| 115 | test('verifyImageProxyToken rejects tampered signature', () => { |
| 116 | const token = signImageProxyToken(SECRET, UID); |
| 117 | const tampered = token.slice(0, -4) + 'XXXX'; |
| 118 | assert.equal(verifyImageProxyToken(SECRET, tampered), null); |
| 119 | }); |
| 120 | |
| 121 | test('verifyImageProxyToken rejects wrong secret', () => { |
| 122 | const token = signImageProxyToken(SECRET, UID); |
| 123 | assert.equal(verifyImageProxyToken('wrong-secret', token), null); |
| 124 | }); |
| 125 | |
| 126 | test('verifyImageProxyToken rejects expired token', () => { |
| 127 | const exp = Math.floor(Date.now() / 1000) - 10; |
| 128 | const payload = `img\0${UID}\0${exp}`; |
| 129 | const sig = crypto.createHmac('sha256', SECRET).update(payload).digest('base64url'); |
| 130 | const token = `${exp}.${Buffer.from(UID).toString('base64url')}.${sig}`; |
| 131 | assert.equal(verifyImageProxyToken(SECRET, token), null); |
| 132 | }); |
| 133 | |
| 134 | test('verifyImageProxyToken rejects invalid format', () => { |
| 135 | assert.equal(verifyImageProxyToken(SECRET, ''), null); |
| 136 | assert.equal(verifyImageProxyToken(SECRET, 'not.a.valid.token'), null); |
| 137 | assert.equal(verifyImageProxyToken(SECRET, null), null); |
| 138 | assert.equal(verifyImageProxyToken(SECRET, undefined), null); |
| 139 | }); |
| 140 | |
| 141 | test('gateway server has image-proxy-token signing endpoint', () => { |
| 142 | const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8'); |
| 143 | assert.ok(src.includes("'/api/v1/vault/image-proxy-token'"), 'gateway must expose image-proxy-token endpoint'); |
| 144 | assert.ok(src.includes('signImageProxyToken'), 'gateway must use signImageProxyToken'); |
| 145 | }); |
| 146 | |
| 147 | test('self-hosted server has image-proxy-token signing endpoint', () => { |
| 148 | const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8'); |
| 149 | assert.ok(src.includes("'/api/v1/vault/image-proxy-token'"), 'self-hosted must expose image-proxy-token endpoint'); |
| 150 | assert.ok(src.includes('signImageProxyToken'), 'self-hosted must use signImageProxyToken'); |
| 151 | }); |
| 152 | |
| 153 | test('gateway image proxy uses verifyImageProxyToken for query token auth', () => { |
| 154 | const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8'); |
| 155 | assert.ok(src.includes('verifyImageProxyToken'), 'gateway image proxy must use verifyImageProxyToken'); |
| 156 | }); |
| 157 | |
| 158 | test('gateway image proxy has backward-compat JWT fallback for ?token=', () => { |
| 159 | const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8'); |
| 160 | assert.ok(src.includes('Backward compat'), 'gateway must include JWT fallback for pre-signed-token hub.js'); |
| 161 | }); |
| 162 | |
| 163 | test('self-hosted image proxy has backward-compat JWT fallback for ?token=', () => { |
| 164 | const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8'); |
| 165 | assert.ok(src.includes('Backward compat'), 'self-hosted must include JWT fallback for pre-signed-token hub.js'); |
| 166 | }); |
| 167 | }); |
| 168 | |
| 169 | // --------------------------------------------------------------------------- |
| 170 | // 3.3 Bridge write routes: requireBridgeEditorOrAdmin on mutations |
| 171 | // --------------------------------------------------------------------------- |
| 172 | describe('3.3 Bridge write routes guarded by requireBridgeEditorOrAdmin', () => { |
| 173 | let bridgeSrc; |
| 174 | const load = () => { |
| 175 | if (!bridgeSrc) bridgeSrc = fs.readFileSync(path.join(ROOT, 'hub/bridge/server.mjs'), 'utf8'); |
| 176 | return bridgeSrc; |
| 177 | }; |
| 178 | |
| 179 | test('POST /api/v1/vault/sync has requireBridgeEditorOrAdmin', () => { |
| 180 | const src = load(); |
| 181 | const syncLine = src.split('\n').find((l) => l.includes("'/api/v1/vault/sync'") && l.includes('app.post')); |
| 182 | assert.ok(syncLine, 'sync route must exist'); |
| 183 | assert.ok(syncLine.includes('requireBridgeEditorOrAdmin'), '/vault/sync must require editor or admin'); |
| 184 | }); |
| 185 | |
| 186 | test('POST /api/v1/index has requireBridgeEditorOrAdmin', () => { |
| 187 | const src = load(); |
| 188 | const indexLine = src.split('\n').find((l) => l.includes("'/api/v1/index'") && l.includes('app.post')); |
| 189 | assert.ok(indexLine, 'index route must exist'); |
| 190 | assert.ok(indexLine.includes('requireBridgeEditorOrAdmin'), '/index must require editor or admin'); |
| 191 | }); |
| 192 | |
| 193 | test('POST /api/v1/index removes stale rows so search cannot return paths no longer in the export', () => { |
| 194 | // Contract intent: after `feat/bridge-embed-hash-cache`, the bridge does incremental |
| 195 | // indexing rather than blind delete-then-upsert. Same semantic guarantee, two paths: |
| 196 | // - empty vault → store.deleteByVaultId(vaultId) clears every row for the vault; |
| 197 | // - non-empty vault → store.deleteByChunkIds(orphanIds) removes chunk_ids in the |
| 198 | // store that are absent from the current export (deleted notes / renamed paths). |
| 199 | // Both calls must remain in the source so a future refactor cannot silently regress |
| 200 | // the security property that prompted this test. |
| 201 | const src = load(); |
| 202 | assert.ok( |
| 203 | src.includes('store.deleteByVaultId(vaultId)'), |
| 204 | 'bridge index must call store.deleteByVaultId(vaultId) on the empty / first-run path', |
| 205 | ); |
| 206 | assert.ok( |
| 207 | src.includes('store.deleteByChunkIds(partitioned.orphanIds)'), |
| 208 | 'bridge index must call store.deleteByChunkIds(partitioned.orphanIds) for incremental orphan cleanup', |
| 209 | ); |
| 210 | assert.ok( |
| 211 | src.includes('search cannot return paths') || |
| 212 | src.includes('search cannot return paths no longer in the export'), |
| 213 | 'bridge index must keep the comment explaining the search-orphan invariant', |
| 214 | ); |
| 215 | }); |
| 216 | |
| 217 | test('POST /api/v1/index JSON includes vectors_deleted for operators', () => { |
| 218 | const src = load(); |
| 219 | assert.ok( |
| 220 | src.includes('vectors_deleted') && src.includes('chunksIndexed') && src.includes('notesProcessed'), |
| 221 | 'bridge index response must expose vectors_deleted alongside notesProcessed/chunksIndexed', |
| 222 | ); |
| 223 | }); |
| 224 | |
| 225 | test('GET /api/v1/bridge-version exists for deploy verification', () => { |
| 226 | const src = load(); |
| 227 | assert.ok( |
| 228 | src.includes("app.get('/api/v1/bridge-version'") && src.includes('COMMIT_REF'), |
| 229 | 'bridge must expose unauthenticated GET /api/v1/bridge-version with commit metadata', |
| 230 | ); |
| 231 | }); |
| 232 | |
| 233 | test('POST /api/v1/memory/store has requireBridgeEditorOrAdmin', () => { |
| 234 | const src = load(); |
| 235 | const storeLine = src.split('\n').find((l) => l.includes("'/api/v1/memory/store'") && l.includes('app.post')); |
| 236 | assert.ok(storeLine, 'memory/store route must exist'); |
| 237 | assert.ok(storeLine.includes('requireBridgeEditorOrAdmin'), '/memory/store must require editor or admin'); |
| 238 | }); |
| 239 | |
| 240 | test('DELETE /api/v1/memory/clear has requireBridgeEditorOrAdmin', () => { |
| 241 | const src = load(); |
| 242 | const clearLine = src.split('\n').find((l) => l.includes("'/api/v1/memory/clear'") && l.includes('app.delete')); |
| 243 | assert.ok(clearLine, 'memory/clear route must exist'); |
| 244 | assert.ok(clearLine.includes('requireBridgeEditorOrAdmin'), '/memory/clear must require editor or admin'); |
| 245 | }); |
| 246 | |
| 247 | test('POST /api/v1/memory/consolidate has requireBridgeEditorOrAdmin', () => { |
| 248 | const src = load(); |
| 249 | const consolLine = src.split('\n').find((l) => l.includes("'/api/v1/memory/consolidate'") && l.includes('app.post')); |
| 250 | assert.ok(consolLine, 'memory/consolidate route must exist'); |
| 251 | assert.ok(consolLine.includes('requireBridgeEditorOrAdmin'), '/memory/consolidate must require editor or admin'); |
| 252 | }); |
| 253 | |
| 254 | test('requireBridgeEditorOrAdmin blocks viewer role', () => { |
| 255 | const src = load(); |
| 256 | const fnBlock = src.slice(src.indexOf('async function requireBridgeEditorOrAdmin')); |
| 257 | assert.ok(fnBlock.includes("role === 'viewer'"), 'middleware must check for viewer role'); |
| 258 | assert.ok(fnBlock.includes('403'), 'middleware must return 403 for viewers'); |
| 259 | }); |
| 260 | }); |
| 261 | |
| 262 | // --------------------------------------------------------------------------- |
| 263 | // 3.4 MCP in-memory refresh token store: periodic sweep |
| 264 | // --------------------------------------------------------------------------- |
| 265 | describe('3.4 MCP refresh token store — periodic expired-token sweep', () => { |
| 266 | let mcpSrc; |
| 267 | const load = () => { |
| 268 | if (!mcpSrc) mcpSrc = fs.readFileSync(path.join(ROOT, 'hub/gateway/mcp-oauth-provider.mjs'), 'utf8'); |
| 269 | return mcpSrc; |
| 270 | }; |
| 271 | |
| 272 | test('KnowtationOAuthProvider has _sweepExpiredRefreshTokens method', () => { |
| 273 | const src = load(); |
| 274 | assert.ok(src.includes('_sweepExpiredRefreshTokens'), 'must have sweep method'); |
| 275 | }); |
| 276 | |
| 277 | test('constructor sets up periodic sweep timer', () => { |
| 278 | const src = load(); |
| 279 | assert.ok(src.includes('setInterval'), 'constructor must create setInterval for sweep'); |
| 280 | assert.ok(src.includes('REFRESH_SWEEP_INTERVAL_MS'), 'must use configured interval constant'); |
| 281 | }); |
| 282 | |
| 283 | test('sweep timer is unref()d to not block Node process exit', () => { |
| 284 | const src = load(); |
| 285 | assert.ok(src.includes('.unref'), 'sweep timer must call unref() to not block exit'); |
| 286 | }); |
| 287 | |
| 288 | test('sweep method deletes expired refresh tokens', () => { |
| 289 | const src = load(); |
| 290 | const sweepBlock = src.slice(src.indexOf('_sweepExpiredRefreshTokens')); |
| 291 | assert.ok(sweepBlock.includes('_refreshTokens.delete'), 'sweep must delete expired tokens'); |
| 292 | assert.ok(sweepBlock.includes('expires'), 'sweep must check expiry'); |
| 293 | }); |
| 294 | |
| 295 | test('destroy() method clears the sweep timer', () => { |
| 296 | const src = load(); |
| 297 | assert.ok(src.includes('destroy()'), 'must have destroy method'); |
| 298 | assert.ok(src.includes('clearInterval'), 'destroy must clear the interval'); |
| 299 | }); |
| 300 | |
| 301 | test('sweep interval is reasonable (5–30 minutes)', () => { |
| 302 | const src = load(); |
| 303 | const match = src.match(/REFRESH_SWEEP_INTERVAL_MS\s*=\s*([^;]+)/); |
| 304 | assert.ok(match, 'REFRESH_SWEEP_INTERVAL_MS must be defined'); |
| 305 | const ms = Function(`return ${match[1].trim()}`)(); |
| 306 | assert.ok(ms >= 5 * 60 * 1000 && ms <= 30 * 60 * 1000, |
| 307 | `sweep interval must be 5–30 min, got ${ms / 60000} min`); |
| 308 | }); |
| 309 | }); |
| 310 | |
| 311 | // --------------------------------------------------------------------------- |
| 312 | // 3.4b MCP OAuth: SDK express-rate-limit behind Nginx (proxy validate relaxations) |
| 313 | // --------------------------------------------------------------------------- |
| 314 | describe('3.4b MCP OAuth: SDK rate limit behind Nginx', () => { |
| 315 | test('gateway disables express-rate-limit validations for mcpAuthRouter (keep limiters)', () => { |
| 316 | const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8'); |
| 317 | assert.ok(src.includes('app.set(\'trust proxy\', 1)'), 'gateway must set trust proxy for X-Forwarded-For'); |
| 318 | const block = src.slice(src.indexOf('app._mcpOAuthProvider = oauthProvider'), src.indexOf('[gateway] MCP OAuth 2.1 endpoints mounted')); |
| 319 | assert.ok( |
| 320 | block.includes('rateLimit: { validate: false }'), |
| 321 | 'must set rateLimit.validate false so ERR_ERL_* does not break /token behind Nginx', |
| 322 | ); |
| 323 | assert.match(block, /authorizationOptions:\s*mcpOAuthSdkRateLimitOpts/); |
| 324 | assert.match(block, /tokenOptions:\s*mcpOAuthSdkRateLimitOpts/); |
| 325 | }); |
| 326 | }); |
| 327 | |
| 328 | // --------------------------------------------------------------------------- |
| 329 | // 3.5 CORS on canister: locked origin when gateway_auth_secret is set |
| 330 | // --------------------------------------------------------------------------- |
| 331 | describe('3.5 Canister CORS locked to gateway origin when auth secret set', () => { |
| 332 | let mainMo; |
| 333 | let migrationMo; |
| 334 | const loadMain = () => { |
| 335 | if (!mainMo) mainMo = fs.readFileSync(path.join(ROOT, 'hub/icp/src/hub/main.mo'), 'utf8'); |
| 336 | return mainMo; |
| 337 | }; |
| 338 | const loadMigration = () => { |
| 339 | if (!migrationMo) migrationMo = fs.readFileSync(path.join(ROOT, 'hub/icp/src/hub/Migration.mo'), 'utf8'); |
| 340 | return migrationMo; |
| 341 | }; |
| 342 | |
| 343 | test('corsHeaders() checks gateway_auth_secret and cors_allowed_origin', () => { |
| 344 | const src = loadMain(); |
| 345 | const corsBlock = src.slice(src.indexOf('func corsHeaders')); |
| 346 | assert.ok(corsBlock.includes('gateway_auth_secret'), 'corsHeaders must check gateway_auth_secret'); |
| 347 | assert.ok(corsBlock.includes('cors_allowed_origin'), 'corsHeaders must check cors_allowed_origin'); |
| 348 | }); |
| 349 | |
| 350 | test('corsHeaders() returns specific origin when both secrets are set', () => { |
| 351 | const src = loadMain(); |
| 352 | const corsBlock = src.slice(src.indexOf('func corsHeaders'), src.indexOf('func corsHeaders') + 500); |
| 353 | assert.ok(corsBlock.includes('"*"'), 'must have wildcard fallback'); |
| 354 | assert.ok(corsBlock.includes('storage.cors_allowed_origin'), 'must use stored origin when locked'); |
| 355 | }); |
| 356 | |
| 357 | test('admin_set_cors_origin function exists and requires controller', () => { |
| 358 | const src = loadMain(); |
| 359 | assert.ok(src.includes('admin_set_cors_origin'), 'must have admin_set_cors_origin function'); |
| 360 | const fnBlock = src.slice(src.indexOf('admin_set_cors_origin')); |
| 361 | assert.ok(fnBlock.includes('isController'), 'must verify caller is controller'); |
| 362 | assert.ok(fnBlock.includes('FORBIDDEN'), 'must trap non-controllers'); |
| 363 | }); |
| 364 | |
| 365 | test('StableStorage type includes cors_allowed_origin field', () => { |
| 366 | const src = loadMigration(); |
| 367 | const stableBlock = src.slice(src.lastIndexOf('public type StableStorage')); |
| 368 | assert.ok(stableBlock.includes('cors_allowed_origin'), 'StableStorage must have cors_allowed_origin'); |
| 369 | }); |
| 370 | |
| 371 | test('migration preserves gateway_auth_secret and cors_allowed_origin', () => { |
| 372 | const src = loadMigration(); |
| 373 | const migBlock = src.slice(src.indexOf('public func migration')); |
| 374 | assert.ok(migBlock.includes('gateway_auth_secret = old.storage.gateway_auth_secret'), |
| 375 | 'migration must preserve existing gateway auth secret'); |
| 376 | // V7 stable already includes cors_allowed_origin; actor hook maps V7→current by preserving it. |
| 377 | assert.ok(migBlock.includes('cors_allowed_origin = old.storage.cors_allowed_origin'), |
| 378 | 'migration must preserve cors_allowed_origin from V7 storage'); |
| 379 | }); |
| 380 | |
| 381 | test('saveStable preserves cors_allowed_origin', () => { |
| 382 | const src = loadMain(); |
| 383 | const saveBlock = src.slice(src.indexOf('func saveStable')); |
| 384 | assert.ok(saveBlock.includes('keepCorsOrigin'), 'saveStable must preserve cors origin'); |
| 385 | assert.ok(saveBlock.includes('cors_allowed_origin = keepCorsOrigin'), 'saveStable must write cors origin'); |
| 386 | }); |
| 387 | }); |
| 388 | |
| 389 | // --------------------------------------------------------------------------- |
| 390 | // 3.6 path-to-regexp ReDoS CVE resolved |
| 391 | // --------------------------------------------------------------------------- |
| 392 | // --------------------------------------------------------------------------- |
| 393 | // Bridge → canister X-Gateway-Auth header (Phase 0 compatibility fix) |
| 394 | // --------------------------------------------------------------------------- |
| 395 | describe('Bridge canister calls include X-Gateway-Auth header', () => { |
| 396 | let bridgeSrc; |
| 397 | const load = () => { |
| 398 | if (!bridgeSrc) bridgeSrc = fs.readFileSync(path.join(ROOT, 'hub/bridge/server.mjs'), 'utf8'); |
| 399 | return bridgeSrc; |
| 400 | }; |
| 401 | |
| 402 | test('bridge reads CANISTER_AUTH_SECRET from env (same var name as gateway)', () => { |
| 403 | const src = load(); |
| 404 | assert.ok(src.includes('CANISTER_AUTH_SECRET'), 'bridge must read CANISTER_AUTH_SECRET env var'); |
| 405 | }); |
| 406 | |
| 407 | test('bridge has canisterHeaders() helper that injects x-gateway-auth', () => { |
| 408 | const src = load(); |
| 409 | assert.ok(src.includes('function canisterHeaders'), 'bridge must define canisterHeaders helper'); |
| 410 | assert.ok(src.includes("'x-gateway-auth'"), 'canisterHeaders must set x-gateway-auth header'); |
| 411 | }); |
| 412 | |
| 413 | test('canisterHeaders() is used at every canister fetch call site', () => { |
| 414 | const src = load(); |
| 415 | // Count how many times we call fetch on the canister URL (CANISTER_URL or ${base}/api) |
| 416 | const fetchCanisterCount = (src.match(/fetch\(CANISTER_URL|fetch\(`\$\{CANISTER_URL\}|fetch\(`\$\{base\}/g) || []).length; |
| 417 | // Count how many times canisterHeaders appears near those calls |
| 418 | const canisterHeadersCount = (src.match(/canisterHeaders\(/g) || []).length; |
| 419 | assert.ok( |
| 420 | canisterHeadersCount >= fetchCanisterCount, |
| 421 | `canisterHeaders() must appear at least as many times as canister fetch calls (fetches: ${fetchCanisterCount}, canisterHeaders: ${canisterHeadersCount})`, |
| 422 | ); |
| 423 | }); |
| 424 | }); |
| 425 | |
| 426 | describe('3.6 path-to-regexp ReDoS CVE resolved', () => { |
| 427 | test('hub/package-lock.json has path-to-regexp >= 0.1.13', () => { |
| 428 | const lockPath = path.join(ROOT, 'hub/package-lock.json'); |
| 429 | if (!fs.existsSync(lockPath)) return; |
| 430 | const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); |
| 431 | const packages = lock.packages || {}; |
| 432 | for (const [pkg, info] of Object.entries(packages)) { |
| 433 | if (pkg.endsWith('/path-to-regexp') || pkg === 'path-to-regexp') { |
| 434 | const ver = info.version; |
| 435 | if (ver && ver.startsWith('0.1.')) { |
| 436 | const patch = parseInt(ver.split('.')[2], 10); |
| 437 | assert.ok(patch >= 13, `path-to-regexp must be >= 0.1.13 (found ${ver})`); |
| 438 | } |
| 439 | } |
| 440 | } |
| 441 | }); |
| 442 | |
| 443 | test('hub/gateway/package-lock.json has path-to-regexp >= 0.1.13', () => { |
| 444 | const lockPath = path.join(ROOT, 'hub/gateway/package-lock.json'); |
| 445 | if (!fs.existsSync(lockPath)) return; |
| 446 | const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); |
| 447 | const packages = lock.packages || {}; |
| 448 | for (const [pkg, info] of Object.entries(packages)) { |
| 449 | if (pkg.endsWith('/path-to-regexp') || pkg === 'path-to-regexp') { |
| 450 | const ver = info.version; |
| 451 | if (ver && ver.startsWith('0.1.')) { |
| 452 | const patch = parseInt(ver.split('.')[2], 10); |
| 453 | assert.ok(patch >= 13, `path-to-regexp must be >= 0.1.13 (found ${ver})`); |
| 454 | } |
| 455 | } |
| 456 | } |
| 457 | }); |
| 458 | |
| 459 | test('root package-lock.json has path-to-regexp >= 0.1.13', () => { |
| 460 | const lockPath = path.join(ROOT, 'package-lock.json'); |
| 461 | if (!fs.existsSync(lockPath)) return; |
| 462 | const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); |
| 463 | const packages = lock.packages || {}; |
| 464 | for (const [pkg, info] of Object.entries(packages)) { |
| 465 | if (pkg.endsWith('/path-to-regexp') || pkg === 'path-to-regexp') { |
| 466 | const ver = info.version; |
| 467 | if (ver && ver.startsWith('0.1.')) { |
| 468 | const patch = parseInt(ver.split('.')[2], 10); |
| 469 | assert.ok(patch >= 13, `path-to-regexp must be >= 0.1.13 (found ${ver})`); |
| 470 | } |
| 471 | } |
| 472 | } |
| 473 | }); |
| 474 | }); |
| 475 | |
| 476 | // --------------------------------------------------------------------------- |
| 477 | // 3.7 Muse thin bridge (Option C): operator proxy route markers |
| 478 | // --------------------------------------------------------------------------- |
| 479 | describe('3.7 Muse thin bridge: operator proxy present on gateway and Node Hub', () => { |
| 480 | test('gateway registers GET /api/v1/operator/muse/proxy with requireAdmin', () => { |
| 481 | const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8'); |
| 482 | assert.ok( |
| 483 | src.includes('/api/v1/operator/muse/proxy'), |
| 484 | 'gateway must expose operator Muse proxy path', |
| 485 | ); |
| 486 | assert.ok( |
| 487 | src.includes('fetchMuseProxiedGet') && src.includes('parseMuseConfigFromEnv'), |
| 488 | 'gateway must use muse-thin-bridge helpers for proxy', |
| 489 | ); |
| 490 | }); |
| 491 | |
| 492 | test('self-hosted Hub registers GET /api/v1/operator/muse/proxy with jwtAuth and admin role', () => { |
| 493 | const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8'); |
| 494 | assert.ok( |
| 495 | src.includes('/api/v1/operator/muse/proxy'), |
| 496 | 'Node Hub must expose operator Muse proxy path', |
| 497 | ); |
| 498 | assert.ok( |
| 499 | src.includes('fetchMuseProxiedGet') && src.includes("requireRole('admin')"), |
| 500 | 'Node Hub must gate Muse proxy with admin role', |
| 501 | ); |
| 502 | }); |
| 503 | |
| 504 | test('Node Hub exposes POST /api/v1/settings/muse for self-hosted YAML Muse URL', () => { |
| 505 | const src = fs.readFileSync(path.join(ROOT, 'hub/server.mjs'), 'utf8'); |
| 506 | assert.ok( |
| 507 | src.includes("'/api/v1/settings/muse'"), |
| 508 | 'Node Hub must allow admins to persist muse.url in config/local.yaml', |
| 509 | ); |
| 510 | }); |
| 511 | |
| 512 | test('gateway rejects POST /api/v1/settings/muse (hosted operator-only)', () => { |
| 513 | const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8'); |
| 514 | assert.ok( |
| 515 | src.includes("'/api/v1/settings/muse'") && src.includes('501'), |
| 516 | 'gateway must not allow browser clients to set Muse URL', |
| 517 | ); |
| 518 | }); |
| 519 | }); |
File History
2 commits
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2
feat(auth): persistent login system + C7 session introspection
Human
minor
⚠
16 days ago
sha256:6a102aafafdfe7e70a24f4e59740200f0ee713ce7915f1b53e9d4ba5ee8b4410
Initial Muse snapshot
Human
48 days ago