sec-kn-1-gateway-auth-fail-closed.test.mjs
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
10 days ago
| 1 | /** |
| 2 | * SEC-KN-1 — seven-tier coverage for fail-closed `gatewayAuthorized`. |
| 3 | * |
| 4 | * Frozen requirement: Pass 2 P1 |
| 5 | * (`~/scooling/docs/PRE-BUILD-SECURITY-AUDIT-FINDINGS-PASS2.md`) — |
| 6 | * empty `gateway_auth_secret` must DENY, not allow. |
| 7 | * |
| 8 | * Tiers: unit · integration · e2e · stress · data-integrity · performance · security |
| 9 | */ |
| 10 | |
| 11 | import { test, describe } from 'node:test'; |
| 12 | import assert from 'node:assert/strict'; |
| 13 | import fs from 'node:fs'; |
| 14 | import path from 'node:path'; |
| 15 | import { performance } from 'node:perf_hooks'; |
| 16 | import { fileURLToPath } from 'node:url'; |
| 17 | import { |
| 18 | gatewayAuthorized, |
| 19 | httpRequestRequiresGatewayAuth, |
| 20 | healthPayload, |
| 21 | } from '../lib/gateway-authorized.mjs'; |
| 22 | |
| 23 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 24 | const ROOT = path.resolve(__dirname, '..'); |
| 25 | const MAIN_MO = path.join(ROOT, 'hub/icp/src/hub/main.mo'); |
| 26 | |
| 27 | function readMainMo() { |
| 28 | return fs.readFileSync(MAIN_MO, 'utf8'); |
| 29 | } |
| 30 | |
| 31 | function extractGatewayAuthorizedBlock(src) { |
| 32 | const start = src.indexOf('func gatewayAuthorized(req : HttpRequest) : Bool {'); |
| 33 | assert.ok(start >= 0, 'gatewayAuthorized must exist in main.mo'); |
| 34 | const end = src.indexOf('\n};', start); |
| 35 | assert.ok(end > start, 'gatewayAuthorized block must close'); |
| 36 | return src.slice(start, end + 3); |
| 37 | } |
| 38 | |
| 39 | /** Pre-fix fail-open behavior — security tier must prove this is wrong. */ |
| 40 | function gatewayAuthorizedFailOpenLegacy(gatewayAuthSecret, headerValue) { |
| 41 | if (!gatewayAuthSecret) return true; |
| 42 | if (headerValue === undefined || headerValue === null) return false; |
| 43 | if (headerValue.length !== gatewayAuthSecret.length) return false; |
| 44 | return headerValue === gatewayAuthSecret; |
| 45 | } |
| 46 | |
| 47 | // --------------------------------------------------------------------------- |
| 48 | // Tier 1 — unit |
| 49 | // --------------------------------------------------------------------------- |
| 50 | describe('SEC-KN-1 unit — gatewayAuthorized fail-closed', () => { |
| 51 | test('empty secret denies missing, wrong, and empty headers', () => { |
| 52 | assert.equal(gatewayAuthorized('', undefined), false); |
| 53 | assert.equal(gatewayAuthorized('', null), false); |
| 54 | assert.equal(gatewayAuthorized('', ''), false); |
| 55 | assert.equal(gatewayAuthorized('', 'forged'), false); |
| 56 | }); |
| 57 | |
| 58 | test('configured secret accepts only exact match', () => { |
| 59 | assert.equal(gatewayAuthorized('sec', 'sec'), true); |
| 60 | assert.equal(gatewayAuthorized('sec', 'se'), false); |
| 61 | assert.equal(gatewayAuthorized('sec', 'secc'), false); |
| 62 | assert.equal(gatewayAuthorized('sec', undefined), false); |
| 63 | }); |
| 64 | |
| 65 | test('healthPayload stays ok:true and reports configured flag loudly', () => { |
| 66 | assert.deepEqual(healthPayload(''), { |
| 67 | ok: true, |
| 68 | gateway_auth_configured: false, |
| 69 | }); |
| 70 | assert.deepEqual(healthPayload('set'), { |
| 71 | ok: true, |
| 72 | gateway_auth_configured: true, |
| 73 | }); |
| 74 | }); |
| 75 | |
| 76 | test('health and OPTIONS do not require gateway auth; vaults GET does', () => { |
| 77 | assert.equal(httpRequestRequiresGatewayAuth('GET', 'health'), false); |
| 78 | assert.equal(httpRequestRequiresGatewayAuth('OPTIONS', 'vaults'), false); |
| 79 | assert.equal(httpRequestRequiresGatewayAuth('GET', 'vaults'), true); |
| 80 | assert.equal(httpRequestRequiresGatewayAuth('POST', 'notes'), true); |
| 81 | }); |
| 82 | }); |
| 83 | |
| 84 | // --------------------------------------------------------------------------- |
| 85 | // Tier 2 — integration (Motoko source + JS mirror + request routing) |
| 86 | // --------------------------------------------------------------------------- |
| 87 | describe('SEC-KN-1 integration — Motoko + routing contract', () => { |
| 88 | test('Motoko empty-secret branch returns false (not true)', () => { |
| 89 | const block = extractGatewayAuthorizedBlock(readMainMo()); |
| 90 | assert.ok( |
| 91 | block.includes('if (Text.size(expected) == 0) { return false }'), |
| 92 | 'empty secret must return false' |
| 93 | ); |
| 94 | assert.ok( |
| 95 | !block.includes('if (Text.size(expected) == 0) { return true }'), |
| 96 | 'fail-open return true must be gone' |
| 97 | ); |
| 98 | }); |
| 99 | |
| 100 | test('http_request serves health and OPTIONS before gatewayAuthorized', () => { |
| 101 | const src = readMainMo(); |
| 102 | const healthIdx = src.indexOf('if (pathKind == "health")'); |
| 103 | const optionsIdx = src.indexOf('if (req.method == "OPTIONS")'); |
| 104 | const authIdx = src.indexOf('if (not gatewayAuthorized(req))'); |
| 105 | assert.ok(healthIdx > 0 && optionsIdx > healthIdx && authIdx > optionsIdx); |
| 106 | }); |
| 107 | |
| 108 | test('http_request_update also gates with gatewayAuthorized', () => { |
| 109 | const src = readMainMo(); |
| 110 | const updateStart = src.indexOf('public func http_request_update'); |
| 111 | assert.ok(updateStart > 0); |
| 112 | const slice = src.slice(updateStart, updateStart + 400); |
| 113 | assert.ok(slice.includes('if (not gatewayAuthorized(req))')); |
| 114 | }); |
| 115 | |
| 116 | test('forged X-User-Id alone never authorizes when secret empty or mismatched', () => { |
| 117 | // Identity header is independent of gatewayAuthorized — auth must fail first. |
| 118 | assert.equal(gatewayAuthorized('', 'attacker-user-id'), false); |
| 119 | assert.equal(gatewayAuthorized('real-secret', 'attacker-user-id'), false); |
| 120 | assert.equal(gatewayAuthorized('real-secret', 'real-secret'), true); |
| 121 | }); |
| 122 | }); |
| 123 | |
| 124 | // --------------------------------------------------------------------------- |
| 125 | // Tier 3 — e2e (fixture request matrix through routing + auth decision) |
| 126 | // --------------------------------------------------------------------------- |
| 127 | describe('SEC-KN-1 e2e — request matrix', () => { |
| 128 | function decide(secret, method, pathKind, gatewayHeader) { |
| 129 | if (!httpRequestRequiresGatewayAuth(method, pathKind)) { |
| 130 | return { status: 200, body: healthPayload(secret) }; |
| 131 | } |
| 132 | if (!gatewayAuthorized(secret, gatewayHeader)) { |
| 133 | return { |
| 134 | status: 403, |
| 135 | body: { |
| 136 | error: 'Gateway authentication required', |
| 137 | code: 'GATEWAY_AUTH_REQUIRED', |
| 138 | }, |
| 139 | }; |
| 140 | } |
| 141 | return { status: 200, body: { ok: true, pathKind } }; |
| 142 | } |
| 143 | |
| 144 | test('empty secret: health 200 with gateway_auth_configured false; vaults 403', () => { |
| 145 | const health = decide('', 'GET', 'health', undefined); |
| 146 | assert.equal(health.status, 200); |
| 147 | assert.equal(health.body.gateway_auth_configured, false); |
| 148 | assert.equal(health.body.ok, true); |
| 149 | |
| 150 | const vaults = decide('', 'GET', 'vaults', undefined); |
| 151 | assert.equal(vaults.status, 403); |
| 152 | assert.equal(vaults.body.code, 'GATEWAY_AUTH_REQUIRED'); |
| 153 | |
| 154 | const options = decide('', 'OPTIONS', 'vaults', undefined); |
| 155 | assert.equal(options.status, 200); |
| 156 | }); |
| 157 | |
| 158 | test('configured secret: forged user id header value as auth still 403; correct auth 200', () => { |
| 159 | const forged = decide('prod-secret', 'GET', 'vaults', 'google:evil'); |
| 160 | assert.equal(forged.status, 403); |
| 161 | const ok = decide('prod-secret', 'GET', 'vaults', 'prod-secret'); |
| 162 | assert.equal(ok.status, 200); |
| 163 | }); |
| 164 | }); |
| 165 | |
| 166 | // --------------------------------------------------------------------------- |
| 167 | // Tier 4 — stress |
| 168 | // --------------------------------------------------------------------------- |
| 169 | describe('SEC-KN-1 stress — many empty-secret and forged decisions', () => { |
| 170 | test('10_000 empty-secret denials stay false with no throw', () => { |
| 171 | for (let i = 0; i < 10_000; i++) { |
| 172 | assert.equal(gatewayAuthorized('', i % 2 === 0 ? undefined : `forge-${i}`), false); |
| 173 | } |
| 174 | }); |
| 175 | |
| 176 | test('10_000 alternating correct/wrong secrets', () => { |
| 177 | const secret = 's'.repeat(64); |
| 178 | for (let i = 0; i < 10_000; i++) { |
| 179 | const header = i % 2 === 0 ? secret : secret.slice(0, -1) + 'x'; |
| 180 | assert.equal(gatewayAuthorized(secret, header), i % 2 === 0); |
| 181 | } |
| 182 | }); |
| 183 | }); |
| 184 | |
| 185 | // --------------------------------------------------------------------------- |
| 186 | // Tier 5 — data-integrity |
| 187 | // --------------------------------------------------------------------------- |
| 188 | describe('SEC-KN-1 data-integrity — idempotent decisions + Motoko health loud field', () => { |
| 189 | test('same inputs always yield same allow/deny', () => { |
| 190 | const cases = [ |
| 191 | ['', undefined], |
| 192 | ['', 'x'], |
| 193 | ['abc', 'abc'], |
| 194 | ['abc', 'abd'], |
| 195 | ]; |
| 196 | for (const [s, h] of cases) { |
| 197 | const a = gatewayAuthorized(s, h); |
| 198 | const b = gatewayAuthorized(s, h); |
| 199 | assert.equal(a, b); |
| 200 | } |
| 201 | }); |
| 202 | |
| 203 | test('Motoko health JSON includes gateway_auth_configured true/false branches', () => { |
| 204 | const src = readMainMo(); |
| 205 | assert.ok(src.includes('gateway_auth_configured')); |
| 206 | assert.ok(src.includes('\\"gateway_auth_configured\\":true}')); |
| 207 | assert.ok(src.includes('\\"gateway_auth_configured\\":false}')); |
| 208 | }); |
| 209 | }); |
| 210 | |
| 211 | // --------------------------------------------------------------------------- |
| 212 | // Tier 6 — performance |
| 213 | // --------------------------------------------------------------------------- |
| 214 | describe('SEC-KN-1 performance — bounded auth decision time', () => { |
| 215 | test('100k decisions complete under 500ms', () => { |
| 216 | const secret = 'perf-secret-value-32-chars!!!!'; |
| 217 | const t0 = performance.now(); |
| 218 | for (let i = 0; i < 100_000; i++) { |
| 219 | gatewayAuthorized(secret, i % 3 === 0 ? secret : 'wrong'); |
| 220 | gatewayAuthorized('', undefined); |
| 221 | } |
| 222 | const ms = performance.now() - t0; |
| 223 | assert.ok(ms < 500, `expected <500ms, got ${ms.toFixed(1)}ms`); |
| 224 | }); |
| 225 | }); |
| 226 | |
| 227 | // --------------------------------------------------------------------------- |
| 228 | // Tier 7 — security (regression must FAIL against pre-fix fail-open) |
| 229 | // --------------------------------------------------------------------------- |
| 230 | describe('SEC-KN-1 security — regression vs fail-open', () => { |
| 231 | test('security regression: empty secret must DENY (legacy fail-open would ALLOW)', () => { |
| 232 | // Against pre-fix code this assertion fails — that is the point of the tier. |
| 233 | assert.equal( |
| 234 | gatewayAuthorizedFailOpenLegacy('', undefined), |
| 235 | true, |
| 236 | 'sanity: legacy helper still models fail-open' |
| 237 | ); |
| 238 | assert.equal( |
| 239 | gatewayAuthorized('', undefined), |
| 240 | false, |
| 241 | 'current contract must deny empty secret' |
| 242 | ); |
| 243 | assert.notEqual( |
| 244 | gatewayAuthorized('', undefined), |
| 245 | gatewayAuthorizedFailOpenLegacy('', undefined), |
| 246 | 'fixed behavior must diverge from fail-open on empty secret' |
| 247 | ); |
| 248 | }); |
| 249 | |
| 250 | test('Motoko source must not contain fail-open empty-secret allow', () => { |
| 251 | const block = extractGatewayAuthorizedBlock(readMainMo()); |
| 252 | assert.match(block, /Text\.size\(expected\) == 0\) \{ return false \}/); |
| 253 | assert.doesNotMatch(block, /Text\.size\(expected\) == 0\) \{ return true \}/); |
| 254 | }); |
| 255 | |
| 256 | test('forged X-User-Id without valid X-Gateway-Auth is denied when secret set', () => { |
| 257 | // Canister trusts X-User-Id only AFTER gatewayAuthorized — missing auth → 403. |
| 258 | assert.equal(gatewayAuthorized('canister-secret', undefined), false); |
| 259 | assert.equal(gatewayAuthorized('canister-secret', 'google:attacker'), false); |
| 260 | }); |
| 261 | |
| 262 | test('canisterAuthHeaders empty secret produces no header (caller would be denied)', async () => { |
| 263 | const { canisterAuthHeaders } = await import('../hub/gateway/canister-auth-headers.mjs'); |
| 264 | const saved = process.env.CANISTER_AUTH_SECRET; |
| 265 | try { |
| 266 | process.env.CANISTER_AUTH_SECRET = ''; |
| 267 | const headers = canisterAuthHeaders(); |
| 268 | assert.deepEqual(headers, {}); |
| 269 | assert.equal(gatewayAuthorized('', headers['x-gateway-auth']), false); |
| 270 | } finally { |
| 271 | if (saved === undefined) delete process.env.CANISTER_AUTH_SECRET; |
| 272 | else process.env.CANISTER_AUTH_SECRET = saved; |
| 273 | } |
| 274 | }); |
| 275 | }); |
File History
1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
10 days ago