@bongos/core 1.19.415 → 1.19.417

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,348 @@
1
+ // tests/sso_checkin_identity_spoof.mjs — the hub SSO check-in spoof (security
2
+ // report 1000027, task 1003340, goal 1000045; ADR 0205).
3
+ //
4
+ // THE HOLE THIS PINS SHUT. `POST /sso/membership/check-in` authenticates the
5
+ // CALLER (client_id + client_secret) and takes the SUBJECT (`github_id`) from the
6
+ // request body. Client credentials say WHICH PROJECT is calling; they say nothing
7
+ // about WHO that github_id is. So a project holding only its OWN credentials could:
8
+ //
9
+ // 1. rewrite the `github_login` + `avatar_url` on ANY person's hub account —
10
+ // `upsertAccount`'s ON CONFLICT re-synced both unconditionally — which is
11
+ // what the public profile renders; and
12
+ // 2. mint the `platform_identity_project_memberships` row that
13
+ // `POST /sso/activity/rollup`'s `membership_required` precheck reads. That
14
+ // precheck IS the ADR 0171 D7 anti-impersonation gate, so the gate was
15
+ // satisfiable by the very call it was meant to gate: check in as the victim,
16
+ // then publish forged credits/karma/rank onto their PUBLIC profile.
17
+ //
18
+ // THE FIX, AND WHAT MAKES IT A GATE. A membership row is now born only at the two
19
+ // moments the HUB itself authenticated a human for a client — `POST /sso/token`
20
+ // (redeeming a code minted under a live hub session) and `POST /sso/device/poll`
21
+ // (a GitHub device approval, audience bound from the registered origin) — each
22
+ // stamping `signin_verified_at`, a column no request body can reach. Check-in may
23
+ // only UPDATE a row carrying that witness, and creates the account shell
24
+ // insert-only. The rollup precheck demands the witness, not merely a row.
25
+ //
26
+ // EVERY ASSERTION BELOW IS MUTATION-SENSITIVE: run this file against the code
27
+ // before the fix and the spoof rows fail (200 instead of 409, an identity
28
+ // re-sync where there must be none, an INSERT where there must be an UPDATE).
29
+ // The two "still works" rows are the control — the fix is a gate, not a wall.
30
+ //
31
+ // DB-free: a recording fake pool, the REAL sso router, the REAL domain module,
32
+ // core pinned through the doorway (the tests/account_privacy_flags.mjs pattern).
33
+ // It therefore runs in the default unit gate, where a real-Postgres proof would
34
+ // silently self-skip.
35
+ //
36
+ // Run: node tests/sso_checkin_identity_spoof.mjs
37
+
38
+ import { strict as assert } from 'node:assert';
39
+ import { test, after } from 'node:test';
40
+ import http from 'node:http';
41
+ import { createRequire } from 'node:module';
42
+ import { existsSync, readFileSync } from 'node:fs';
43
+ import { dirname, join } from 'node:path';
44
+ import { fileURLToPath } from 'node:url';
45
+
46
+ const require = createRequire(import.meta.url);
47
+ const express = require('express');
48
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
49
+
50
+ // ---- the recording fake pool ----------------------------------------------
51
+ //
52
+ // One shared pool object: the router's `pool` is destructured from the pinned
53
+ // doorway at require time, so it must be the same object each test queues into.
54
+ // SQL is whitespace-normalised on the way in so the assertions can match shapes
55
+ // without caring how a statement is wrapped.
56
+ let queued = [];
57
+ let calls = [];
58
+ const pool = {
59
+ query: async (text, params) => {
60
+ calls.push({ text: String(text).replace(/\s+/g, ' ').trim(), params: params || [] });
61
+ return queued.shift() || { rows: [], rowCount: 0 };
62
+ },
63
+ };
64
+ function expect(...responses) { queued = responses; calls = []; }
65
+ const sqlFor = (re) => calls.filter((c) => re.test(c.text));
66
+
67
+ // ---- the real routes over the real domain, with core pinned ----------------
68
+
69
+ const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
70
+ const moduleApiPath = require.resolve('../src/module-api.js');
71
+ const helpers = require('../src/bongos/routes/_helpers.js');
72
+ require.cache[moduleApiPath] = {
73
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
74
+ exports: {
75
+ pool,
76
+ validateOrRespond: helpers.validateOrRespond,
77
+ resolveSession: async () => null, // the browser leg is not under test
78
+ logger: () => noopLog,
79
+ hasProvider: () => true, // the hub port is already registered
80
+ registerProvider: () => {},
81
+ on: () => () => {}, // the SLO listener is not under test
82
+ resolveEnv: () => null, // no hub signing key ⇒ device legs 503
83
+ userAgent: () => 'cloud-bongos-tests',
84
+ LIVE_RANK_LADDER: ['xenos', 'thetes', 'metic', 'archon'],
85
+ },
86
+ };
87
+
88
+ const pi = require('../modules/platform-identity/platform-identity.js');
89
+ const buildSsoRoutes = require('../modules/platform-identity/routes/sso.js');
90
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
91
+
92
+ const app = express();
93
+ app.use(express.json());
94
+ app.use(attachFail);
95
+ app.use(buildSsoRoutes());
96
+ const server = app.listen(0, '127.0.0.1');
97
+ await new Promise((r) => server.once('listening', r));
98
+ const port = server.address().port;
99
+ after(() => server.close());
100
+
101
+ function call(method, path, body) {
102
+ return new Promise((resolve, reject) => {
103
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
104
+ const req = http.request({
105
+ host: '127.0.0.1', port, method, path,
106
+ headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
107
+ }, (res) => {
108
+ const chunks = [];
109
+ res.on('data', (c) => chunks.push(c));
110
+ res.on('end', () => {
111
+ const text = Buffer.concat(chunks).toString('utf8');
112
+ let json = null; try { json = JSON.parse(text); } catch { /* non-JSON body */ }
113
+ resolve({ status: res.statusCode, json, text });
114
+ });
115
+ });
116
+ req.on('error', reject);
117
+ req.end(payload);
118
+ });
119
+ }
120
+
121
+ // ---- fixtures --------------------------------------------------------------
122
+ //
123
+ // The attacker holds a fully legitimate, active SSO client and its real secret —
124
+ // the whole point is that valid client credentials must not be authority over a
125
+ // person. VICTIM is somebody else's github_id entirely.
126
+
127
+ const CLIENT_ID = 'sec1000027-attacker-client';
128
+ const CLIENT_SECRET = 'sec1000027-attacker-secret';
129
+ const REDIRECT = 'https://attacker.example/api/gds/auth/web/callback';
130
+ const VICTIM = 9002801777;
131
+ const SIGNED_IN = 4242;
132
+
133
+ const clientRow = () => ({
134
+ client_id: CLIENT_ID,
135
+ secret_hash: pi.sha256hex(CLIENT_SECRET),
136
+ name: 'Attacker Project',
137
+ redirect_uris: [REDIRECT],
138
+ origin: 'https://attacker.example',
139
+ owner_github_id: '1',
140
+ status: 'active',
141
+ });
142
+ const membershipRow = (over = {}) => ({
143
+ id: '5', client_id: CLIENT_ID, github_id: String(VICTIM),
144
+ membership_kind: 'member', joined_at: 'T0', last_active: 'T0',
145
+ signin_verified_at: null, ...over,
146
+ });
147
+ const checkInBody = (over = {}) => ({
148
+ client_id: CLIENT_ID,
149
+ client_secret: CLIENT_SECRET,
150
+ github_id: VICTIM,
151
+ github_login: 'pwned-login',
152
+ display_name: 'Pwned',
153
+ avatar_url: 'https://attacker.example/av.png',
154
+ membership_kind: 'member',
155
+ ...over,
156
+ });
157
+ const rollupBody = (over = {}) => ({
158
+ client_id: CLIENT_ID,
159
+ client_secret: CLIENT_SECRET,
160
+ github_id: VICTIM,
161
+ credits: 999999,
162
+ karma: 999999,
163
+ tasks_shipped: 9999,
164
+ rank: 'archon',
165
+ disciplines: ['engineer'],
166
+ ...over,
167
+ });
168
+
169
+ // ===========================================================================
170
+ // 1. The attack itself — a client with valid credentials, an unaffiliated person
171
+ // ===========================================================================
172
+
173
+ test('check-in for an unaffiliated github_id is REFUSED (no hub sign-in witness)', async () => {
174
+ // getClient → the attacker's real client; the membership lookup finds nothing.
175
+ expect({ rows: [clientRow()] }, { rows: [] });
176
+ const r = await call('POST', '/sso/membership/check-in', checkInBody());
177
+ assert.equal(r.status, 409, 'valid client credentials are not authority over a person');
178
+ assert.equal(r.json.error.code, 'signin_required');
179
+ });
180
+
181
+ test('the refused check-in writes NOTHING — not the identity, not a membership', async () => {
182
+ expect({ rows: [clientRow()] }, { rows: [] });
183
+ await call('POST', '/sso/membership/check-in', checkInBody());
184
+ assert.equal(sqlFor(/platform_identity_accounts/).length, 0,
185
+ "the victim's account row must not be touched at all by a caller who cannot speak for them");
186
+ assert.equal(sqlFor(/INSERT INTO platform_identity_project_memberships/).length, 0,
187
+ 'a check-in must never CREATE a membership — that is what unlocked the forged rollup');
188
+ });
189
+
190
+ test('an accepted check-in re-syncs no identity: the account write is insert-only', async () => {
191
+ expect(
192
+ { rows: [clientRow()] },
193
+ { rows: [membershipRow({ signin_verified_at: '2026-08-29T00:00:00.000Z' })] },
194
+ { rows: [{ id: '9', github_id: String(VICTIM), github_login: 'real-login', handle: 'real-login' }] },
195
+ );
196
+ const r = await call('POST', '/sso/membership/check-in', checkInBody());
197
+ assert.equal(r.status, 200);
198
+
199
+ const writes = sqlFor(/INSERT INTO platform_identity_accounts|UPDATE platform_identity_accounts/);
200
+ assert.equal(writes.length, 1, 'exactly one account WRITE');
201
+ assert.match(writes[0].text, /ON CONFLICT \(github_id\) DO NOTHING/,
202
+ 'an existing account is left alone; only an absent one is created');
203
+ for (const c of sqlFor(/platform_identity_accounts/)) {
204
+ assert.doesNotMatch(c.text, /github_login = EXCLUDED\.github_login/,
205
+ 'the public GitHub login must never be re-synced from an unproven request body');
206
+ assert.doesNotMatch(c.text, /avatar_url = EXCLUDED\.avatar_url/,
207
+ 'nor the avatar the public profile renders');
208
+ assert.doesNotMatch(c.text, /DO UPDATE/,
209
+ 'no statement this route issues may rewrite an existing account');
210
+ }
211
+ });
212
+
213
+ test('an accepted check-in only UPDATEs a membership the hub witnessed', async () => {
214
+ expect(
215
+ { rows: [clientRow()] },
216
+ { rows: [membershipRow({ signin_verified_at: '2026-08-29T00:00:00.000Z' })] },
217
+ { rows: [{ id: '9', handle: 'real-login' }] },
218
+ );
219
+ await call('POST', '/sso/membership/check-in', checkInBody());
220
+
221
+ const membership = sqlFor(/platform_identity_project_memberships/);
222
+ assert.equal(membership.length, 1, 'exactly one membership statement');
223
+ assert.match(membership[0].text, /^UPDATE platform_identity_project_memberships/);
224
+ assert.match(membership[0].text, /signin_verified_at IS NOT NULL/,
225
+ 'the witness is the predicate — without it the UPDATE is a disguised INSERT');
226
+ assert.equal(membership[0].params[0], CLIENT_ID,
227
+ 'keyed to the AUTHENTICATED client, never a caller-named one');
228
+ });
229
+
230
+ test('a non-numeric github_id is refused before it can reach the bigint column', async () => {
231
+ expect({ rows: [clientRow()] });
232
+ const r = await call('POST', '/sso/membership/check-in', checkInBody({ github_id: 'system:bfg' }));
233
+ assert.equal(r.status, 400);
234
+ assert.equal(r.json.error.code, 'invalid_github_id');
235
+ });
236
+
237
+ // ===========================================================================
238
+ // 2. The rollup gate the check-in used to unlock (ADR 0171 D7)
239
+ // ===========================================================================
240
+
241
+ test('the forged portfolio is refused: a membership row without the witness is not a membership', async () => {
242
+ expect({ rows: [clientRow()] }, { rows: [membershipRow({ signin_verified_at: null })] });
243
+ const r = await call('POST', '/sso/activity/rollup', rollupBody());
244
+ assert.equal(r.status, 409, 'row-existence alone was never proof — the writer of the row was the caller');
245
+ assert.equal(r.json.error.code, 'membership_required');
246
+ assert.equal(sqlFor(/platform_identity_builder_project_activity/).length, 0,
247
+ 'nothing reaches the table the public profile aggregates');
248
+ });
249
+
250
+ test('a pending INVITE row still fails the rollup gate, witness or not', async () => {
251
+ expect({ rows: [clientRow()] }, { rows: [membershipRow({ membership_kind: 'pending', signin_verified_at: '2026-08-29T00:00:00.000Z' })] });
252
+ const r = await call('POST', '/sso/activity/rollup', rollupBody());
253
+ assert.equal(r.status, 409);
254
+ assert.equal(r.json.error.code, 'membership_required');
255
+ });
256
+
257
+ test('CONTROL: a witnessed, non-pending membership still publishes its rollup', async () => {
258
+ expect(
259
+ { rows: [clientRow()] },
260
+ { rows: [membershipRow({ signin_verified_at: '2026-08-29T00:00:00.000Z' })] },
261
+ { rows: [{ id: '1', client_id: CLIENT_ID, github_id: String(VICTIM) }] },
262
+ );
263
+ const r = await call('POST', '/sso/activity/rollup', rollupBody());
264
+ assert.equal(r.status, 200, 'the fix is a gate on proof, not a blanket denial of federation');
265
+ assert.equal(sqlFor(/INSERT INTO platform_identity_builder_project_activity/).length, 1);
266
+ });
267
+
268
+ // ===========================================================================
269
+ // 3. Where the proof comes from — the hub's own sign-in, never a request body
270
+ // ===========================================================================
271
+
272
+ test('POST /sso/token stamps the witness, for the github_id the REDEEMED code names', async () => {
273
+ expect(
274
+ { rows: [clientRow()] },
275
+ { rows: [{ github_id: String(SIGNED_IN), github_login: 'octo', display_name: 'Octo', avatar_url: 'https://a.example/o.png', redirect_uri: REDIRECT }] },
276
+ { rows: [{ id: '5', signin_verified_at: 'now' }] },
277
+ );
278
+ const r = await call('POST', '/sso/token', {
279
+ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code: 'the-code', redirect_uri: REDIRECT,
280
+ });
281
+ assert.equal(r.status, 200);
282
+
283
+ const stamp = sqlFor(/signin_verified_at = now\(\)/);
284
+ assert.equal(stamp.length, 1, 'the redeem is where a membership is born');
285
+ assert.deepEqual(stamp[0].params, [CLIENT_ID, String(SIGNED_IN)],
286
+ 'the subject comes from the single-use code the hub minted, never from the caller');
287
+ assert.match(stamp[0].text, /INSERT INTO platform_identity_project_memberships/);
288
+ });
289
+
290
+ test('the token leg accepts no caller-named subject at all (strict schema)', async () => {
291
+ expect({ rows: [clientRow()] });
292
+ const r = await call('POST', '/sso/token', {
293
+ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code: 'the-code', redirect_uri: REDIRECT,
294
+ github_id: VICTIM,
295
+ });
296
+ assert.equal(r.status, 400, 'an undeclared github_id must be rejected, not quietly honoured');
297
+ assert.equal(r.json.error.code, 'validation_failed');
298
+ });
299
+
300
+ test('a failed redeem stamps nothing — no code, no witness', async () => {
301
+ expect({ rows: [clientRow()] }, { rows: [] });
302
+ const r = await call('POST', '/sso/token', {
303
+ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code: 'wrong', redirect_uri: REDIRECT,
304
+ });
305
+ assert.equal(r.status, 400);
306
+ assert.equal(r.json.error.code, 'invalid_grant');
307
+ assert.equal(sqlFor(/signin_verified_at = now\(\)/).length, 0);
308
+ });
309
+
310
+ // ===========================================================================
311
+ // 4. The stored shape + the avatar scheme check
312
+ // ===========================================================================
313
+
314
+ test('migration 014 adds the witness column and deliberately does NOT backfill it', () => {
315
+ const file = join(ROOT, 'modules/platform-identity/migrations/platform_identity_014_membership_signin_proof.sql');
316
+ assert.ok(existsSync(file), 'the witness column needs a migration to exist in');
317
+ const sql = readFileSync(file, 'utf8');
318
+ assert.match(sql, /ADD COLUMN IF NOT EXISTS signin_verified_at timestamptz/);
319
+ assert.doesNotMatch(sql, /SET signin_verified_at\s*=/,
320
+ 'grandfathering existing rows would preserve exactly the rows the report is about');
321
+ });
322
+
323
+ test('safeAvatarUrl admits https and nothing else', () => {
324
+ assert.equal(pi.safeAvatarUrl('https://avatars.example/u/1.png'), 'https://avatars.example/u/1.png');
325
+ for (const bad of [
326
+ 'http://avatars.example/u/1.png',
327
+ 'data:image/svg+xml;base64,PHN2Zy8+',
328
+ 'javascript:alert(1)',
329
+ 'file:///etc/passwd',
330
+ '/relative/path.png',
331
+ 'not a url at all',
332
+ '',
333
+ ]) {
334
+ assert.equal(pi.safeAvatarUrl(bad), null, `${bad || '(empty)'} must not reach storage`);
335
+ }
336
+ assert.equal(pi.safeAvatarUrl(null), null);
337
+ assert.equal(pi.safeAvatarUrl(undefined), null);
338
+ });
339
+
340
+ test('both account writers bind the sanitized avatar, never the raw string', async () => {
341
+ expect({ rows: [{ handle: 'h' }] });
342
+ await pi.upsertAccount({ githubId: 1, githubLogin: 'x', avatarUrl: 'javascript:alert(1)' }, { pool });
343
+ assert.equal(calls[0].params[3], null, 'upsertAccount (the hub-authenticated writer)');
344
+
345
+ expect({ rows: [{ handle: 'h' }] });
346
+ await pi.ensureAccount({ githubId: 1, githubLogin: 'x', avatarUrl: 'data:text/html,<script>' }, { pool });
347
+ assert.equal(calls[0].params[3], null, 'ensureAccount (the federated-client writer)');
348
+ });