proposal-approve-rbac-fix-security.test.mjs
184 lines 10.3 KB
Raw
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 43 days ago
1 /**
2 * Security tests — proposal approve RBAC fix.
3 *
4 * Verifies that the RBAC fix does not introduce privilege escalation, secret leakage,
5 * or bypass opportunities. These are build-blocking tests.
6 *
7 * Threat model:
8 * S1. Attacker forges a JWT with role='admin' using a different secret → jwt.verify throws.
9 * S2. Attacker passes a sub that matches HUB_ADMIN_USER_IDS but JWT is invalid → override
10 * should not fire when JWT verification fails (getUserId returns null → no sub).
11 * S3. Error messages in showToast must not leak SESSION_SECRET, JWT payloads, or internal paths.
12 * S4. Bridge 401 fallback must only use the GATEWAY's SESSION_SECRET to verify — not skip verify.
13 * S5. Gateway admin override only applies to the exact sub from a verified JWT, not from query params.
14 * S6. The canApprove check in assertHostedProposalApproveDiscard must be the last gate before proxying.
15 * S7. No plaintext role claim (from an unverified source) can bypass the JWT verify step.
16 */
17
18 import { test, describe } from 'node:test';
19 import assert from 'node:assert/strict';
20 import fs from 'node:fs';
21 import path from 'node:path';
22 import { fileURLToPath } from 'node:url';
23 import jwt from 'jsonwebtoken';
24
25 const __dirname = path.dirname(fileURLToPath(import.meta.url));
26 const ROOT = path.resolve(__dirname, '..');
27
28 const REAL_SECRET = 'legit-secret-for-tests';
29 const ATTACKER_SECRET = 'attacker-secret-cannot-forge';
30
31 describe('Security: JWT forgery resistance', () => {
32 test('S1: forged JWT with wrong secret is rejected by jwt.verify', () => {
33 const forgedToken = jwt.sign({ sub: 'google:fake-admin', role: 'admin' }, ATTACKER_SECRET, { expiresIn: '1h' });
34 assert.throws(
35 () => jwt.verify(forgedToken, REAL_SECRET),
36 /invalid signature|JsonWebTokenError/,
37 'Forged JWT rejected by verify with correct secret'
38 );
39 });
40
41 test('S2: sub extraction from forged JWT fails before admin override can fire', () => {
42 // getUserId uses jwt.verify internally; a forged token means sub is null
43 const forgedToken = jwt.sign({ sub: 'google:admin-id', role: 'admin' }, ATTACKER_SECRET);
44 let sub = null;
45 try {
46 const payload = jwt.verify(forgedToken, REAL_SECRET);
47 sub = payload.sub ?? null;
48 } catch (_) {}
49 assert.equal(sub, null, 'Sub is null when JWT cannot be verified → admin override cannot fire');
50 });
51
52 test('S3: showToast error message in hub.js does not reference SESSION_SECRET, jwt, or internal paths', () => {
53 const src = fs.readFileSync(path.join(ROOT, 'web/hub/hub.js'), 'utf8');
54 const fnStart = src.indexOf('async function approveProposal');
55 const fn = src.slice(fnStart, src.indexOf('\n async function discardProposal', fnStart));
56 const catchBlock = fn.slice(fn.indexOf('} catch (e)'));
57 // The toast message is built from e.message — verify the format is safe
58 assert.ok(!catchBlock.includes('SESSION_SECRET'), 'SESSION_SECRET not in toast message template');
59 assert.ok(!catchBlock.includes('jwt.sign'), 'jwt.sign not referenced in toast template');
60 assert.ok(!catchBlock.includes('SECRET'), 'SECRET keyword not in approve catch block');
61 // Message should contain the user-facing error from the API (e.message)
62 assert.ok(catchBlock.includes('e.message'), 'toast includes API error message for user');
63 });
64
65 test('S4: bridge fallback uses once-verified bearerPayload (not JSON.parse on raw header)', () => {
66 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
67 const fnStart = src.indexOf('async function resolveHostedActorRole');
68 const fn = src.slice(fnStart, src.indexOf('\n}\n', fnStart) + 3);
69 // SEC-KN-3: verify once at entry; fallback must consume bearerPayload, never raw-parse.
70 assert.ok(fn.includes('jwt.verify(token, SESSION_SECRET)'), 'Entry path verifies JWT with SESSION_SECRET');
71 const fallbackBlock = fn.slice(fn.indexOf('!bridgeResolved'));
72 assert.ok(
73 fallbackBlock.includes('roleFromVerifiedAccessPayload(bearerPayload'),
74 'Bridge fallback uses verified bearerPayload (not a second unverified parse)',
75 );
76 assert.ok(!fallbackBlock.includes('JSON.parse'), 'Bridge fallback does not JSON.parse the raw header');
77 assert.ok(!fallbackBlock.includes('jwt.verify'), 'Fallback does not re-verify; it reuses bearerPayload');
78 });
79
80 test('S5: gateway admin override uses getUserId (verified sub), not query params', () => {
81 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
82 const fnStart = src.indexOf('async function resolveHostedActorRole');
83 const fn = src.slice(fnStart, src.indexOf('\n}\n', fnStart) + 3);
84 const overrideBlock = fn.slice(fn.indexOf('Gateway-level admin override'));
85 assert.ok(overrideBlock.includes('getUserId(req)'), 'Override uses getUserId (JWT-verified sub)');
86 assert.ok(!overrideBlock.includes('req.query'), 'Override does not use query parameters as sub');
87 assert.ok(!overrideBlock.includes('req.body'), 'Override does not use request body as sub');
88 });
89
90 test('S6: canApprove is the final gate immediately before 403 response', () => {
91 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
92 const fnStart = src.indexOf('async function assertHostedProposalApproveDiscard');
93 const fn = src.slice(fnStart, src.indexOf('\n}\n', fnStart) + 3);
94 const canApproveIdx = fn.indexOf('const canApprove');
95 assert.ok(canApproveIdx > 0, 'canApprove is computed');
96 // The 403 for approve specifically appears AFTER canApprove is defined.
97 // (There is also a 403 for discard before canApprove — find the approve-specific one.)
98 const approveForbiddenIdx = fn.indexOf("'FORBIDDEN'", canApproveIdx);
99 assert.ok(approveForbiddenIdx > canApproveIdx, '403 FORBIDDEN for approve follows canApprove check');
100 const returnFalseIdx = fn.indexOf('return false', approveForbiddenIdx);
101 assert.ok(returnFalseIdx > approveForbiddenIdx, 'return false after 403 prevents bypass');
102 });
103
104 test('S7: role from bridge is only accepted after roleRes.ok check', () => {
105 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
106 const fnStart = src.indexOf('async function resolveHostedActorRole');
107 const fn = src.slice(fnStart, src.indexOf('\n}\n', fnStart) + 3);
108 const bridgeBlock = fn.slice(fn.indexOf('else if (BRIDGE_URL'), fn.indexOf('!bridgeResolved'));
109 assert.ok(bridgeBlock.includes('roleRes.ok'), 'Bridge role is only used when roleRes.ok is true');
110 // data.role is only set inside the ok block
111 const okBlock = bridgeBlock.slice(bridgeBlock.indexOf('if (roleRes.ok)'));
112 assert.ok(okBlock.includes('data.role'), 'data.role is inside the roleRes.ok guard');
113 });
114 });
115
116 describe('Security: privilege escalation prevention', () => {
117 test('member JWT cannot become admin via bridge fallback alone', () => {
118 // When bridge returns 401, the fallback uses the JWT role.
119 // A member JWT stays member even through the fallback.
120 const token = jwt.sign({ sub: 'google:member', role: 'member' }, REAL_SECRET, { expiresIn: '1h' });
121 let role = 'member';
122 try {
123 const payload = jwt.verify(token, REAL_SECRET);
124 role = payload.role || 'member';
125 } catch (_) {}
126 assert.equal(role, 'member', 'Member JWT remains member through JWT fallback');
127 });
128
129 test('admin claim in JWT body is verified against SESSION_SECRET before use', () => {
130 // A properly signed admin JWT from the correct gateway → valid
131 const validAdminToken = jwt.sign({ sub: 'google:real-admin', role: 'admin' }, REAL_SECRET, { expiresIn: '1h' });
132 let role = 'member';
133 try {
134 const p = jwt.verify(validAdminToken, REAL_SECRET);
135 role = p.role || 'member';
136 } catch (_) {}
137 assert.equal(role, 'admin', 'Valid admin JWT verified correctly');
138
139 // Same token verified with wrong secret → throws
140 assert.throws(
141 () => jwt.verify(validAdminToken, ATTACKER_SECRET),
142 /invalid signature|JsonWebTokenError/,
143 'Admin claim rejected when verified with wrong secret'
144 );
145 });
146
147 test('gateway admin override fires only for subs in adminUserIdsSet', () => {
148 const adminSet = new Set(['google:the-real-admin']);
149 const checkOverride = (sub, currentRole) => {
150 if (sub && currentRole !== 'admin' && adminSet.has(sub)) {
151 return 'admin';
152 }
153 return currentRole;
154 };
155 assert.equal(checkOverride('google:the-real-admin', 'member'), 'admin', 'admin sub gets override');
156 assert.equal(checkOverride('google:impersonator', 'member'), 'member', 'non-admin sub gets no override');
157 assert.equal(checkOverride('', 'member'), 'member', 'empty sub gets no override');
158 assert.equal(checkOverride(null, 'member'), 'member', 'null sub gets no override');
159 assert.equal(checkOverride('google:the-real-admin', 'admin'), 'admin', 'already-admin gets no change');
160 });
161 });
162
163 describe('Security: no secrets in error surfaces', () => {
164 test('hub.js showToast does not expose raw JWT in error messages', () => {
165 const src = fs.readFileSync(path.join(ROOT, 'web/hub/hub.js'), 'utf8');
166 const fnStart = src.indexOf('async function approveProposal');
167 const fn = src.slice(fnStart, src.indexOf('\n async function discardProposal', fnStart));
168 // Pattern: showToast('Approve failed: ' + msg, true)
169 // Verify the message is built from e.message, which is a string — not the full error object
170 const toastLine = fn.slice(fn.indexOf('showToast('));
171 assert.ok(toastLine.includes('e.message || String(e)') || toastLine.includes('e.message'), 'error uses e.message string, not full error object');
172 assert.ok(!toastLine.includes('JSON.stringify(e)'), 'JSON.stringify not used on error in toast');
173 });
174
175 test('server.mjs RBAC check 403 response does not echo back sensitive request data', () => {
176 const src = fs.readFileSync(path.join(ROOT, 'hub/gateway/server.mjs'), 'utf8');
177 const fnStart = src.indexOf('async function assertHostedProposalApproveDiscard');
178 const fn = src.slice(fnStart, src.indexOf('\n}\n', fnStart) + 3);
179 const forbiddenBlock = fn.slice(fn.indexOf("'FORBIDDEN'") - 200, fn.indexOf("'FORBIDDEN'") + 200);
180 // Error message must be static — not echo req.headers or sub
181 assert.ok(!forbiddenBlock.includes('req.headers'), '403 does not echo request headers');
182 assert.ok(!forbiddenBlock.includes('req.body'), '403 does not echo request body');
183 });
184 });
File History 4 commits
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 43 days ago
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 60 days ago
sha256:873e30b7fafe601346295f8f4289f388f21d8f715f28584d5481899ba2b714fc Merge pull request #249 from aaronrene/muse-mirror Agent 77 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 80 days ago