@bongos/core 1.19.1076 → 1.19.1077
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.
- package/.bongos-core.json +31 -26
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +7 -0
- package/docs/api/openapi.json +22 -1
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/auth.js +8 -1
- package/src/bongos/middleware/rate-limit.js +1 -1
- package/src/bongos/routes/auth.js +271 -73
- package/src/module-api.js +1 -1
- package/tests/hub_cookie_host_only.mjs +279 -0
- package/tests/landing_page.mjs +4 -1
- package/tests/session_rename_fallback.mjs +19 -11
- package/tests/subdomain_auth.mjs +17 -15
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// tests/hub_cookie_host_only.mjs — the hub's sign-in never reaches a project hall (task 1004357).
|
|
2
|
+
//
|
|
3
|
+
// ADR 0145 Finding 1: the hub scoped its session + handshake cookies to ".<apex>", so the
|
|
4
|
+
// browser sent a hub builder's session to EVERY subdomain — including a project's hall at
|
|
5
|
+
// <handle>.<apex>, which runs code from the project's own repository. That code could read the
|
|
6
|
+
// Cookie header and replay the session against the hub.
|
|
7
|
+
//
|
|
8
|
+
// This suite runs the REAL auth router over HTTP and feeds every Set-Cookie into a small
|
|
9
|
+
// RFC 6265 cookie jar (host-only vs Domain matching), then asks the jar what a project hall
|
|
10
|
+
// would receive. It walks the whole sign-in: start on builders., the restart on the apex, the
|
|
11
|
+
// GitHub callback (the GitHub exchange itself is stubbed), the handoff back to builders., a
|
|
12
|
+
// builder moving from the apex to builders. while signed in, and a browser that still holds a
|
|
13
|
+
// pre-fix apex-scoped cookie. Only the DB session lookup and the GitHub code exchange are stubbed.
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test tests/hub_cookie_host_only.mjs
|
|
16
|
+
|
|
17
|
+
import { test, before, after } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import http from 'node:http';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
// RFC 2606 reserved — synthetic, never routable, no host brand in this file.
|
|
26
|
+
const APEX = 'bongos-test.example';
|
|
27
|
+
const HALL = `mercury.${APEX}`; // a project's hall on one of our addresses
|
|
28
|
+
const BUILDERS = `builders.${APEX}`;
|
|
29
|
+
|
|
30
|
+
const brandDir = mkdtempSync(join(tmpdir(), 'cb-hub-cookie-'));
|
|
31
|
+
const brandFile = join(brandDir, 'branding.json');
|
|
32
|
+
writeFileSync(brandFile, JSON.stringify({
|
|
33
|
+
envPrefix: 'HUBCOOKIE_FIXTURE',
|
|
34
|
+
domains: { cookieDomain: `.${APEX}`, publicOrigin: `https://${APEX}`, oauthOrigin: `https://${APEX}` },
|
|
35
|
+
}));
|
|
36
|
+
process.env.BONGOS_BRANDING_FILE = brandFile;
|
|
37
|
+
process.env.HUBCOOKIE_FIXTURE_GITHUB_CLIENT_ID = 'fixture-client';
|
|
38
|
+
process.env.HUBCOOKIE_FIXTURE_GITHUB_CLIENT_SECRET = 'fixture-secret';
|
|
39
|
+
// An ambient <PREFIX>_PUBLIC_ORIGIN in the shell would outrank the pack's origin — clear every spelling.
|
|
40
|
+
const require0 = createRequire(import.meta.url);
|
|
41
|
+
for (const p of ['BONGOS', ...require0('../src/instance-config.js').LEGACY_ENV_PREFIXES]) delete process.env[`${p}_PUBLIC_ORIGIN`];
|
|
42
|
+
process.on('exit', () => { try { rmSync(brandDir, { recursive: true, force: true }); } catch { /* best-effort */ } });
|
|
43
|
+
|
|
44
|
+
const require = createRequire(import.meta.url);
|
|
45
|
+
const express = require('express');
|
|
46
|
+
const auth = require('../src/bongos/auth.js');
|
|
47
|
+
const db = require('../src/bongos/db');
|
|
48
|
+
const buildAuthRouter = require('../src/bongos/routes/auth.js');
|
|
49
|
+
|
|
50
|
+
// ── stubs: the two things that would leave the process ──
|
|
51
|
+
const LIVE = new Map(); // token → session row
|
|
52
|
+
db.lookupSession = async (t) => LIVE.get(t) || null;
|
|
53
|
+
const realCompleteWebFlow = auth.completeWebFlow;
|
|
54
|
+
auth.completeWebFlow = async () => {
|
|
55
|
+
const token = 'tok' + 'a'.repeat(40);
|
|
56
|
+
const expiresAt = new Date(Date.now() + 86400000);
|
|
57
|
+
LIVE.set(token, { builder_id: 7, expires_at: expiresAt });
|
|
58
|
+
return { token, expiresAt, builder: { id: 7 } };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
let server, port;
|
|
62
|
+
before(async () => {
|
|
63
|
+
const app = express();
|
|
64
|
+
app.set('trust proxy', true);
|
|
65
|
+
app.use('/api/bongos', buildAuthRouter());
|
|
66
|
+
// A stand-in for any page/API on any host: echoes the Cookie header the browser sent.
|
|
67
|
+
app.get('/echo', (req, res) => res.json({ cookie: req.headers.cookie || '' }));
|
|
68
|
+
server = http.createServer(app);
|
|
69
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
70
|
+
port = server.address().port;
|
|
71
|
+
});
|
|
72
|
+
after(() => { server.close(); auth.completeWebFlow = realCompleteWebFlow; });
|
|
73
|
+
|
|
74
|
+
// ── a minimal RFC 6265 jar: Domain cookies match the domain and every subdomain, host-only
|
|
75
|
+
// cookies match exactly one host. Max-Age<=0 / a past Expires deletes. ──
|
|
76
|
+
function makeJar() {
|
|
77
|
+
const cookies = new Map(); // key name|domain|hostOnly → {name, value, domain, hostOnly}
|
|
78
|
+
return {
|
|
79
|
+
store(host, setCookies) {
|
|
80
|
+
for (const sc of setCookies) {
|
|
81
|
+
const [pair, ...attrs] = sc.split(';').map((s) => s.trim());
|
|
82
|
+
const i = pair.indexOf('=');
|
|
83
|
+
const name = pair.slice(0, i); const value = pair.slice(i + 1);
|
|
84
|
+
let domain = null; let dead = false;
|
|
85
|
+
for (const a of attrs) {
|
|
86
|
+
const [k, v = ''] = a.split('=');
|
|
87
|
+
const key = k.toLowerCase();
|
|
88
|
+
if (key === 'domain') domain = v.replace(/^\./, '').toLowerCase();
|
|
89
|
+
if (key === 'max-age' && Number(v) <= 0) dead = true;
|
|
90
|
+
if (key === 'expires' && new Date(v).getTime() < Date.now()) dead = true;
|
|
91
|
+
}
|
|
92
|
+
const hostOnly = !domain;
|
|
93
|
+
const d = domain || host;
|
|
94
|
+
const k = `${name}|${d}|${hostOnly}`;
|
|
95
|
+
if (dead) cookies.delete(k); else cookies.set(k, { name, value, domain: d, hostOnly });
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
header(host) {
|
|
99
|
+
return [...cookies.values()]
|
|
100
|
+
.filter((c) => (c.hostOnly ? host === c.domain : host === c.domain || host.endsWith('.' + c.domain)))
|
|
101
|
+
.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
102
|
+
},
|
|
103
|
+
all() { return [...cookies.values()]; },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// One browser request to https://<host><path>, through the jar.
|
|
108
|
+
function request(jar, host, path, { referer } = {}) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const headers = { host, 'x-forwarded-proto': 'https' };
|
|
111
|
+
const cookie = jar.header(host);
|
|
112
|
+
if (cookie) headers.cookie = cookie;
|
|
113
|
+
if (referer) headers.referer = referer;
|
|
114
|
+
const req = http.request({ host: '127.0.0.1', port, path, method: 'GET', headers }, (res) => {
|
|
115
|
+
let body = '';
|
|
116
|
+
res.on('data', (c) => { body += c; });
|
|
117
|
+
res.on('end', () => {
|
|
118
|
+
const setCookies = [].concat(res.headers['set-cookie'] || []);
|
|
119
|
+
jar.store(host, setCookies);
|
|
120
|
+
resolve({ status: res.statusCode, location: res.headers.location, setCookies, body, headers: res.headers });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
req.on('error', reject);
|
|
124
|
+
req.end();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sessionNames = auth.SESSION_COOKIE_NAMES;
|
|
129
|
+
const hallSees = (jar) => jar.header(HALL);
|
|
130
|
+
const hasSession = (cookieHeader) => sessionNames.some((n) => new RegExp(`(^|; )${n}=`).test(cookieHeader));
|
|
131
|
+
|
|
132
|
+
test('no hub endpoint ever sets a cookie with a Domain attribute', async () => {
|
|
133
|
+
const jar = makeJar();
|
|
134
|
+
const r = await request(jar, APEX, '/api/bongos/auth/web/start?return=%2Fbuilders&repos=1');
|
|
135
|
+
assert.equal(r.status, 302);
|
|
136
|
+
assert.ok(r.setCookies.length >= 3, 'state · return · repos');
|
|
137
|
+
for (const sc of r.setCookies) assert.doesNotMatch(sc, /;\s*Domain=/i, sc);
|
|
138
|
+
assert.equal(hallSees(jar), '', 'the hall receives none of the handshake cookies');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('a sign-in begun on builders. restarts on the apex, sets nothing there, and keeps its return', async () => {
|
|
142
|
+
const jar = makeJar();
|
|
143
|
+
const r = await request(jar, BUILDERS, '/api/bongos/auth/web/start?return=%2Fwatch');
|
|
144
|
+
assert.equal(r.status, 302);
|
|
145
|
+
assert.deepEqual(r.setCookies, [], 'no cookie on the satellite host — its callback could never read it');
|
|
146
|
+
const u = new URL(r.location);
|
|
147
|
+
assert.equal(u.host, APEX);
|
|
148
|
+
assert.equal(u.pathname, '/api/bongos/auth/web/start');
|
|
149
|
+
assert.equal(u.searchParams.get('return'), `https://${BUILDERS}/watch`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('the full sign-in: builders. → apex → GitHub → handoff → builders., and the hall sees nothing', async () => {
|
|
153
|
+
const jar = makeJar();
|
|
154
|
+
// 1. builders. → apex start
|
|
155
|
+
let r = await request(jar, BUILDERS, '/api/bongos/auth/web/start?return=%2Fwatch');
|
|
156
|
+
const startUrl = new URL(r.location);
|
|
157
|
+
r = await request(jar, APEX, startUrl.pathname + startUrl.search);
|
|
158
|
+
assert.equal(r.status, 302);
|
|
159
|
+
const state = new URL(r.location).searchParams.get('state');
|
|
160
|
+
assert.ok(state, 'went to GitHub with a state');
|
|
161
|
+
// 2. GitHub → apex callback (the exchange is stubbed)
|
|
162
|
+
r = await request(jar, APEX, `/api/bongos/auth/web/callback?code=c0de&state=${state}`);
|
|
163
|
+
assert.equal(r.status, 302, r.body);
|
|
164
|
+
const sess = r.setCookies.find((c) => c.startsWith('__Host-bongos_session='));
|
|
165
|
+
assert.ok(sess, 'the session is the __Host- cookie');
|
|
166
|
+
assert.doesNotMatch(sess, /Domain=/i);
|
|
167
|
+
assert.match(sess, /Secure/i);
|
|
168
|
+
assert.match(sess, /Path=\//);
|
|
169
|
+
// 3. apex → builders. handoff
|
|
170
|
+
const hand = new URL(r.location);
|
|
171
|
+
assert.equal(hand.host, BUILDERS);
|
|
172
|
+
assert.equal(hand.pathname, '/api/bongos/auth/web/handoff');
|
|
173
|
+
assert.equal(hand.searchParams.get('return'), '/watch');
|
|
174
|
+
r = await request(jar, BUILDERS, hand.pathname + hand.search);
|
|
175
|
+
assert.equal(r.status, 302);
|
|
176
|
+
assert.equal(r.location, '/watch');
|
|
177
|
+
assert.equal(r.headers['referrer-policy'], 'no-referrer');
|
|
178
|
+
// Signed in on both hub hosts…
|
|
179
|
+
assert.ok(hasSession(jar.header(APEX)), 'apex carries the session');
|
|
180
|
+
assert.ok(hasSession(jar.header(BUILDERS)), 'builders. carries the session');
|
|
181
|
+
// …and the project hall, and any other subdomain, receives no hub cookie at all.
|
|
182
|
+
assert.equal(hallSees(jar), '', `the hall was sent: ${hallSees(jar)}`);
|
|
183
|
+
assert.equal(jar.header(`status.${APEX}`), '', 'a hub host the builder never visited has nothing either');
|
|
184
|
+
// 4. the code was single-use
|
|
185
|
+
r = await request(makeJar(), BUILDERS, hand.pathname + hand.search);
|
|
186
|
+
assert.equal(r.status, 400);
|
|
187
|
+
assert.deepEqual(r.setCookies, []);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('signed in on the apex, opening builders. hands over the session with no GitHub round-trip', async () => {
|
|
191
|
+
const jar = makeJar();
|
|
192
|
+
const token = 'tok' + 'b'.repeat(40);
|
|
193
|
+
LIVE.set(token, { builder_id: 8, expires_at: new Date(Date.now() + 86400000) });
|
|
194
|
+
jar.store(APEX, [`__Host-bongos_session=${token}; Path=/; Secure; HttpOnly`]);
|
|
195
|
+
// builders.'s 401 sends the builder to its own start…
|
|
196
|
+
let r = await request(jar, BUILDERS, '/api/bongos/auth/web/start?return=%2Fbuilders%2Fideas');
|
|
197
|
+
r = await request(jar, APEX, new URL(r.location).pathname + new URL(r.location).search);
|
|
198
|
+
const hand = new URL(r.location);
|
|
199
|
+
assert.equal(hand.host, BUILDERS, 'straight to the handoff, not to GitHub');
|
|
200
|
+
r = await request(jar, BUILDERS, hand.pathname + hand.search);
|
|
201
|
+
assert.equal(r.location, '/builders/ideas');
|
|
202
|
+
assert.match(jar.header(BUILDERS), new RegExp(`__Host-bongos_session=${token}`));
|
|
203
|
+
assert.equal(hallSees(jar), '');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('a handoff is never minted for, nor redeemable on, a project hall', async () => {
|
|
207
|
+
const jar = makeJar();
|
|
208
|
+
const token = 'tok' + 'c'.repeat(40);
|
|
209
|
+
LIVE.set(token, { builder_id: 9, expires_at: new Date(Date.now() + 86400000) });
|
|
210
|
+
jar.store(APEX, [`__Host-bongos_session=${token}; Path=/; Secure; HttpOnly`]);
|
|
211
|
+
// A sign-in whose return is the hall goes to GitHub like any other — no handoff code for it.
|
|
212
|
+
const r = await request(jar, APEX, `/api/bongos/auth/web/start?return=${encodeURIComponent(`https://${HALL}/`)}`);
|
|
213
|
+
assert.equal(new URL(r.location).host, 'github.com');
|
|
214
|
+
assert.equal(buildAuthRouter.handoffUrlFor({ baseUrl: '/api/bongos' }, `https://${HALL}/x`, token, null), null);
|
|
215
|
+
// A code minted for builders. is refused on the hall host.
|
|
216
|
+
const code = buildAuthRouter.mintHandoffCode({ token, host: BUILDERS });
|
|
217
|
+
const onHall = await request(makeJar(), HALL, `/api/bongos/auth/web/handoff?code=${code}&return=%2F`);
|
|
218
|
+
assert.equal(onHall.status, 400);
|
|
219
|
+
assert.deepEqual(onHall.setCookies, []);
|
|
220
|
+
// (In production a hall's requests reach the hall's own server, never this one — the real
|
|
221
|
+
// guarantee is that no code is ever put in a URL to a non-hub host, asserted just above.)
|
|
222
|
+
// A code minted for builders. and presented on another hub host is refused AND burnt.
|
|
223
|
+
const onStatus = await request(makeJar(), `status.${APEX}`, `/api/bongos/auth/web/handoff?code=${code}&return=%2F`);
|
|
224
|
+
assert.equal(onStatus.status, 400);
|
|
225
|
+
assert.deepEqual(onStatus.setCookies, []);
|
|
226
|
+
assert.equal(buildAuthRouter.redeemHandoffCode(code, BUILDERS), null, 'single-use even on a host mismatch');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('a handoff code expires, and its return can never leave the host', async () => {
|
|
230
|
+
const t0 = Date.now();
|
|
231
|
+
const code = buildAuthRouter.mintHandoffCode({ token: 'x', host: BUILDERS, now: t0 });
|
|
232
|
+
assert.equal(buildAuthRouter.redeemHandoffCode(code, BUILDERS, t0 + buildAuthRouter.HANDOFF_TTL_MS + 1), null);
|
|
233
|
+
assert.equal(buildAuthRouter.redeemHandoffCode('not-hex', BUILDERS), null);
|
|
234
|
+
const token = 'tok' + 'd'.repeat(40);
|
|
235
|
+
const good = buildAuthRouter.mintHandoffCode({ token, host: BUILDERS });
|
|
236
|
+
const r = await request(makeJar(), BUILDERS, `/api/bongos/auth/web/handoff?code=${good}&return=${encodeURIComponent('https://evil.example/')}`);
|
|
237
|
+
assert.equal(r.status, 302);
|
|
238
|
+
assert.equal(r.location, '/', 'an absolute return is dropped — relative paths only');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('a browser holding a pre-fix apex-scoped session is moved onto the host-only cookie', async () => {
|
|
242
|
+
const jar = makeJar();
|
|
243
|
+
const token = 'tok' + 'e'.repeat(40);
|
|
244
|
+
LIVE.set(token, { builder_id: 10, expires_at: new Date(Date.now() + 86400000) });
|
|
245
|
+
jar.store(APEX, [`bongos_session=${token}; Domain=.${APEX}; Path=/; Secure; HttpOnly`]);
|
|
246
|
+
assert.ok(hasSession(hallSees(jar)), 'precondition: the legacy cookie reaches the hall (the bug)');
|
|
247
|
+
// Any API request on the hub…
|
|
248
|
+
await request(jar, APEX, '/api/bongos/auth/web/admission-status');
|
|
249
|
+
// …re-issues the token host-only and expires the apex-scoped copy.
|
|
250
|
+
assert.match(jar.header(APEX), new RegExp(`__Host-bongos_session=${token}`));
|
|
251
|
+
assert.equal(hallSees(jar), '', `the hall still receives: ${hallSees(jar)}`);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('a dead pre-fix cookie is simply expired, and a DB failure signs nobody out', async () => {
|
|
255
|
+
const jar = makeJar();
|
|
256
|
+
jar.store(APEX, [`bongos_session=deadtoken; Domain=.${APEX}; Path=/; Secure`]);
|
|
257
|
+
await request(jar, APEX, '/api/bongos/auth/web/admission-status');
|
|
258
|
+
assert.equal(hallSees(jar), '');
|
|
259
|
+
assert.equal(jar.header(APEX), '');
|
|
260
|
+
|
|
261
|
+
const jar2 = makeJar();
|
|
262
|
+
jar2.store(APEX, [`bongos_session=anything; Domain=.${APEX}; Path=/; Secure`]);
|
|
263
|
+
const real = db.lookupSession;
|
|
264
|
+
db.lookupSession = async () => { throw new Error('db down'); };
|
|
265
|
+
try {
|
|
266
|
+
const r = await request(jar2, APEX, '/api/bongos/auth/web/admission-status');
|
|
267
|
+
assert.deepEqual(r.setCookies, [], 'a lookup that throws changes nothing');
|
|
268
|
+
} finally { db.lookupSession = real; }
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('plain http (localhost dev) keeps the un-prefixed name, still host-only', async () => {
|
|
272
|
+
const jar = makeJar();
|
|
273
|
+
const res = { cookies: [], cookie(n, v, o) { this.cookies.push([n, v, o]); }, clearCookie() {} };
|
|
274
|
+
buildAuthRouter.setSessionCookie({ headers: { host: 'localhost:3000' } }, res, 'tok', null);
|
|
275
|
+
assert.equal(res.cookies[0][0], 'bongos_session');
|
|
276
|
+
assert.equal(res.cookies[0][2].domain, undefined);
|
|
277
|
+
assert.equal(res.cookies[0][2].secure, false);
|
|
278
|
+
assert.equal(jar.all().length, 0);
|
|
279
|
+
});
|
package/tests/landing_page.mjs
CHANGED
|
@@ -488,7 +488,10 @@ test('sign out POSTs, and expires both cookies in both Domain forms', () => {
|
|
|
488
488
|
// Every accepted cookie name must be expired, not just the newest: a browser arriving with an
|
|
489
489
|
// older name would otherwise keep a live cookie through "sign out". Derived from the canonical
|
|
490
490
|
// list so this follows the rename chain (task 1003704, and again at 1003706).
|
|
491
|
-
|
|
491
|
+
// Minus the __Host- head (task 1004357): a page script cannot expire that httpOnly, Secure-only
|
|
492
|
+
// cookie; the server logout clears it.
|
|
493
|
+
const scriptClearable = SESSION_COOKIE_NAMES.filter((n) => !n.startsWith('__Host-'));
|
|
494
|
+
assert.match(CODE, new RegExp('\\[' + scriptClearable.map((n) => `'${n}'`).join(', ') + '\\]'));
|
|
492
495
|
assert.match(CODE, /name \+ '=; Max-Age=0; path=\/';/);
|
|
493
496
|
assert.match(CODE, /name \+ '=; Max-Age=0; path=\/; Domain=\.' \+ apex/);
|
|
494
497
|
// it runs whether the POST lands or not
|
|
@@ -185,11 +185,13 @@ test('readSessionConfigSync falls back too — it is what resolveApiBase reads',
|
|
|
185
185
|
const req = (cookie, authz) => ({ headers: { cookie, ...(authz ? { authorization: authz } : {}) } });
|
|
186
186
|
|
|
187
187
|
test('SESSION_COOKIE_NAMES is newest-first and frozen', () => {
|
|
188
|
-
|
|
188
|
+
// __Host-bongos_session is the host-only head (task 1004357); the pre-1004357 apex-scoped
|
|
189
|
+
// bongos_session stays readable so nobody is signed out when the fix deploys.
|
|
190
|
+
assert.deepEqual(auth.SESSION_COOKIE_NAMES, ['__Host-bongos_session', 'bongos_session', 'gds_session', 'pms_session']);
|
|
189
191
|
assert.ok(Object.isFrozen(auth.SESSION_COOKIE_NAMES));
|
|
190
192
|
});
|
|
191
193
|
|
|
192
|
-
for (const name of ['bongos_session', 'gds_session', 'pms_session']) {
|
|
194
|
+
for (const name of ['__Host-bongos_session', 'bongos_session', 'gds_session', 'pms_session']) {
|
|
193
195
|
test(`a request carrying ONLY ${name} still resolves a token`, () => {
|
|
194
196
|
assert.deepEqual(auth.extractTokens(req(`${name}=TOKEN-X`)), ['TOKEN-X']);
|
|
195
197
|
});
|
|
@@ -220,17 +222,19 @@ test('a non-session cookie is never mistaken for one', () => {
|
|
|
220
222
|
|
|
221
223
|
// ── 4. the router sets the new name and clears the old ones ─────────────────────────────────
|
|
222
224
|
|
|
223
|
-
test('routes/auth.js sets ONLY the
|
|
225
|
+
test('routes/auth.js sets ONLY the first two names and derives the clear-list from the read-list', () => {
|
|
224
226
|
const src = read('src/bongos/routes/auth.js');
|
|
225
227
|
assert.match(src, /const SESSION_COOKIE = auth\.SESSION_COOKIE_NAMES\[0\];/,
|
|
226
|
-
'the SET name must be the read-list head, not a second literal');
|
|
227
|
-
assert.match(src, /const
|
|
228
|
-
'the
|
|
228
|
+
'the https SET name must be the read-list head, not a second literal');
|
|
229
|
+
assert.match(src, /const SESSION_COOKIE_INSECURE = auth\.SESSION_COOKIE_NAMES\[1\];/,
|
|
230
|
+
'the plain-http SET name must be the read-list second entry');
|
|
231
|
+
assert.match(src, /for \(const name of auth\.SESSION_COOKIE_NAMES\) \{\n\s+if \(name === keep\) continue;/,
|
|
232
|
+
'the clear-list must be every read-list name but the one being set');
|
|
229
233
|
assert.doesNotMatch(src, /'gds_session'|"gds_session"|'pms_session'|"pms_session"/,
|
|
230
234
|
'no session-cookie name may be re-declared as a literal here — that is how the lists drift');
|
|
231
235
|
// Superseded names must be cleared on BOTH the sign-in write and the logout, or a stale cookie
|
|
232
236
|
// outlives the session it belonged to.
|
|
233
|
-
const clears = src.split('clearSupersededSessionCookies(req, res
|
|
237
|
+
const clears = src.split('clearSupersededSessionCookies(req, res').length - 1;
|
|
234
238
|
assert.ok(clears >= 3, `expected the clear helper defined + used at sign-in and logout, saw ${clears}`);
|
|
235
239
|
});
|
|
236
240
|
|
|
@@ -245,7 +249,7 @@ test('rate-limit.js — its dependency-free copy of the cookie names matches the
|
|
|
245
249
|
const src = read('src/bongos/middleware/rate-limit.js');
|
|
246
250
|
const m = /const SESSION_COOKIE_RES = \[([^\]]*)\]/.exec(src);
|
|
247
251
|
assert.ok(m, 'SESSION_COOKIE_RES not found — did the limiter stop reading session cookies?');
|
|
248
|
-
const names = [...m[1].matchAll(/'([
|
|
252
|
+
const names = [...m[1].matchAll(/'([A-Za-z_-]+)'/g)].map((x) => x[1]);
|
|
249
253
|
assert.deepEqual(names, auth.SESSION_COOKIE_NAMES,
|
|
250
254
|
'the limiter’s copy drifted from src/bongos/auth.js SESSION_COOKIE_NAMES');
|
|
251
255
|
});
|
|
@@ -256,10 +260,14 @@ test('the browser logout clears every session-cookie name', () => {
|
|
|
256
260
|
for (const rel of ['modules/hall-ui/public/shell.js',
|
|
257
261
|
'modules/public-landing/public/index.html',
|
|
258
262
|
'modules/public-landing/public/projects.html']) {
|
|
259
|
-
const m = /\[((?:'[
|
|
263
|
+
const m = /\[((?:'[A-Za-z_-]+',?\s*)+)\]\.forEach\(function \(name\) \{/.exec(read(rel));
|
|
260
264
|
assert.ok(m, `${rel}: cookie-clearing list not found`);
|
|
261
|
-
const names = [...m[1].matchAll(/'([
|
|
262
|
-
|
|
265
|
+
const names = [...m[1].matchAll(/'([A-Za-z_-]+)'/g)].map((x) => x[1]);
|
|
266
|
+
// Minus the __Host- head (task 1004357): a page script cannot expire it — its expire would
|
|
267
|
+
// have to be Secure, and the httpOnly cookie is invisible to document.cookie anyway. The
|
|
268
|
+
// SERVER logout clears it (clearSupersededSessionCookies with { all: true }).
|
|
269
|
+
const scriptClearable = auth.SESSION_COOKIE_NAMES.filter((n) => !n.startsWith('__Host-'));
|
|
270
|
+
assert.deepEqual(names, scriptClearable, `${rel}: clear-list drifted`);
|
|
263
271
|
}
|
|
264
272
|
});
|
|
265
273
|
|
package/tests/subdomain_auth.mjs
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// Locks down the two security-sensitive pure helpers added for the dedicated
|
|
4
4
|
// builders.<apex> subdomain:
|
|
5
5
|
// - safeReturnTo: the open-redirect guard on the post-sign-in return target.
|
|
6
|
-
// -
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// - legacyApexCookieOpts: the hub hosts whose PRE-1004357 apex-scoped cookies
|
|
7
|
+
// get cleared. Since task 1004357 nothing is SET with a Domain attribute —
|
|
8
|
+
// tests/hub_cookie_host_only.mjs proves that over HTTP; this pins which hosts
|
|
9
|
+
// still clear the legacy form (the hub's own four, never staging or a hall).
|
|
9
10
|
// Pure functions, no DB — requiring the route module does not open a pool.
|
|
10
11
|
//
|
|
11
12
|
// Both helpers derive their allowed hosts from the instance branding pack's
|
|
@@ -49,7 +50,7 @@ function ok(cond, msg) {
|
|
|
49
50
|
if (cond) { pass++; console.log(` ok ${msg}`); }
|
|
50
51
|
else { fail++; console.log(` FAIL ${msg}`); }
|
|
51
52
|
}
|
|
52
|
-
const { safeReturnTo,
|
|
53
|
+
const { safeReturnTo, legacyApexCookieOpts } = authRoute;
|
|
53
54
|
|
|
54
55
|
// ---- safeReturnTo: open-redirect guard ------------------------------------
|
|
55
56
|
console.log('\nsafeReturnTo — accepts safe same-city targets:');
|
|
@@ -72,19 +73,20 @@ ok(safeReturnTo(null) === null, 'null');
|
|
|
72
73
|
ok(safeReturnTo('/' + 'a'.repeat(600)) === null, 'over length cap');
|
|
73
74
|
ok(safeReturnTo('builders') === null, 'bare word (no leading slash, not a URL)');
|
|
74
75
|
|
|
75
|
-
// ----
|
|
76
|
+
// ---- legacyApexCookieOpts: where the legacy apex-scoped form is cleared -------
|
|
76
77
|
const reqWith = (host) => ({ headers: { host } });
|
|
77
|
-
console.log('\
|
|
78
|
-
ok(
|
|
79
|
-
ok(
|
|
80
|
-
ok(
|
|
81
|
-
ok(
|
|
82
|
-
ok(
|
|
78
|
+
console.log('\nlegacyApexCookieOpts — the hub hosts clear the legacy apex form:');
|
|
79
|
+
ok(legacyApexCookieOpts(reqWith(APEX)).domain === `.${APEX}`, `apex → .${APEX}`);
|
|
80
|
+
ok(legacyApexCookieOpts(reqWith(`www.${APEX}`)).domain === `.${APEX}`, `www → .${APEX}`);
|
|
81
|
+
ok(legacyApexCookieOpts(reqWith(`builders.${APEX}`)).domain === `.${APEX}`, `builders → .${APEX}`);
|
|
82
|
+
ok(legacyApexCookieOpts(reqWith(`status.${APEX}`)).domain === `.${APEX}`, `status → .${APEX}`);
|
|
83
|
+
ok(legacyApexCookieOpts(reqWith(`${APEX}:3000`)).domain === `.${APEX}`, 'apex with port');
|
|
83
84
|
|
|
84
|
-
console.log('\
|
|
85
|
-
ok(
|
|
86
|
-
ok(
|
|
87
|
-
ok(
|
|
85
|
+
console.log('\nlegacyApexCookieOpts — nothing to clear elsewhere (no domain key):');
|
|
86
|
+
ok(legacyApexCookieOpts(reqWith(`mercury.${APEX}`)).domain === undefined, 'a project hall host is not a hub host');
|
|
87
|
+
ok(legacyApexCookieOpts(reqWith('localhost:3000')).domain === undefined, 'localhost → host-only');
|
|
88
|
+
ok(legacyApexCookieOpts(reqWith(`staging.${APEX}`)).domain === undefined, 'staging → host-only (no cross-env bleed)');
|
|
89
|
+
ok(legacyApexCookieOpts(reqWith('127.0.0.1')).domain === undefined, 'raw IP → host-only');
|
|
88
90
|
|
|
89
91
|
console.log(`\nSubdomain-auth tests: ${pass} passed, ${fail} failed`);
|
|
90
92
|
if (fail > 0) process.exit(1);
|