sec-kn-1-gateway-auth-fail-closed.test.mjs
275 lines 11.0 KB
Raw
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