openvibers/openvibe.network Commits
Split security crawls into module-scoped test files (#33)
Changes
nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++// Personal data, private modules, follows and blocks stay private across the route crawl.++const assert = require('assert');++const { withWorld, privateCrawl, privateMine, checkPrivateStatuses } = require('./security-suite');++++withWorld('private-personal', async (w) => {++ const { users } = w;++ const { res } = await privateCrawl(w, 'personal');++ checkPrivateStatuses(w, res);++++ // Positive controls: the owners of the data do see it, so the needles are real.++ await privateMine(w, 'alice', '/api/auth/me', users.alice.email);++ await privateMine(w, 'alice', '/api/modules/ai.preferences', 'alice-private-perspective');++ await privateMine(w, 'alice', '/api/notifications', 'alice-private-notification');++ await privateMine(w, 'alice', '/api/auth/sessions', 'alice-laptop');++ await privateMine(w, 'alice', '/api/history', 'alice-private-history');++ // The linked-account positive control was deleted with the retired internal key.++ const pub = await w.call(null, 'GET', '/api/v1/status/incidents');++ assert.strictEqual(pub.status, 200);++ assert.ok(!pub.text.includes('long-closed-incident-title'), 'an incident closed 40 days ago is not listed');++++ // Blocks and follows are the person's own: bob cannot list alice's, only count public follows.++ await w.call('alice', 'PUT', `/api/v1/me/follows/channel/${users.owner.subject}`, {});++ let r = await w.call('bob', 'GET', '/api/v1/me/blocks');++ assert.ok(!r.text.includes(users.carol.subject), 'bob does not see who alice blocks');++ r = await w.call('bob', 'GET', '/api/v1/me/follows');++ assert.ok(!r.text.includes(users.owner.subject), 'bob does not see whom alice follows');++ r = await w.call('bob', 'GET', `/api/v1/follows/channel/${users.owner.subject}/followers`);++ assert.ok(r.status === 403 || r.status === 404, `followers of someone else are not bob's to list (${r.status})`);++ assert.ok(!r.text.includes(users.alice.subject));++ r = await w.call('owner', 'GET', `/api/v1/follows/channel/${users.owner.subject}/followers`);++ assert.ok(r.status === 200 && r.text.includes(users.alice.subject), 'the target lists its own followers (positive control)');++ r = await w.call('alice', 'GET', '/api/v1/me/blocks');++ assert.ok(r.text.includes(users.carol.subject), 'alice lists her own blocks (positive control)');++ // Public module reads carry only a namespace's public fields.++ r = await w.call('bob', 'GET', `/api/modules/ai.preferences/public/${users.alice.subject}`);++ assert.ok(!r.text.includes('alice-private-perspective') && !r.text.includes('casual'), `public module read carries no private field (${r.status})`);++}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++// Other GET routes, internal capabilities and retired internal endpoints.++const assert = require('assert');++const { withWorld, privateCrawl, checkPrivateStatuses } = require('./security-suite');++++withWorld('private-platform', async (w) => {++ const { users } = w;++ const { res } = await privateCrawl(w, 'platform');++ checkPrivateStatuses(w, res);++ const st = res.byPath;++ // A service principal reaches only the /internal routes its capabilities name.++ assert.strictEqual(st['service GET /internal/integrations/github-token'], 403, 'github-token needs its own capability');++ // Deleted with the X-Internal-Key retirement (plan T2, no caller left): no longer routes at all.++ for (const p of ['/internal/stats', `/internal/users/${users.alice.id}`, `/internal/users/${users.alice.id}/linked-accounts`, '/internal/anon-list', `/internal/notifications/unread/${users.alice.id}`]) {++ assert.strictEqual(st[`service GET ${p}`], undefined, `${p} is gone`);++ }++}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++// Project details, lists and app tokens respect membership and session boundaries.++const assert = require('assert');++const { withWorld, privateCrawl, privateMine, checkPrivateStatuses } = require('./security-suite');++++withWorld('private-projects', async (w) => {++ const { dev } = w;++ const { res, people } = await privateCrawl(w, 'projects');++ checkPrivateStatuses(w, res);++ await privateMine(w, 'alice', `/api/v1/projects/${dev.PA.id}`, 'alice-private-project');++ await privateMine(w, 'alice', `/api/v1/projects/${dev.PA.id}/apps`, 'alice-private-app');++++ // Lists and search: bob's view of projects never includes alice's, even asking for all.++ for (const p of ['/api/v1/projects', '/api/v1/projects?all=1']) {++ const r = await w.call('bob', 'GET', p);++ assert.strictEqual(r.status, 200);++ assert.ok(r.body.projects.every((x) => x.id !== dev.PA.id), `${p}: alice's project is not listed to bob`);++ }++ // A developer app token opens no person's API and no project API.++ for (const p of ['/api/auth/me', '/api/notifications', '/api/v1/projects', `/api/v1/projects/${dev.PA.id}`, '/api/modules/ai.preferences']) {++ const r = await w.call(people.app, 'GET', p);++ assert.strictEqual(r.status, 401, `${p}: an app token is not a person's session`);++ }++}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });ddeleted file mode 100644@@@ -1,161 +0,0 @@--'use strict';--// Private bypass (roadmap WS-R task 5). With the seeded world of test/security-world.js, alice's--// private data (her email, module data, notification, session device, linked account, history, push--// endpoint, the people she blocks and follows) and the developer projects (names, apps, members,--// credentials, usage, audit) are private; so are /internal/* and the staff pages. Every GET route--// Express knows after boot is crawled as anonymous, bob (another user), a developer app and a service--// principal: no answer may carry alice's private data, a project's private data to a non-member, or--// an incident closed long ago. Then, by status: /internal/* never answers 2xx without a valid key or--// capability token, staff pages never answer non-staff, a non-member gets 404 (existence not--// disclosed) on every read of a project, its apps, members, credentials, grants, quotas, usage and--// audit, and anonymous gets 401.--// node test/security-private.test.js--const assert = require('assert');--const { buildWorld } = require('./security-world');--const crawler = require('./security-crawl');----const out = (...a) => process.stdout.write(a.join(' ') + '\n');----(async () => {-- const t0 = Date.now();-- const w = await buildWorld({ label: 'private' });-- try {-- const { users, dev, db } = w;-- // An incident closed 40 days ago is gone from the public page (recent = the last 30 days).-- const inc = await w.call('staff', 'POST', '/api/v1/status/incidents', { kind: 'incident', title: 'long-closed-incident-title', severity: 'minor', state: 'investigating', services: ['network'], message: 'x' });-- assert.strictEqual(inc.status, 201, inc.text);-- const closed = await w.call('staff', 'POST', `/api/v1/status/incidents/${inc.body.id}/updates`, { state: 'resolved', message: 'fixed' });-- assert.strictEqual(closed.status, 200, closed.text);-- const longAgo = new Date(Date.now() - 40 * 864e5).toISOString();-- await db.prepare('UPDATE status_incidents SET starts_at = ?, updated_at = ?, ends_at = ? WHERE id = ?').run(longAgo, longAgo, longAgo, inc.body.id);-- await db.prepare('UPDATE status_incident_updates SET at = ? WHERE incident_id = ?').run(longAgo, inc.body.id);---- const alice = {-- 'alice email': users.alice.email,-- 'alice module data': 'alice-private-perspective',-- 'alice notification': 'alice-private-notification',-- 'alice session device': 'alice-laptop',-- 'alice linked account': 'alice-games-771',-- 'alice history': 'alice-private-history',-- 'alice push endpoint': w.pushSub.endpoint,-- 'alice project': 'alice-private-project',-- 'alice app': 'alice-private-app',-- 'bob project': 'bob-private-project',-- 'bob app': 'bob-private-app',-- 'long-closed incident': 'long-closed-incident-title',-- 'carol email': users.carol.email,-- 'staff email': users.staff.email,-- };-- const values = (name) => ({-- project: [dev.PA.id, 'prj_00000000000000000000000000'],-- app: [dev.PA.app, 'app_00000000000000000000000000'],-- credential: [dev.PA.credential, 'crd_x'],-- capability: ['media.object.upload', 'x'],-- subject: [users.alice.subject, 'usr_x'],-- target: [users.alice.subject, users.carol.subject, 'x'],-- type: ['user', 'channel'],-- creator: [users.alice.subject, 'alice'],-- id: [String(users.alice.id), String(users.carol.id), '1', 'x'],-- userId: [String(users.alice.id), String(users.carol.id)],-- username: ['alice', 'carol'],-- name: ['alice', 'carol'],-- ns: ['ai.preferences', 'chat.tts_defaults', 'x'],-- namespace: ['general', 'x'],-- token: ['x'],-- idOrSlug: ['default', 'x'],-- serviceId: ['live', 'x'],-- domain: ['openvibe.live'],-- topic: ['live.stream.started'],-- category: ['system'],-- key: ['OV_LIVE_URL'],-- }[name] || ['1']);-- const paths = crawler.pathsFor(w.routes, values, { method: 'get', query: 'all=1&limit=100&user_id=' + users.alice.id + '&subject=' + users.alice.subject, extra: ['/api/v1/projects?all=1', '/api/v1/status/incidents'] });-- // bob is not a member of alice's project and bob's own project is his: his own names are not needles for him.-- const people = { anonymous: w.callers.anonymous, user: w.callers.user, app: w.callers.app, service: w.callers.service };-- assert.ok(people.app && people.app.authorization, 'a developer app token to crawl with');-- const needlesFor = (who) => {-- const n = { ...alice };-- if (who === 'user') { delete n['bob project']; delete n['bob app']; }-- return n;-- };-- const res = await crawler.crawl(w.srv.base, paths, people, needlesFor);-- out(`private crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(res.statuses)}`);-- assert.deepStrictEqual(res.found, [], `private data reached the wrong caller:\n${res.found.join('\n')}`);-- assert.ok(res.answered === paths.length * Object.keys(people).length && (res.statuses['2xx'] || 0) > 200);---- // Positive controls: the owners of the data do see it, so the needles are real.-- const mine = async (who, p, needle) => { const r = await w.call(who, 'GET', p); assert.ok(r.text.includes(needle), `${who} sees ${needle} at ${p} (${r.status})`); };-- await mine('alice', '/api/auth/me', users.alice.email);-- await mine('alice', '/api/modules/ai.preferences', 'alice-private-perspective');-- await mine('alice', '/api/notifications', 'alice-private-notification');-- await mine('alice', '/api/auth/sessions', 'alice-laptop');-- await mine('alice', '/api/history', 'alice-private-history');-- await mine('alice', `/api/v1/projects/${dev.PA.id}`, 'alice-private-project');-- await mine('alice', `/api/v1/projects/${dev.PA.id}/apps`, 'alice-private-app');-- // (The linked-account needle's positive control was GET /internal/users/:id/linked-accounts, deleted with the key-- // in plan T2; the needle stays in the crawl so any route that exposes it to the wrong caller still fails.)-- const pub = await w.call(null, 'GET', '/api/v1/status/incidents');-- assert.strictEqual(pub.status, 200);-- assert.ok(!pub.text.includes('long-closed-incident-title'), 'an incident closed 40 days ago is not listed');---- // ── By status ─────────────────────────────────────────────────-- const st = res.byPath;-- const bad = [];-- for (const [k, s] of Object.entries(st)) {-- const [who, , p] = k.split(' ');-- const path = p.split('?')[0];-- if (who === 'service') continue;-- // /internal/*: a key or a capability token, else never 2xx (and never a redirect into it).-- if (/^\/internal\//.test(path) && s < 400) bad.push(`${k} → ${s}`);-- // Staff and admin pages: never 2xx for these callers.-- // (/api/v1/staff/capabilities is the caller's own: empty for non-staff.)-- if (/^\/api\/(admin|v1\/staff)\//.test(path) && path !== '/api/v1/staff/capabilities' && s < 400) bad.push(`${k} → ${s}`);-- // Alice's project: anonymous 401, others 404 (existence not disclosed).-- if (path.startsWith(`/api/v1/projects/${dev.PA.id}`)) {-- const want = who === 'anonymous' || who === 'app' ? [401] : [404];-- if (!want.includes(s)) bad.push(`${k} → ${s} (want ${want})`);-- }-- // Personal lists: anonymous and apps are refused.-- // (/api/modules/:ns/public/:subject is public by design: only a namespace's public fields.)-- if (/^\/api\/(notifications|history|modules|coins\/me|push\/status|auth\/sessions|auth\/me|v1\/me\/)/.test(path) && !/^\/api\/modules\/[^/]+\/public\//.test(path) && who !== 'user' && s < 400) bad.push(`${k} → ${s}`);-- }-- assert.deepStrictEqual(bad, [], `private reads answered:\n${bad.join('\n')}`);-- // A service principal reaches only the /internal routes its capabilities name.-- assert.strictEqual(st['service GET /internal/integrations/github-token'], 403, 'github-token needs its own capability');-- // Deleted with the X-Internal-Key retirement (plan T2, no caller left): no longer routes at all.-- for (const p of ['/internal/stats', `/internal/users/${users.alice.id}`, `/internal/users/${users.alice.id}/linked-accounts`, '/internal/anon-list', `/internal/notifications/unread/${users.alice.id}`]) {-- assert.strictEqual(st[`service GET ${p}`], undefined, `${p} is gone`);-- }---- // Lists and search: bob's view of projects never includes alice's, even asking for all.-- for (const p of ['/api/v1/projects', '/api/v1/projects?all=1']) {-- const r = await w.call('bob', 'GET', p);-- assert.strictEqual(r.status, 200);-- assert.ok(r.body.projects.every((x) => x.id !== dev.PA.id), `${p}: alice's project is not listed to bob`);-- }-- // Blocks and follows are the person's own: bob cannot list alice's, only count public follows.-- await w.call('alice', 'PUT', `/api/v1/me/follows/channel/${users.owner.subject}`, {});-- let r = await w.call('bob', 'GET', '/api/v1/me/blocks');-- assert.ok(!r.text.includes(users.carol.subject), 'bob does not see who alice blocks');-- r = await w.call('bob', 'GET', '/api/v1/me/follows');-- assert.ok(!r.text.includes(users.owner.subject), 'bob does not see whom alice follows');-- r = await w.call('bob', 'GET', `/api/v1/follows/channel/${users.owner.subject}/followers`);-- assert.ok(r.status === 403 || r.status === 404, `followers of someone else are not bob's to list (${r.status})`);-- assert.ok(!r.text.includes(users.alice.subject));-- r = await w.call('owner', 'GET', `/api/v1/follows/channel/${users.owner.subject}/followers`);-- assert.ok(r.status === 200 && r.text.includes(users.alice.subject), 'the target lists its own followers (positive control)');-- r = await w.call('alice', 'GET', '/api/v1/me/blocks');-- assert.ok(r.text.includes(users.carol.subject), 'alice lists her own blocks (positive control)');-- // Public module reads carry only a namespace's public fields.-- r = await w.call('bob', 'GET', `/api/modules/ai.preferences/public/${users.alice.subject}`);-- assert.ok(!r.text.includes('alice-private-perspective') && !r.text.includes('casual'), `public module read carries no private field (${r.status})`);-- // A developer app token opens no person's API and no project API.-- for (const p of ['/api/auth/me', '/api/notifications', '/api/v1/projects', `/api/v1/projects/${dev.PA.id}`, '/api/modules/ai.preferences']) {-- r = await w.call(people.app, 'GET', p);-- assert.strictEqual(r.status, 401, `${p}: an app token is not a person's session`);-- }-- out(`security private: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);-- } finally {-- await w.stop();-- }--})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,26 @@++'use strict';++// Without key files, a keyless child never publishes its ephemeral HS256 secret.++const assert = require('assert');++const jwt = require('jsonwebtoken');++const { bootServer } = require('./helpers/boot-server');++const { out } = require('./security-suite');++++(async () => {++ const t0 = Date.now();++ const bare = await bootServer({ child: true });++ try {++ const r = await fetch(`${bare.base}/api/.well-known/jwks`);++ const body = await r.json();++ const k = body.public_key;++ if (k && !String(k).includes('BEGIN')) {++ // A published HS256 secret would let a caller forge an admin session.++ const forged = jwt.sign({ sub: 1, id: 1, username: 'admin', role: 'admin' }, k, { algorithm: 'HS256', issuer: 'https://openvibe.network', expiresIn: '1h' });++ const me = await fetch(`${bare.base}/api/auth/me`, { headers: { authorization: `Bearer ${forged}` } });++ assert.notStrictEqual(me.status, 200, 'a session signed with the published HS256 secret is refused');++ }++ assert.ok(!k || String(k).includes('BEGIN PUBLIC KEY'), 'a keyless Network publishes no HS256 secret as its public key');++ } finally {++ await bare.stop();++ }++ out(`security secrets-keys: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++// Crawl every GET route for secrets and verify owner-only reads and JWKS.++const assert = require('assert');++const crypto = require('crypto');++const crawler = require('./security-crawl');++const { withWorld, out, secretsPeople, secretsNeedlesFor, secretsPaths, checkSecretsStored } = require('./security-suite');++++withWorld('secrets-reads', async (w) => {++ const paths = secretsPaths(w);++ const people = secretsPeople(w);++ const needlesFor = secretsNeedlesFor(w, people);++ const gets = await crawler.crawl(w.srv.base, paths, people, needlesFor);++ out(`GET crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(gets.statuses)}`);++ // The owner, and only the owner, reads provider secrets and secret-typed registry values in clear.++ const OWNER_READS = /^owner: GET \/api\/admin\/(settings|url-registry)(\?[^ ]*)? → 200 carries (db setting [a-z_.]+|env DEPLOY_CLOUDFLARE_TOKEN) in its body$/;++ const ownerReads = gets.found.filter((f) => OWNER_READS.test(f));++ assert.ok(ownerReads.length > 0, 'the owner exception is still exercised');++ for (const p of ['/api/admin/settings', '/api/admin/url-registry']) {++ const r = await w.call(people.owner, 'GET', p);++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('cache-control') || '', /no-store/, `${p}: the owner's clear-text read is never cached`);++ const staff = await w.call(people.admin, 'GET', p);++ assert.ok(staff.status === 403 || (staff.status === 200 && crawler.leaks(staff, needlesFor('admin')).length === 0), `${p}: staff admins get it masked or not at all`);++ }++ const leaked = gets.found.filter((f) => !OWNER_READS.test(f));++ assert.deepStrictEqual(leaked, [], `secrets in GET responses:\n${leaked.join('\n')}`);++ assert.ok(paths.length >= 300 && gets.answered === paths.length * Object.keys(people).length, 'every request was answered');++ assert.ok((gets.statuses['2xx'] || 0) > 500, 'the crawl reached real pages, not only refusals');++ assert.strictEqual(Object.values(gets.byPath).filter((s) => s === 429).length, 0, 'no request was rate-limited away');++ const st = (who, p) => gets.byPath[`${who} GET ${p}`];++ assert.strictEqual(st('user', '/api/auth/me'), 200);++ assert.strictEqual(st('anonymous', '/api/auth/me'), 401);++ assert.strictEqual(st('admin', '/api/admin/url-registry'), 200);++ assert.strictEqual(st('admin', '/api/admin/settings'), 403, 'settings are the owner\'s');++ assert.strictEqual(st('user', '/api/admin/url-registry'), 403);++ assert.strictEqual(st('owner', '/api/admin/secrets'), 200);++ assert.strictEqual(st('owner', '/api/admin/settings'), 200);++ assert.strictEqual(st('service', '/internal/url-registry/resolved'), 200, 'live may read the resolved registry');++ assert.strictEqual(st('key', '/internal/url-registry/resolved'), 401, 'the retired X-Internal-Key opens nothing');++ assert.strictEqual(st('anonymous', '/internal/url-registry/resolved'), 401, 'and nobody without a service token gets in');++++ const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');++ assert.strictEqual(jwks.status, 200);++ assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);++ for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);++ const jwk = jwks.body.keys[0];++ assert.strictEqual(jwk.kty, 'RSA');++ assert.strictEqual(jwk.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');++ assert.strictEqual(jwk.n, crypto.createPublicKey(w.keys.publicKey).export({ format: 'jwk' }).n, 'the modulus is the configured key\'s');++ assert.ok(!/PRIVATE/.test(jwks.text));++ await checkSecretsStored(w, needlesFor);++}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,16 @@++'use strict';++// Shown-once responses, logs and persisted events keep secrets private.++const assert = require('assert');++const { withWorld, secretsPeople, secretsNeedlesFor, checkSecretsStored } = require('./security-suite');++++withWorld('secrets-storage', async (w) => {++ const needlesFor = secretsNeedlesFor(w, secretsPeople(w));++ for (const s of w.shownOnce.filter((x) => x.status >= 200 && x.status < 300)) assert.match(s.cache, /no-store/, `${s.what}: Cache-Control no-store`);++ assert.ok(w.shownOnce.filter((x) => x.status >= 200 && x.status < 300).length >= 6, 'the shown-once responses were made');++ for (const [who, m, p, body] of [['alice', 'POST', '/api/auth/refresh', {}], [null, 'POST', '/api/auth/anon-session', {}], [null, 'GET', '/api/auth/anon-identities'], [null, 'POST', '/api/auth/login', { username: 'alice', password: 'wrong' }]]) {++ const r = await w.call(who, m, p, body);++ assert.match(r.headers.get('cache-control') || '', /no-store/, `${m} ${p} (${r.status}): Cache-Control no-store`);++ }++++ await checkSecretsStored(w, needlesFor);++}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++// Refused and malformed write routes must never reveal secrets.++const assert = require('assert');++const jwt = require('jsonwebtoken');++const crawler = require('./security-crawl');++const { withWorld, out, secretsValues, secretsPeople, secretsNeedlesFor, secretsPaths, checkSecretsStored } = require('./security-suite');++++withWorld('secrets-writes', async (w) => {++ const { users, dev } = w;++ const paths = secretsPaths(w);++ const people = secretsPeople(w);++ const needlesFor = secretsNeedlesFor(w, people);++ const writes = {};++ for (const m of ['post', 'put', 'patch', 'delete']) writes[m] = crawler.pathsFor(w.routes, (name) => secretsValues(w, name), { method: m });++ const found = [];++ let sent = 0;++ for (const [m, list] of Object.entries(writes)) {++ const M = m.toUpperCase();++ for (const [body, callers] of [++ ['{"broken": ', people],++ ['{}', { anonymous: {}, app: people.app || {} }],++ ]) {++ const r = await crawler.crawl(w.srv.base, list, callers, needlesFor, { method: M, body });++ sent += list.length * Object.keys(callers).length;++ found.push(...r.found);++ }++ }++ const wrong = {++ 'retired internal key': { 'x-internal-key': 'retired-key' },++ 'forged bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, 'guess', { issuer: w.issuer })}` },++ 'none alg bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, null, { algorithm: 'none' })}` },++ 'setup token guess': { 'x-setup-token': 'x', authorization: 'Bearer x' },++ };++ const wr = await crawler.crawl(w.srv.base, [...writes.post.filter((p) => /^\/(internal|oauth|api\/setup|api\/admin)/.test(p)), ...paths.filter((p) => /^\/(internal|api\/admin)/.test(p))], wrong, needlesFor, { method: 'GET' });++ found.push(...wr.found);++ for (const [grant, form] of [++ ['client_credentials', { client_id: 'live', client_secret: 'wrong' }],++ ['client_credentials', { client_id: dev.PA.clientId, client_secret: 'ovsec_wrong' }],++ ['authorization_code', { client_id: 'live', client_secret: w.clientSecrets.live, code: 'nope', redirect_uri: 'https://evil.test/' }],++ ['refresh_token', { client_id: 'live', client_secret: w.clientSecrets.live, refresh_token: 'f'.repeat(96) }],++ ]) {++ const r = await w.call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: grant, ...form }));++ assert.ok(r.status >= 400, `${grant} with wrong material is refused`);++ for (const l of crawler.leaks(r, needlesFor('anonymous'))) found.push(`oauth ${grant} refusal carries ${l.label}`);++ }++ out(`write/error crawl: ${sent} requests`);++ assert.deepStrictEqual(found, [], `secrets in error responses:\n${found.join('\n')}`);++ await checkSecretsStored(w, needlesFor);++}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });ddeleted file mode 100644@@@ -1,205 +0,0 @@--'use strict';--// Internal-secret leak (roadmap WS-R task 5). The real server boots with a throwaway RS256 pair, a--// generated VAPID pair and a fake low-entropy sentinel in every other secret it reads from the--// environment (test/security-world.js), plus database-held secrets: first-party OAuth client--// secrets, developer app credentials (two overlapping after a rotation) and their hashes, an export--// token, a refresh token and its hash, a session token, an anonymous identity's token, push keys,--// provider-secret copies in site_settings, password hashes and a verification key.--// Every GET route Express knows after boot (test/security-crawl.js walks the router, so a route added--// later is crawled without anyone listing it) is requested with seeded and nonsense ids, with and--// without a query string, plus probes for files that must never be served, as: anonymous, a user,--// a staff admin, the owner, a service principal (live), a developer app and a caller still sending the--// retired X-Internal-Key (which the server must keep refusing and must never answer).--// Then every write route is sent malformed JSON (and anonymous an empty body), and wrong credentials.--// No body or header may carry any secret (as is, URL-encoded or base64); the responses that show a--// secret once (token, credential and session creation) must be Cache-Control: no-store. The log, the--// event outbox and the audit tables written meanwhile must not carry one either. Also: a Network booted--// without its key files never publishes its ephemeral HS256 secret as a "public key".--// node test/security-secrets.test.js--const assert = require('assert');--const crypto = require('crypto');--const jwt = require('jsonwebtoken');--const { buildWorld } = require('./security-world');--const { bootServer } = require('./helpers/boot-server');--const crawler = require('./security-crawl');----const out = (...a) => process.stdout.write(a.join(' ') + '\n');----(async () => {-- const t0 = Date.now();-- const w = await buildWorld({ label: 'secrets' });-- try {-- const { users, dev } = w;-- const values = (name) => ({-- project: [dev.PA.id, dev.PB.id, 'prj_00000000000000000000000000', '..%2f'],-- app: [dev.PA.app, dev.PB.app, 'app_00000000000000000000000000', 'x'],-- credential: [dev.PA.credential, dev.PB.credential, 'crd_x'],-- capability: ['media.object.upload', 'identity.subject.resolve', 'x'],-- subject: [users.alice.subject, users.bob.subject, 'usr_x'],-- target: [users.alice.subject, users.bob.subject, 'x'],-- type: ['user', 'channel', 'x'],-- creator: [users.alice.subject, 'alice', 'x'],-- id: [String(users.alice.id), String(users.owner.id), '1', '999999', 'x', '-1'],-- userId: [String(users.alice.id), String(users.bob.id), '999999'],-- username: ['alice', 'rootowner', 'nobody'],-- name: ['alice', 'live', 'network', 'nobody'],-- ns: ['ai.preferences', 'chat.tts_defaults', 'live.profile', 'x'],-- namespace: ['general', 'network', 'x'],-- key: ['DEPLOY_CLOUDFLARE_TOKEN', 'OV_LIVE_URL', 'x'],-- token: ['not-a-token', 'x'],-- idOrSlug: ['default', 'x'],-- serviceId: ['live', 'network', 'x'],-- domain: ['openvibe.live', 'x'],-- topic: ['live.stream.started', 'x'],-- category: ['system', 'x'],-- }[name] || ['1', 'x']);-- const extra = ['/api/nope', '/internal/nope', '/INTERNAL/users/1', '/.env', '/.git/config', '/package.json', '/server/config.js',-- '/data/network.db', '/data/keys/private.pem', '/keys/private.pem', '/private.pem', '/network.db', '/data/avatars/..%2fnetwork.db',-- '/data/avatars/%2e%2e/network.db', '/shared/..%2f..%2fpackage.json', '/node_modules/.package-lock.json', '/api/.well-known/jwks',-- '/.well-known/openid-configuration', '/oauth/.well-known/openid-configuration', '/api/ready', '/metrics', '/release.json', '/status',-- '/api/v1/status', '/api/admin/secrets', '/api/admin/settings', '/api/admin/config', '/api/admin/url-registry', '/api/admin/email',-- '/api/admin/discord/', '/api/admin/integrations/github/', '/api/admin/deploy/config', '/internal/url-registry/resolved',-- '/internal/integrations/github-token', '/api/setup/status', '/oauth/authorize?client_id=live&redirect_uri=https%3A%2F%2Fevil.test%2F&response_type=code',-- '/oauth/client-info?client_id=live', `/oauth/client-info?client_id=${dev.PA.app}`, '/api/auth/anon-identities', '/sso/check', '/fedcm/accounts'];-- const paths = crawler.pathsFor(w.routes, values, { method: 'get', query: 'limit=5&all=1&debug=1&include=secret', extra });-- const tokenOf = (h) => String((h && h.authorization) || '').replace(/^Bearer /, '');-- const people = { ...w.callers, key: { 'x-internal-key': 'retired-key' } };-- // What each caller must never see: every secret, plus every other caller's credential. Staff-- // admins and the owner list verification keys (they make them to hand out); nobody else sees one.-- const needlesFor = (who) => {-- const n = { ...w.secrets };-- for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s bearer token`] = tokenOf(h);-- if (who !== 'admin' && who !== 'owner') n['verification key'] = w.verificationKey;-- return n;-- };---- // ── Every GET, as everyone ─────────────────────────────────────-- const gets = await crawler.crawl(w.srv.base, paths, people, needlesFor);-- out(`GET crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(gets.statuses)}`);-- // The one documented exception (server/auth/owner-guard.js): the owner, and only the owner,-- // reads provider secrets in the admin settings and the secret-typed registry values in clear,-- // to change them; staff admins get them masked. Those two reads must not be cached.-- const OWNER_READS = /^owner: GET \/api\/admin\/(settings|url-registry)(\?[^ ]*)? → 200 carries (db setting [a-z_.]+|env DEPLOY_CLOUDFLARE_TOKEN) in its body$/;-- const ownerReads = gets.found.filter((f) => OWNER_READS.test(f));-- assert.ok(ownerReads.length > 0, 'the owner exception is still exercised');-- for (const p of ['/api/admin/settings', '/api/admin/url-registry']) {-- const r = await w.call(people.owner, 'GET', p);-- assert.strictEqual(r.status, 200);-- assert.match(r.headers.get('cache-control') || '', /no-store/, `${p}: the owner's clear-text read is never cached`);-- const staff = await w.call(people.admin, 'GET', p);-- assert.ok(staff.status === 403 || (staff.status === 200 && crawler.leaks(staff, needlesFor('admin')).length === 0), `${p}: staff admins get it masked or not at all`);-- }-- const leaked = gets.found.filter((f) => !OWNER_READS.test(f));-- assert.deepStrictEqual(leaked, [], `secrets in GET responses:\n${leaked.join('\n')}`);-- assert.ok(paths.length >= 300 && gets.answered === paths.length * Object.keys(people).length, 'every request was answered');-- assert.ok((gets.statuses['2xx'] || 0) > 500, 'the crawl reached real pages, not only refusals');-- assert.strictEqual(Object.values(gets.byPath).filter((s) => s === 429).length, 0, 'no request was rate-limited away');-- // The crawl really was signed in: each caller reaches something only it can.-- const st = (who, p) => gets.byPath[`${who} GET ${p}`];-- assert.strictEqual(st('user', '/api/auth/me'), 200);-- assert.strictEqual(st('anonymous', '/api/auth/me'), 401);-- assert.strictEqual(st('admin', '/api/admin/url-registry'), 200);-- assert.strictEqual(st('admin', '/api/admin/settings'), 403, 'settings are the owner\'s');-- assert.strictEqual(st('user', '/api/admin/url-registry'), 403);-- assert.strictEqual(st('owner', '/api/admin/secrets'), 200);-- assert.strictEqual(st('owner', '/api/admin/settings'), 200);-- assert.strictEqual(st('service', '/internal/url-registry/resolved'), 200, 'live may read the resolved registry');-- assert.strictEqual(st('key', '/internal/url-registry/resolved'), 401, 'the retired X-Internal-Key opens nothing');-- assert.strictEqual(st('anonymous', '/internal/url-registry/resolved'), 401, 'and nobody without a service token gets in');---- // JWKS and OIDC discovery: the public key, never a private part.-- const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');-- assert.strictEqual(jwks.status, 200);-- assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);-- for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);-- // The configured key (decision 2, plan T2): the kid and modulus JWKS serves are the signing key's.-- const jwk = jwks.body.keys[0];-- assert.strictEqual(jwk.kty, 'RSA');-- assert.strictEqual(jwk.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');-- assert.strictEqual(jwk.n, crypto.createPublicKey(w.keys.publicKey).export({ format: 'jwk' }).n, 'the modulus is the configured key\'s');-- assert.ok(!/PRIVATE/.test(jwks.text));---- // ── Error paths: every write route, refused or malformed ─────────-- const writes = {};-- for (const m of ['post', 'put', 'patch', 'delete']) writes[m] = crawler.pathsFor(w.routes, values, { method: m });-- const found = [];-- let sent = 0;-- for (const [m, list] of Object.entries(writes)) {-- const M = m.toUpperCase();-- for (const [body, callers] of [-- ['{"broken": ', people], // malformed JSON, everyone-- ['{}', { anonymous: {}, app: people.app || {} }], // empty body without the right credential-- ]) {-- const r = await crawler.crawl(w.srv.base, list, callers, needlesFor, { method: M, body });-- sent += list.length * Object.keys(callers).length;-- found.push(...r.found);-- }-- }-- // Wrong credentials of every kind on the paths that take them.-- const wrong = {-- 'retired internal key': { 'x-internal-key': 'retired-key' },-- 'forged bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, 'guess', { issuer: w.issuer })}` },-- 'none alg bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, null, { algorithm: 'none' })}` },-- 'setup token guess': { 'x-setup-token': 'x', authorization: 'Bearer x' },-- };-- const wr = await crawler.crawl(w.srv.base, [...writes.post.filter((p) => /^\/(internal|oauth|api\/setup|api\/admin)/.test(p)), ...paths.filter((p) => /^\/(internal|api\/admin)/.test(p))], wrong, needlesFor, { method: 'GET' });-- found.push(...wr.found);-- for (const [grant, form] of [-- ['client_credentials', { client_id: 'live', client_secret: 'wrong' }],-- ['client_credentials', { client_id: dev.PA.clientId, client_secret: 'ovsec_wrong' }],-- ['authorization_code', { client_id: 'live', client_secret: w.clientSecrets.live, code: 'nope', redirect_uri: 'https://evil.test/' }],-- ['refresh_token', { client_id: 'live', client_secret: w.clientSecrets.live, refresh_token: 'f'.repeat(96) }],-- ]) {-- const r = await w.call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: grant, ...form }));-- assert.ok(r.status >= 400, `${grant} with wrong material is refused`);-- for (const l of crawler.leaks(r, needlesFor('anonymous'))) found.push(`oauth ${grant} refusal carries ${l.label}`);-- }-- out(`write/error crawl: ${sent} requests`);-- assert.deepStrictEqual(found, [], `secrets in error responses:\n${found.join('\n')}`);---- // ── Shown once, never cached ─────────────────────────────────────-- for (const s of w.shownOnce.filter((x) => x.status >= 200 && x.status < 300)) assert.match(s.cache, /no-store/, `${s.what}: Cache-Control no-store`);-- assert.ok(w.shownOnce.filter((x) => x.status >= 200 && x.status < 300).length >= 6, 'the shown-once responses were made');-- // The other answers that carry a token: refresh, an anonymous session, the anonymous identities of this address.-- for (const [who, m, p, body] of [['alice', 'POST', '/api/auth/refresh', {}], [null, 'POST', '/api/auth/anon-session', {}], [null, 'GET', '/api/auth/anon-identities'], [null, 'POST', '/api/auth/login', { username: 'alice', password: 'wrong' }]]) {-- const r = await w.call(who, m, p, body);-- assert.match(r.headers.get('cache-control') || '', /no-store/, `${m} ${p} (${r.status}): Cache-Control no-store`);-- }---- // ── Logs, outbox and audit tables ────────────────────────────────-- const all = { ...needlesFor('nobody'), 'verification key': w.verificationKey };-- delete all['verification key']; // stored in its own table by design; only its readers are checked above-- const logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);-- assert.deepStrictEqual(logHits, [], 'nothing secret was logged');-- const tables = (await w.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND (table_name LIKE '%outbox%' OR table_name LIKE '%audit%' OR table_name LIKE '%log%' OR table_name LIKE '%usage%' OR table_name LIKE '%changes%' OR table_name LIKE '%alerts%' OR table_name LIKE '%revisions%' OR table_name = 'notifications')").all()).map((r) => r.name);-- assert.ok(tables.includes('network_event_outbox') && tables.includes('dev_audit') && tables.includes('audit_log'), tables.join(','));-- const rowHits = [];-- for (const t of tables) {-- const text = JSON.stringify(await w.db.prepare(`SELECT * FROM "${t}"`).all());-- for (const l of crawler.leaks({ text, headers: {} }, all)) rowHits.push(`${t}: ${l.label}`);-- }-- assert.deepStrictEqual(rowHits, [], 'no secret in the outbox, audit or log tables');-- assert.ok((await w.db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get()).n > 0, 'the outbox was written meanwhile');-- } finally {-- await w.stop();-- }---- // ── Without key files: the ephemeral HS256 secret is never published (a child of its own, so its-- // database and process are not the seeded world's) ───-- const bare = await bootServer({ child: true });-- try {-- const r = await fetch(`${bare.base}/api/.well-known/jwks`);-- const body = await r.json();-- const k = body.public_key;-- if (k && !String(k).includes('BEGIN')) {-- // What the published "key" would let anyone do: sign a session for the first admin.-- const forged = jwt.sign({ sub: 1, id: 1, username: 'admin', role: 'admin' }, k, { algorithm: 'HS256', issuer: 'https://openvibe.network', expiresIn: '1h' });-- const me = await fetch(`${bare.base}/api/auth/me`, { headers: { authorization: `Bearer ${forged}` } });-- assert.notStrictEqual(me.status, 200, 'a session signed with the published HS256 secret is refused');-- }-- assert.ok(!k || String(k).includes('BEGIN PUBLIC KEY'), 'a keyless Network publishes no HS256 secret as its public key');-- } finally {-- await bare.stop();-- }-- out(`security secrets: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);--})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,188 @@++'use strict';++// Shared fixtures and route expansion for the module-scoped security checks.++const assert = require('assert');++const { buildWorld } = require('./security-world');++const crawler = require('./security-crawl');++++const out = (...a) => process.stdout.write(a.join(' ') + '\n');++++async function withWorld(label, check) {++ const t0 = Date.now();++ const w = await buildWorld({ label });++ try {++ await check(w);++ out(`security ${label}: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);++ } finally {++ await w.stop();++ }++}++++async function prepareClosedIncident(w) {++ const inc = await w.call('staff', 'POST', '/api/v1/status/incidents', { kind: 'incident', title: 'long-closed-incident-title', severity: 'minor', state: 'investigating', services: ['network'], message: 'x' });++ assert.strictEqual(inc.status, 201, inc.text);++ const closed = await w.call('staff', 'POST', `/api/v1/status/incidents/${inc.body.id}/updates`, { state: 'resolved', message: 'fixed' });++ assert.strictEqual(closed.status, 200, closed.text);++ const longAgo = new Date(Date.now() - 40 * 864e5).toISOString();++ await w.db.prepare('UPDATE status_incidents SET starts_at = ?, updated_at = ?, ends_at = ? WHERE id = ?').run(longAgo, longAgo, longAgo, inc.body.id);++ await w.db.prepare('UPDATE status_incident_updates SET at = ? WHERE incident_id = ?').run(longAgo, inc.body.id);++}++++function privateValues(w, name) {++ const { users, dev } = w;++ return ({++ project: [dev.PA.id, 'prj_00000000000000000000000000'],++ app: [dev.PA.app, 'app_00000000000000000000000000'],++ credential: [dev.PA.credential, 'crd_x'],++ capability: ['media.object.upload', 'x'],++ subject: [users.alice.subject, 'usr_x'],++ target: [users.alice.subject, users.carol.subject, 'x'],++ type: ['user', 'channel'],++ creator: [users.alice.subject, 'alice'],++ id: [String(users.alice.id), String(users.carol.id), '1', 'x'],++ userId: [String(users.alice.id), String(users.carol.id)],++ username: ['alice', 'carol'],++ name: ['alice', 'carol'],++ ns: ['ai.preferences', 'chat.tts_defaults', 'x'],++ namespace: ['general', 'x'],++ token: ['x'],++ idOrSlug: ['default', 'x'],++ serviceId: ['live', 'x'],++ domain: ['openvibe.live'],++ topic: ['live.stream.started'],++ category: ['system'],++ key: ['OV_LIVE_URL'],++ })[name] || ['1'];++}++++function privateArea(path) {++ if (path.startsWith('/api/v1/projects')) return 'projects';++ if (path.match(/^\/api\/(auth|notifications|history|modules|coins|push|v1\/me|v1\/follows|v1\/status)(\/|\?|$)/)) return 'personal';++ return 'platform';++}++++async function privateCrawl(w, area) {++ const { users, dev } = w;++ await prepareClosedIncident(w);++ const alice = {++ 'alice email': users.alice.email,++ 'alice module data': 'alice-private-perspective',++ 'alice notification': 'alice-private-notification',++ 'alice session device': 'alice-laptop',++ 'alice linked account': 'alice-games-771',++ 'alice history': 'alice-private-history',++ 'alice push endpoint': w.pushSub.endpoint,++ 'alice project': 'alice-private-project',++ 'alice app': 'alice-private-app',++ 'bob project': 'bob-private-project',++ 'bob app': 'bob-private-app',++ 'long-closed incident': 'long-closed-incident-title',++ 'carol email': users.carol.email,++ 'staff email': users.staff.email,++ };++ const allPaths = crawler.pathsFor(w.routes, (name) => privateValues(w, name), { method: 'get', query: 'all=1&limit=100&user_id=' + users.alice.id + '&subject=' + users.alice.subject, extra: ['/api/v1/projects?all=1', '/api/v1/status/incidents'] });++ const paths = allPaths.filter((p) => privateArea(p) === area);++ const people = { anonymous: w.callers.anonymous, user: w.callers.user, app: w.callers.app, service: w.callers.service };++ assert.ok(people.app && people.app.authorization, 'a developer app token to crawl with');++ const needlesFor = (who) => {++ const n = { ...alice };++ if (who === 'user') { delete n['bob project']; delete n['bob app']; }++ return n;++ };++ const res = await crawler.crawl(w.srv.base, paths, people, needlesFor);++ out(`private ${area} crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(res.statuses)}`);++ assert.deepStrictEqual(res.found, [], `private data reached the wrong caller:\n${res.found.join('\n')}`);++ assert.ok(res.answered === paths.length * Object.keys(people).length && (res.statuses['2xx'] || 0) > (area === 'platform' ? 200 : 0));++ return { res, people };++}++++async function privateMine(w, who, p, needle) {++ const r = await w.call(who, 'GET', p);++ assert.ok(r.text.includes(needle), `${who} sees ${needle} at ${p} (${r.status})`);++}++++function checkPrivateStatuses(w, res) {++ const st = res.byPath;++ const bad = [];++ for (const [k, s] of Object.entries(st)) {++ const [who, , p] = k.split(' ');++ const path = p.split('?')[0];++ if (who === 'service') continue;++ if (/^\/internal\//.test(path) && s < 400) bad.push(`${k} → ${s}`);++ if (/^\/api\/(admin|v1\/staff)\//.test(path) && path !== '/api/v1/staff/capabilities' && s < 400) bad.push(`${k} → ${s}`);++ if (path.startsWith(`/api/v1/projects/${w.dev.PA.id}`)) {++ const want = who === 'anonymous' || who === 'app' ? [401] : [404];++ if (!want.includes(s)) bad.push(`${k} → ${s} (want ${want})`);++ }++ if (/^\/api\/(notifications|history|modules|coins\/me|push\/status|auth\/sessions|auth\/me|v1\/me\/)/.test(path) && !/^\/api\/modules\/[^/]+\/public\//.test(path) && who !== 'user' && s < 400) bad.push(`${k} → ${s}`);++ }++ assert.deepStrictEqual(bad, [], `private reads answered:\n${bad.join('\n')}`);++}++++function secretsValues(w, name) {++ const { users, dev } = w;++ return ({++ project: [dev.PA.id, dev.PB.id, 'prj_00000000000000000000000000', '..%2f'],++ app: [dev.PA.app, dev.PB.app, 'app_00000000000000000000000000', 'x'],++ credential: [dev.PA.credential, dev.PB.credential, 'crd_x'],++ capability: ['media.object.upload', 'identity.subject.resolve', 'x'],++ subject: [users.alice.subject, users.bob.subject, 'usr_x'],++ target: [users.alice.subject, users.bob.subject, 'x'],++ type: ['user', 'channel', 'x'],++ creator: [users.alice.subject, 'alice', 'x'],++ id: [String(users.alice.id), String(users.owner.id), '1', '999999', 'x', '-1'],++ userId: [String(users.alice.id), String(users.bob.id), '999999'],++ username: ['alice', 'rootowner', 'nobody'],++ name: ['alice', 'live', 'network', 'nobody'],++ ns: ['ai.preferences', 'chat.tts_defaults', 'live.profile', 'x'],++ namespace: ['general', 'network', 'x'],++ key: ['DEPLOY_CLOUDFLARE_TOKEN', 'OV_LIVE_URL', 'x'],++ token: ['not-a-token', 'x'],++ idOrSlug: ['default', 'x'],++ serviceId: ['live', 'network', 'x'],++ domain: ['openvibe.live', 'x'],++ topic: ['live.stream.started', 'x'],++ category: ['system', 'x'],++ })[name] || ['1', 'x'];++}++++function secretsPeople(w) { return { ...w.callers, key: { 'x-internal-key': 'retired-key' } }; }++++function secretsNeedlesFor(w, people) {++ const tokenOf = (h) => String((h && h.authorization) || '').replace(/^Bearer /, '');++ return (who) => {++ const n = { ...w.secrets };++ for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s bearer token`] = tokenOf(h);++ if (who !== 'admin' && who !== 'owner') n['verification key'] = w.verificationKey;++ return n;++ };++}++++function secretsPaths(w) {++ const extra = ['/api/nope', '/internal/nope', '/INTERNAL/users/1', '/.env', '/.git/config', '/package.json', '/server/config.js',++ '/data/network.db', '/data/keys/private.pem', '/keys/private.pem', '/private.pem', '/network.db', '/data/avatars/..%2fnetwork.db',++ '/data/avatars/%2e%2e/network.db', '/shared/..%2f..%2fpackage.json', '/node_modules/.package-lock.json', '/api/.well-known/jwks',++ '/.well-known/openid-configuration', '/oauth/.well-known/openid-configuration', '/api/ready', '/metrics', '/release.json', '/status',++ '/api/v1/status', '/api/admin/secrets', '/api/admin/settings', '/api/admin/config', '/api/admin/url-registry', '/api/admin/email',++ '/api/admin/discord/', '/api/admin/integrations/github/', '/api/admin/deploy/config', '/internal/url-registry/resolved',++ '/internal/integrations/github-token', '/api/setup/status', '/oauth/authorize?client_id=live&redirect_uri=https%3A%2F%2Fevil.test%2F&response_type=code',++ '/oauth/client-info?client_id=live', `/oauth/client-info?client_id=${w.dev.PA.app}`, '/api/auth/anon-identities', '/sso/check', '/fedcm/accounts'];++ return crawler.pathsFor(w.routes, (name) => secretsValues(w, name), { method: 'get', query: 'limit=5&all=1&debug=1&include=secret', extra });++}++++async function checkSecretsStored(w, needlesFor) {++ const all = { ...needlesFor('nobody'), 'verification key': w.verificationKey };++ delete all['verification key']; // stored in its own table by design; only its readers are checked above++ const logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);++ assert.deepStrictEqual(logHits, [], 'nothing secret was logged');++ const tables = (await w.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND (table_name LIKE '%outbox%' OR table_name LIKE '%audit%' OR table_name LIKE '%log%' OR table_name LIKE '%usage%' OR table_name LIKE '%changes%' OR table_name LIKE '%alerts%' OR table_name LIKE '%revisions%' OR table_name = 'notifications')").all()).map((r) => r.name);++ assert.ok(tables.includes('network_event_outbox') && tables.includes('dev_audit') && tables.includes('audit_log'), tables.join(','));++ const rowHits = [];++ for (const t of tables) {++ const text = JSON.stringify(await w.db.prepare(`SELECT * FROM "${t}"`).all());++ for (const l of crawler.leaks({ text, headers: {} }, all)) rowHits.push(`${t}: ${l.label}`);++ }++ assert.deepStrictEqual(rowHits, [], 'no secret in the outbox, audit or log tables');++ assert.ok((await w.db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get()).n > 0, 'the outbox was written meanwhile');++}++++module.exports = { withWorld, out, privateCrawl, privateMine, checkPrivateStatuses, secretsValues, secretsPeople, secretsNeedlesFor, secretsPaths, checkSecretsStored };
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+// Personal data, private modules, follows and blocks stay private across the route crawl.
+const assert = require('assert');
+const { withWorld, privateCrawl, privateMine, checkPrivateStatuses } = require('./security-suite');
+
+withWorld('private-personal', async (w) => {
+ const { users } = w;
+ const { res } = await privateCrawl(w, 'personal');
+ checkPrivateStatuses(w, res);
+
+ // Positive controls: the owners of the data do see it, so the needles are real.
+ await privateMine(w, 'alice', '/api/auth/me', users.alice.email);
+ await privateMine(w, 'alice', '/api/modules/ai.preferences', 'alice-private-perspective');
+ await privateMine(w, 'alice', '/api/notifications', 'alice-private-notification');
+ await privateMine(w, 'alice', '/api/auth/sessions', 'alice-laptop');
+ await privateMine(w, 'alice', '/api/history', 'alice-private-history');
+ // The linked-account positive control was deleted with the retired internal key.
+ const pub = await w.call(null, 'GET', '/api/v1/status/incidents');
+ assert.strictEqual(pub.status, 200);
+ assert.ok(!pub.text.includes('long-closed-incident-title'), 'an incident closed 40 days ago is not listed');
+
+ // Blocks and follows are the person's own: bob cannot list alice's, only count public follows.
+ await w.call('alice', 'PUT', `/api/v1/me/follows/channel/${users.owner.subject}`, {});
+ let r = await w.call('bob', 'GET', '/api/v1/me/blocks');
+ assert.ok(!r.text.includes(users.carol.subject), 'bob does not see who alice blocks');
+ r = await w.call('bob', 'GET', '/api/v1/me/follows');
+ assert.ok(!r.text.includes(users.owner.subject), 'bob does not see whom alice follows');
+ r = await w.call('bob', 'GET', `/api/v1/follows/channel/${users.owner.subject}/followers`);
+ assert.ok(r.status === 403 || r.status === 404, `followers of someone else are not bob's to list (${r.status})`);
+ assert.ok(!r.text.includes(users.alice.subject));
+ r = await w.call('owner', 'GET', `/api/v1/follows/channel/${users.owner.subject}/followers`);
+ assert.ok(r.status === 200 && r.text.includes(users.alice.subject), 'the target lists its own followers (positive control)');
+ r = await w.call('alice', 'GET', '/api/v1/me/blocks');
+ assert.ok(r.text.includes(users.carol.subject), 'alice lists her own blocks (positive control)');
+ // Public module reads carry only a namespace's public fields.
+ r = await w.call('bob', 'GET', `/api/modules/ai.preferences/public/${users.alice.subject}`);
+ assert.ok(!r.text.includes('alice-private-perspective') && !r.text.includes('casual'), `public module read carries no private field (${r.status})`);
+}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+// Other GET routes, internal capabilities and retired internal endpoints.
+const assert = require('assert');
+const { withWorld, privateCrawl, checkPrivateStatuses } = require('./security-suite');
+
+withWorld('private-platform', async (w) => {
+ const { users } = w;
+ const { res } = await privateCrawl(w, 'platform');
+ checkPrivateStatuses(w, res);
+ const st = res.byPath;
+ // A service principal reaches only the /internal routes its capabilities name.
+ assert.strictEqual(st['service GET /internal/integrations/github-token'], 403, 'github-token needs its own capability');
+ // Deleted with the X-Internal-Key retirement (plan T2, no caller left): no longer routes at all.
+ for (const p of ['/internal/stats', `/internal/users/${users.alice.id}`, `/internal/users/${users.alice.id}/linked-accounts`, '/internal/anon-list', `/internal/notifications/unread/${users.alice.id}`]) {
+ assert.strictEqual(st[`service GET ${p}`], undefined, `${p} is gone`);
+ }
+}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+// Project details, lists and app tokens respect membership and session boundaries.
+const assert = require('assert');
+const { withWorld, privateCrawl, privateMine, checkPrivateStatuses } = require('./security-suite');
+
+withWorld('private-projects', async (w) => {
+ const { dev } = w;
+ const { res, people } = await privateCrawl(w, 'projects');
+ checkPrivateStatuses(w, res);
+ await privateMine(w, 'alice', `/api/v1/projects/${dev.PA.id}`, 'alice-private-project');
+ await privateMine(w, 'alice', `/api/v1/projects/${dev.PA.id}/apps`, 'alice-private-app');
+
+ // Lists and search: bob's view of projects never includes alice's, even asking for all.
+ for (const p of ['/api/v1/projects', '/api/v1/projects?all=1']) {
+ const r = await w.call('bob', 'GET', p);
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.body.projects.every((x) => x.id !== dev.PA.id), `${p}: alice's project is not listed to bob`);
+ }
+ // A developer app token opens no person's API and no project API.
+ for (const p of ['/api/auth/me', '/api/notifications', '/api/v1/projects', `/api/v1/projects/${dev.PA.id}`, '/api/modules/ai.preferences']) {
+ const r = await w.call(people.app, 'GET', p);
+ assert.strictEqual(r.status, 401, `${p}: an app token is not a person's session`);
+ }
+}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,161 +0,0 @@
@@ -1,161 +0,0 @@
-'use strict';
-// Private bypass (roadmap WS-R task 5). With the seeded world of test/security-world.js, alice's
-// private data (her email, module data, notification, session device, linked account, history, push
-// endpoint, the people she blocks and follows) and the developer projects (names, apps, members,
-// credentials, usage, audit) are private; so are /internal/* and the staff pages. Every GET route
-// Express knows after boot is crawled as anonymous, bob (another user), a developer app and a service
-// principal: no answer may carry alice's private data, a project's private data to a non-member, or
-// an incident closed long ago. Then, by status: /internal/* never answers 2xx without a valid key or
-// capability token, staff pages never answer non-staff, a non-member gets 404 (existence not
-// disclosed) on every read of a project, its apps, members, credentials, grants, quotas, usage and
-// audit, and anonymous gets 401.
-// node test/security-private.test.js
-const assert = require('assert');
-const { buildWorld } = require('./security-world');
-const crawler = require('./security-crawl');
-
-const out = (...a) => process.stdout.write(a.join(' ') + '\n');
-
-(async () => {
- const t0 = Date.now();
- const w = await buildWorld({ label: 'private' });
- try {
- const { users, dev, db } = w;
- // An incident closed 40 days ago is gone from the public page (recent = the last 30 days).
- const inc = await w.call('staff', 'POST', '/api/v1/status/incidents', { kind: 'incident', title: 'long-closed-incident-title', severity: 'minor', state: 'investigating', services: ['network'], message: 'x' });
- assert.strictEqual(inc.status, 201, inc.text);
- const closed = await w.call('staff', 'POST', `/api/v1/status/incidents/${inc.body.id}/updates`, { state: 'resolved', message: 'fixed' });
- assert.strictEqual(closed.status, 200, closed.text);
- const longAgo = new Date(Date.now() - 40 * 864e5).toISOString();
- await db.prepare('UPDATE status_incidents SET starts_at = ?, updated_at = ?, ends_at = ? WHERE id = ?').run(longAgo, longAgo, longAgo, inc.body.id);
- await db.prepare('UPDATE status_incident_updates SET at = ? WHERE incident_id = ?').run(longAgo, inc.body.id);
-
- const alice = {
- 'alice email': users.alice.email,
- 'alice module data': 'alice-private-perspective',
- 'alice notification': 'alice-private-notification',
- 'alice session device': 'alice-laptop',
- 'alice linked account': 'alice-games-771',
- 'alice history': 'alice-private-history',
- 'alice push endpoint': w.pushSub.endpoint,
- 'alice project': 'alice-private-project',
- 'alice app': 'alice-private-app',
- 'bob project': 'bob-private-project',
- 'bob app': 'bob-private-app',
- 'long-closed incident': 'long-closed-incident-title',
- 'carol email': users.carol.email,
- 'staff email': users.staff.email,
- };
- const values = (name) => ({
- project: [dev.PA.id, 'prj_00000000000000000000000000'],
- app: [dev.PA.app, 'app_00000000000000000000000000'],
- credential: [dev.PA.credential, 'crd_x'],
- capability: ['media.object.upload', 'x'],
- subject: [users.alice.subject, 'usr_x'],
- target: [users.alice.subject, users.carol.subject, 'x'],
- type: ['user', 'channel'],
- creator: [users.alice.subject, 'alice'],
- id: [String(users.alice.id), String(users.carol.id), '1', 'x'],
- userId: [String(users.alice.id), String(users.carol.id)],
- username: ['alice', 'carol'],
- name: ['alice', 'carol'],
- ns: ['ai.preferences', 'chat.tts_defaults', 'x'],
- namespace: ['general', 'x'],
- token: ['x'],
- idOrSlug: ['default', 'x'],
- serviceId: ['live', 'x'],
- domain: ['openvibe.live'],
- topic: ['live.stream.started'],
- category: ['system'],
- key: ['OV_LIVE_URL'],
- }[name] || ['1']);
- const paths = crawler.pathsFor(w.routes, values, { method: 'get', query: 'all=1&limit=100&user_id=' + users.alice.id + '&subject=' + users.alice.subject, extra: ['/api/v1/projects?all=1', '/api/v1/status/incidents'] });
- // bob is not a member of alice's project and bob's own project is his: his own names are not needles for him.
- const people = { anonymous: w.callers.anonymous, user: w.callers.user, app: w.callers.app, service: w.callers.service };
- assert.ok(people.app && people.app.authorization, 'a developer app token to crawl with');
- const needlesFor = (who) => {
- const n = { ...alice };
- if (who === 'user') { delete n['bob project']; delete n['bob app']; }
- return n;
- };
- const res = await crawler.crawl(w.srv.base, paths, people, needlesFor);
- out(`private crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(res.statuses)}`);
- assert.deepStrictEqual(res.found, [], `private data reached the wrong caller:\n${res.found.join('\n')}`);
- assert.ok(res.answered === paths.length * Object.keys(people).length && (res.statuses['2xx'] || 0) > 200);
-
- // Positive controls: the owners of the data do see it, so the needles are real.
- const mine = async (who, p, needle) => { const r = await w.call(who, 'GET', p); assert.ok(r.text.includes(needle), `${who} sees ${needle} at ${p} (${r.status})`); };
- await mine('alice', '/api/auth/me', users.alice.email);
- await mine('alice', '/api/modules/ai.preferences', 'alice-private-perspective');
- await mine('alice', '/api/notifications', 'alice-private-notification');
- await mine('alice', '/api/auth/sessions', 'alice-laptop');
- await mine('alice', '/api/history', 'alice-private-history');
- await mine('alice', `/api/v1/projects/${dev.PA.id}`, 'alice-private-project');
- await mine('alice', `/api/v1/projects/${dev.PA.id}/apps`, 'alice-private-app');
- // (The linked-account needle's positive control was GET /internal/users/:id/linked-accounts, deleted with the key
- // in plan T2; the needle stays in the crawl so any route that exposes it to the wrong caller still fails.)
- const pub = await w.call(null, 'GET', '/api/v1/status/incidents');
- assert.strictEqual(pub.status, 200);
- assert.ok(!pub.text.includes('long-closed-incident-title'), 'an incident closed 40 days ago is not listed');
-
- // ── By status ─────────────────────────────────────────────────
- const st = res.byPath;
- const bad = [];
- for (const [k, s] of Object.entries(st)) {
- const [who, , p] = k.split(' ');
- const path = p.split('?')[0];
- if (who === 'service') continue;
- // /internal/*: a key or a capability token, else never 2xx (and never a redirect into it).
- if (/^\/internal\//.test(path) && s < 400) bad.push(`${k} → ${s}`);
- // Staff and admin pages: never 2xx for these callers.
- // (/api/v1/staff/capabilities is the caller's own: empty for non-staff.)
- if (/^\/api\/(admin|v1\/staff)\//.test(path) && path !== '/api/v1/staff/capabilities' && s < 400) bad.push(`${k} → ${s}`);
- // Alice's project: anonymous 401, others 404 (existence not disclosed).
- if (path.startsWith(`/api/v1/projects/${dev.PA.id}`)) {
- const want = who === 'anonymous' || who === 'app' ? [401] : [404];
- if (!want.includes(s)) bad.push(`${k} → ${s} (want ${want})`);
- }
- // Personal lists: anonymous and apps are refused.
- // (/api/modules/:ns/public/:subject is public by design: only a namespace's public fields.)
- if (/^\/api\/(notifications|history|modules|coins\/me|push\/status|auth\/sessions|auth\/me|v1\/me\/)/.test(path) && !/^\/api\/modules\/[^/]+\/public\//.test(path) && who !== 'user' && s < 400) bad.push(`${k} → ${s}`);
- }
- assert.deepStrictEqual(bad, [], `private reads answered:\n${bad.join('\n')}`);
- // A service principal reaches only the /internal routes its capabilities name.
- assert.strictEqual(st['service GET /internal/integrations/github-token'], 403, 'github-token needs its own capability');
- // Deleted with the X-Internal-Key retirement (plan T2, no caller left): no longer routes at all.
- for (const p of ['/internal/stats', `/internal/users/${users.alice.id}`, `/internal/users/${users.alice.id}/linked-accounts`, '/internal/anon-list', `/internal/notifications/unread/${users.alice.id}`]) {
- assert.strictEqual(st[`service GET ${p}`], undefined, `${p} is gone`);
- }
-
- // Lists and search: bob's view of projects never includes alice's, even asking for all.
- for (const p of ['/api/v1/projects', '/api/v1/projects?all=1']) {
- const r = await w.call('bob', 'GET', p);
- assert.strictEqual(r.status, 200);
- assert.ok(r.body.projects.every((x) => x.id !== dev.PA.id), `${p}: alice's project is not listed to bob`);
- }
- // Blocks and follows are the person's own: bob cannot list alice's, only count public follows.
- await w.call('alice', 'PUT', `/api/v1/me/follows/channel/${users.owner.subject}`, {});
- let r = await w.call('bob', 'GET', '/api/v1/me/blocks');
- assert.ok(!r.text.includes(users.carol.subject), 'bob does not see who alice blocks');
- r = await w.call('bob', 'GET', '/api/v1/me/follows');
- assert.ok(!r.text.includes(users.owner.subject), 'bob does not see whom alice follows');
- r = await w.call('bob', 'GET', `/api/v1/follows/channel/${users.owner.subject}/followers`);
- assert.ok(r.status === 403 || r.status === 404, `followers of someone else are not bob's to list (${r.status})`);
- assert.ok(!r.text.includes(users.alice.subject));
- r = await w.call('owner', 'GET', `/api/v1/follows/channel/${users.owner.subject}/followers`);
- assert.ok(r.status === 200 && r.text.includes(users.alice.subject), 'the target lists its own followers (positive control)');
- r = await w.call('alice', 'GET', '/api/v1/me/blocks');
- assert.ok(r.text.includes(users.carol.subject), 'alice lists her own blocks (positive control)');
- // Public module reads carry only a namespace's public fields.
- r = await w.call('bob', 'GET', `/api/modules/ai.preferences/public/${users.alice.subject}`);
- assert.ok(!r.text.includes('alice-private-perspective') && !r.text.includes('casual'), `public module read carries no private field (${r.status})`);
- // A developer app token opens no person's API and no project API.
- for (const p of ['/api/auth/me', '/api/notifications', '/api/v1/projects', `/api/v1/projects/${dev.PA.id}`, '/api/modules/ai.preferences']) {
- r = await w.call(people.app, 'GET', p);
- assert.strictEqual(r.status, 401, `${p}: an app token is not a person's session`);
- }
- out(`security private: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
- } finally {
- await w.stop();
- }
-})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+'use strict';
+// Without key files, a keyless child never publishes its ephemeral HS256 secret.
+const assert = require('assert');
+const jwt = require('jsonwebtoken');
+const { bootServer } = require('./helpers/boot-server');
+const { out } = require('./security-suite');
+
+(async () => {
+ const t0 = Date.now();
+ const bare = await bootServer({ child: true });
+ try {
+ const r = await fetch(`${bare.base}/api/.well-known/jwks`);
+ const body = await r.json();
+ const k = body.public_key;
+ if (k && !String(k).includes('BEGIN')) {
+ // A published HS256 secret would let a caller forge an admin session.
+ const forged = jwt.sign({ sub: 1, id: 1, username: 'admin', role: 'admin' }, k, { algorithm: 'HS256', issuer: 'https://openvibe.network', expiresIn: '1h' });
+ const me = await fetch(`${bare.base}/api/auth/me`, { headers: { authorization: `Bearer ${forged}` } });
+ assert.notStrictEqual(me.status, 200, 'a session signed with the published HS256 secret is refused');
+ }
+ assert.ok(!k || String(k).includes('BEGIN PUBLIC KEY'), 'a keyless Network publishes no HS256 secret as its public key');
+ } finally {
+ await bare.stop();
+ }
+ out(`security secrets-keys: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
+})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+// Crawl every GET route for secrets and verify owner-only reads and JWKS.
+const assert = require('assert');
+const crypto = require('crypto');
+const crawler = require('./security-crawl');
+const { withWorld, out, secretsPeople, secretsNeedlesFor, secretsPaths, checkSecretsStored } = require('./security-suite');
+
+withWorld('secrets-reads', async (w) => {
+ const paths = secretsPaths(w);
+ const people = secretsPeople(w);
+ const needlesFor = secretsNeedlesFor(w, people);
+ const gets = await crawler.crawl(w.srv.base, paths, people, needlesFor);
+ out(`GET crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(gets.statuses)}`);
+ // The owner, and only the owner, reads provider secrets and secret-typed registry values in clear.
+ const OWNER_READS = /^owner: GET \/api\/admin\/(settings|url-registry)(\?[^ ]*)? → 200 carries (db setting [a-z_.]+|env DEPLOY_CLOUDFLARE_TOKEN) in its body$/;
+ const ownerReads = gets.found.filter((f) => OWNER_READS.test(f));
+ assert.ok(ownerReads.length > 0, 'the owner exception is still exercised');
+ for (const p of ['/api/admin/settings', '/api/admin/url-registry']) {
+ const r = await w.call(people.owner, 'GET', p);
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('cache-control') || '', /no-store/, `${p}: the owner's clear-text read is never cached`);
+ const staff = await w.call(people.admin, 'GET', p);
+ assert.ok(staff.status === 403 || (staff.status === 200 && crawler.leaks(staff, needlesFor('admin')).length === 0), `${p}: staff admins get it masked or not at all`);
+ }
+ const leaked = gets.found.filter((f) => !OWNER_READS.test(f));
+ assert.deepStrictEqual(leaked, [], `secrets in GET responses:\n${leaked.join('\n')}`);
+ assert.ok(paths.length >= 300 && gets.answered === paths.length * Object.keys(people).length, 'every request was answered');
+ assert.ok((gets.statuses['2xx'] || 0) > 500, 'the crawl reached real pages, not only refusals');
+ assert.strictEqual(Object.values(gets.byPath).filter((s) => s === 429).length, 0, 'no request was rate-limited away');
+ const st = (who, p) => gets.byPath[`${who} GET ${p}`];
+ assert.strictEqual(st('user', '/api/auth/me'), 200);
+ assert.strictEqual(st('anonymous', '/api/auth/me'), 401);
+ assert.strictEqual(st('admin', '/api/admin/url-registry'), 200);
+ assert.strictEqual(st('admin', '/api/admin/settings'), 403, 'settings are the owner\'s');
+ assert.strictEqual(st('user', '/api/admin/url-registry'), 403);
+ assert.strictEqual(st('owner', '/api/admin/secrets'), 200);
+ assert.strictEqual(st('owner', '/api/admin/settings'), 200);
+ assert.strictEqual(st('service', '/internal/url-registry/resolved'), 200, 'live may read the resolved registry');
+ assert.strictEqual(st('key', '/internal/url-registry/resolved'), 401, 'the retired X-Internal-Key opens nothing');
+ assert.strictEqual(st('anonymous', '/internal/url-registry/resolved'), 401, 'and nobody without a service token gets in');
+
+ const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');
+ assert.strictEqual(jwks.status, 200);
+ assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);
+ for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);
+ const jwk = jwks.body.keys[0];
+ assert.strictEqual(jwk.kty, 'RSA');
+ assert.strictEqual(jwk.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');
+ assert.strictEqual(jwk.n, crypto.createPublicKey(w.keys.publicKey).export({ format: 'jwk' }).n, 'the modulus is the configured key\'s');
+ assert.ok(!/PRIVATE/.test(jwks.text));
+ await checkSecretsStored(w, needlesFor);
+}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+'use strict';
+// Shown-once responses, logs and persisted events keep secrets private.
+const assert = require('assert');
+const { withWorld, secretsPeople, secretsNeedlesFor, checkSecretsStored } = require('./security-suite');
+
+withWorld('secrets-storage', async (w) => {
+ const needlesFor = secretsNeedlesFor(w, secretsPeople(w));
+ for (const s of w.shownOnce.filter((x) => x.status >= 200 && x.status < 300)) assert.match(s.cache, /no-store/, `${s.what}: Cache-Control no-store`);
+ assert.ok(w.shownOnce.filter((x) => x.status >= 200 && x.status < 300).length >= 6, 'the shown-once responses were made');
+ for (const [who, m, p, body] of [['alice', 'POST', '/api/auth/refresh', {}], [null, 'POST', '/api/auth/anon-session', {}], [null, 'GET', '/api/auth/anon-identities'], [null, 'POST', '/api/auth/login', { username: 'alice', password: 'wrong' }]]) {
+ const r = await w.call(who, m, p, body);
+ assert.match(r.headers.get('cache-control') || '', /no-store/, `${m} ${p} (${r.status}): Cache-Control no-store`);
+ }
+
+ await checkSecretsStored(w, needlesFor);
+}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+// Refused and malformed write routes must never reveal secrets.
+const assert = require('assert');
+const jwt = require('jsonwebtoken');
+const crawler = require('./security-crawl');
+const { withWorld, out, secretsValues, secretsPeople, secretsNeedlesFor, secretsPaths, checkSecretsStored } = require('./security-suite');
+
+withWorld('secrets-writes', async (w) => {
+ const { users, dev } = w;
+ const paths = secretsPaths(w);
+ const people = secretsPeople(w);
+ const needlesFor = secretsNeedlesFor(w, people);
+ const writes = {};
+ for (const m of ['post', 'put', 'patch', 'delete']) writes[m] = crawler.pathsFor(w.routes, (name) => secretsValues(w, name), { method: m });
+ const found = [];
+ let sent = 0;
+ for (const [m, list] of Object.entries(writes)) {
+ const M = m.toUpperCase();
+ for (const [body, callers] of [
+ ['{"broken": ', people],
+ ['{}', { anonymous: {}, app: people.app || {} }],
+ ]) {
+ const r = await crawler.crawl(w.srv.base, list, callers, needlesFor, { method: M, body });
+ sent += list.length * Object.keys(callers).length;
+ found.push(...r.found);
+ }
+ }
+ const wrong = {
+ 'retired internal key': { 'x-internal-key': 'retired-key' },
+ 'forged bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, 'guess', { issuer: w.issuer })}` },
+ 'none alg bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, null, { algorithm: 'none' })}` },
+ 'setup token guess': { 'x-setup-token': 'x', authorization: 'Bearer x' },
+ };
+ const wr = await crawler.crawl(w.srv.base, [...writes.post.filter((p) => /^\/(internal|oauth|api\/setup|api\/admin)/.test(p)), ...paths.filter((p) => /^\/(internal|api\/admin)/.test(p))], wrong, needlesFor, { method: 'GET' });
+ found.push(...wr.found);
+ for (const [grant, form] of [
+ ['client_credentials', { client_id: 'live', client_secret: 'wrong' }],
+ ['client_credentials', { client_id: dev.PA.clientId, client_secret: 'ovsec_wrong' }],
+ ['authorization_code', { client_id: 'live', client_secret: w.clientSecrets.live, code: 'nope', redirect_uri: 'https://evil.test/' }],
+ ['refresh_token', { client_id: 'live', client_secret: w.clientSecrets.live, refresh_token: 'f'.repeat(96) }],
+ ]) {
+ const r = await w.call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: grant, ...form }));
+ assert.ok(r.status >= 400, `${grant} with wrong material is refused`);
+ for (const l of crawler.leaks(r, needlesFor('anonymous'))) found.push(`oauth ${grant} refusal carries ${l.label}`);
+ }
+ out(`write/error crawl: ${sent} requests`);
+ assert.deepStrictEqual(found, [], `secrets in error responses:\n${found.join('\n')}`);
+ await checkSecretsStored(w, needlesFor);
+}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,205 +0,0 @@
@@ -1,205 +0,0 @@
-'use strict';
-// Internal-secret leak (roadmap WS-R task 5). The real server boots with a throwaway RS256 pair, a
-// generated VAPID pair and a fake low-entropy sentinel in every other secret it reads from the
-// environment (test/security-world.js), plus database-held secrets: first-party OAuth client
-// secrets, developer app credentials (two overlapping after a rotation) and their hashes, an export
-// token, a refresh token and its hash, a session token, an anonymous identity's token, push keys,
-// provider-secret copies in site_settings, password hashes and a verification key.
-// Every GET route Express knows after boot (test/security-crawl.js walks the router, so a route added
-// later is crawled without anyone listing it) is requested with seeded and nonsense ids, with and
-// without a query string, plus probes for files that must never be served, as: anonymous, a user,
-// a staff admin, the owner, a service principal (live), a developer app and a caller still sending the
-// retired X-Internal-Key (which the server must keep refusing and must never answer).
-// Then every write route is sent malformed JSON (and anonymous an empty body), and wrong credentials.
-// No body or header may carry any secret (as is, URL-encoded or base64); the responses that show a
-// secret once (token, credential and session creation) must be Cache-Control: no-store. The log, the
-// event outbox and the audit tables written meanwhile must not carry one either. Also: a Network booted
-// without its key files never publishes its ephemeral HS256 secret as a "public key".
-// node test/security-secrets.test.js
-const assert = require('assert');
-const crypto = require('crypto');
-const jwt = require('jsonwebtoken');
-const { buildWorld } = require('./security-world');
-const { bootServer } = require('./helpers/boot-server');
-const crawler = require('./security-crawl');
-
-const out = (...a) => process.stdout.write(a.join(' ') + '\n');
-
-(async () => {
- const t0 = Date.now();
- const w = await buildWorld({ label: 'secrets' });
- try {
- const { users, dev } = w;
- const values = (name) => ({
- project: [dev.PA.id, dev.PB.id, 'prj_00000000000000000000000000', '..%2f'],
- app: [dev.PA.app, dev.PB.app, 'app_00000000000000000000000000', 'x'],
- credential: [dev.PA.credential, dev.PB.credential, 'crd_x'],
- capability: ['media.object.upload', 'identity.subject.resolve', 'x'],
- subject: [users.alice.subject, users.bob.subject, 'usr_x'],
- target: [users.alice.subject, users.bob.subject, 'x'],
- type: ['user', 'channel', 'x'],
- creator: [users.alice.subject, 'alice', 'x'],
- id: [String(users.alice.id), String(users.owner.id), '1', '999999', 'x', '-1'],
- userId: [String(users.alice.id), String(users.bob.id), '999999'],
- username: ['alice', 'rootowner', 'nobody'],
- name: ['alice', 'live', 'network', 'nobody'],
- ns: ['ai.preferences', 'chat.tts_defaults', 'live.profile', 'x'],
- namespace: ['general', 'network', 'x'],
- key: ['DEPLOY_CLOUDFLARE_TOKEN', 'OV_LIVE_URL', 'x'],
- token: ['not-a-token', 'x'],
- idOrSlug: ['default', 'x'],
- serviceId: ['live', 'network', 'x'],
- domain: ['openvibe.live', 'x'],
- topic: ['live.stream.started', 'x'],
- category: ['system', 'x'],
- }[name] || ['1', 'x']);
- const extra = ['/api/nope', '/internal/nope', '/INTERNAL/users/1', '/.env', '/.git/config', '/package.json', '/server/config.js',
- '/data/network.db', '/data/keys/private.pem', '/keys/private.pem', '/private.pem', '/network.db', '/data/avatars/..%2fnetwork.db',
- '/data/avatars/%2e%2e/network.db', '/shared/..%2f..%2fpackage.json', '/node_modules/.package-lock.json', '/api/.well-known/jwks',
- '/.well-known/openid-configuration', '/oauth/.well-known/openid-configuration', '/api/ready', '/metrics', '/release.json', '/status',
- '/api/v1/status', '/api/admin/secrets', '/api/admin/settings', '/api/admin/config', '/api/admin/url-registry', '/api/admin/email',
- '/api/admin/discord/', '/api/admin/integrations/github/', '/api/admin/deploy/config', '/internal/url-registry/resolved',
- '/internal/integrations/github-token', '/api/setup/status', '/oauth/authorize?client_id=live&redirect_uri=https%3A%2F%2Fevil.test%2F&response_type=code',
- '/oauth/client-info?client_id=live', `/oauth/client-info?client_id=${dev.PA.app}`, '/api/auth/anon-identities', '/sso/check', '/fedcm/accounts'];
- const paths = crawler.pathsFor(w.routes, values, { method: 'get', query: 'limit=5&all=1&debug=1&include=secret', extra });
- const tokenOf = (h) => String((h && h.authorization) || '').replace(/^Bearer /, '');
- const people = { ...w.callers, key: { 'x-internal-key': 'retired-key' } };
- // What each caller must never see: every secret, plus every other caller's credential. Staff
- // admins and the owner list verification keys (they make them to hand out); nobody else sees one.
- const needlesFor = (who) => {
- const n = { ...w.secrets };
- for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s bearer token`] = tokenOf(h);
- if (who !== 'admin' && who !== 'owner') n['verification key'] = w.verificationKey;
- return n;
- };
-
- // ── Every GET, as everyone ─────────────────────────────────────
- const gets = await crawler.crawl(w.srv.base, paths, people, needlesFor);
- out(`GET crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(gets.statuses)}`);
- // The one documented exception (server/auth/owner-guard.js): the owner, and only the owner,
- // reads provider secrets in the admin settings and the secret-typed registry values in clear,
- // to change them; staff admins get them masked. Those two reads must not be cached.
- const OWNER_READS = /^owner: GET \/api\/admin\/(settings|url-registry)(\?[^ ]*)? → 200 carries (db setting [a-z_.]+|env DEPLOY_CLOUDFLARE_TOKEN) in its body$/;
- const ownerReads = gets.found.filter((f) => OWNER_READS.test(f));
- assert.ok(ownerReads.length > 0, 'the owner exception is still exercised');
- for (const p of ['/api/admin/settings', '/api/admin/url-registry']) {
- const r = await w.call(people.owner, 'GET', p);
- assert.strictEqual(r.status, 200);
- assert.match(r.headers.get('cache-control') || '', /no-store/, `${p}: the owner's clear-text read is never cached`);
- const staff = await w.call(people.admin, 'GET', p);
- assert.ok(staff.status === 403 || (staff.status === 200 && crawler.leaks(staff, needlesFor('admin')).length === 0), `${p}: staff admins get it masked or not at all`);
- }
- const leaked = gets.found.filter((f) => !OWNER_READS.test(f));
- assert.deepStrictEqual(leaked, [], `secrets in GET responses:\n${leaked.join('\n')}`);
- assert.ok(paths.length >= 300 && gets.answered === paths.length * Object.keys(people).length, 'every request was answered');
- assert.ok((gets.statuses['2xx'] || 0) > 500, 'the crawl reached real pages, not only refusals');
- assert.strictEqual(Object.values(gets.byPath).filter((s) => s === 429).length, 0, 'no request was rate-limited away');
- // The crawl really was signed in: each caller reaches something only it can.
- const st = (who, p) => gets.byPath[`${who} GET ${p}`];
- assert.strictEqual(st('user', '/api/auth/me'), 200);
- assert.strictEqual(st('anonymous', '/api/auth/me'), 401);
- assert.strictEqual(st('admin', '/api/admin/url-registry'), 200);
- assert.strictEqual(st('admin', '/api/admin/settings'), 403, 'settings are the owner\'s');
- assert.strictEqual(st('user', '/api/admin/url-registry'), 403);
- assert.strictEqual(st('owner', '/api/admin/secrets'), 200);
- assert.strictEqual(st('owner', '/api/admin/settings'), 200);
- assert.strictEqual(st('service', '/internal/url-registry/resolved'), 200, 'live may read the resolved registry');
- assert.strictEqual(st('key', '/internal/url-registry/resolved'), 401, 'the retired X-Internal-Key opens nothing');
- assert.strictEqual(st('anonymous', '/internal/url-registry/resolved'), 401, 'and nobody without a service token gets in');
-
- // JWKS and OIDC discovery: the public key, never a private part.
- const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');
- assert.strictEqual(jwks.status, 200);
- assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);
- for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);
- // The configured key (decision 2, plan T2): the kid and modulus JWKS serves are the signing key's.
- const jwk = jwks.body.keys[0];
- assert.strictEqual(jwk.kty, 'RSA');
- assert.strictEqual(jwk.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');
- assert.strictEqual(jwk.n, crypto.createPublicKey(w.keys.publicKey).export({ format: 'jwk' }).n, 'the modulus is the configured key\'s');
- assert.ok(!/PRIVATE/.test(jwks.text));
-
- // ── Error paths: every write route, refused or malformed ─────────
- const writes = {};
- for (const m of ['post', 'put', 'patch', 'delete']) writes[m] = crawler.pathsFor(w.routes, values, { method: m });
- const found = [];
- let sent = 0;
- for (const [m, list] of Object.entries(writes)) {
- const M = m.toUpperCase();
- for (const [body, callers] of [
- ['{"broken": ', people], // malformed JSON, everyone
- ['{}', { anonymous: {}, app: people.app || {} }], // empty body without the right credential
- ]) {
- const r = await crawler.crawl(w.srv.base, list, callers, needlesFor, { method: M, body });
- sent += list.length * Object.keys(callers).length;
- found.push(...r.found);
- }
- }
- // Wrong credentials of every kind on the paths that take them.
- const wrong = {
- 'retired internal key': { 'x-internal-key': 'retired-key' },
- 'forged bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, 'guess', { issuer: w.issuer })}` },
- 'none alg bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, null, { algorithm: 'none' })}` },
- 'setup token guess': { 'x-setup-token': 'x', authorization: 'Bearer x' },
- };
- const wr = await crawler.crawl(w.srv.base, [...writes.post.filter((p) => /^\/(internal|oauth|api\/setup|api\/admin)/.test(p)), ...paths.filter((p) => /^\/(internal|api\/admin)/.test(p))], wrong, needlesFor, { method: 'GET' });
- found.push(...wr.found);
- for (const [grant, form] of [
- ['client_credentials', { client_id: 'live', client_secret: 'wrong' }],
- ['client_credentials', { client_id: dev.PA.clientId, client_secret: 'ovsec_wrong' }],
- ['authorization_code', { client_id: 'live', client_secret: w.clientSecrets.live, code: 'nope', redirect_uri: 'https://evil.test/' }],
- ['refresh_token', { client_id: 'live', client_secret: w.clientSecrets.live, refresh_token: 'f'.repeat(96) }],
- ]) {
- const r = await w.call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: grant, ...form }));
- assert.ok(r.status >= 400, `${grant} with wrong material is refused`);
- for (const l of crawler.leaks(r, needlesFor('anonymous'))) found.push(`oauth ${grant} refusal carries ${l.label}`);
- }
- out(`write/error crawl: ${sent} requests`);
- assert.deepStrictEqual(found, [], `secrets in error responses:\n${found.join('\n')}`);
-
- // ── Shown once, never cached ─────────────────────────────────────
- for (const s of w.shownOnce.filter((x) => x.status >= 200 && x.status < 300)) assert.match(s.cache, /no-store/, `${s.what}: Cache-Control no-store`);
- assert.ok(w.shownOnce.filter((x) => x.status >= 200 && x.status < 300).length >= 6, 'the shown-once responses were made');
- // The other answers that carry a token: refresh, an anonymous session, the anonymous identities of this address.
- for (const [who, m, p, body] of [['alice', 'POST', '/api/auth/refresh', {}], [null, 'POST', '/api/auth/anon-session', {}], [null, 'GET', '/api/auth/anon-identities'], [null, 'POST', '/api/auth/login', { username: 'alice', password: 'wrong' }]]) {
- const r = await w.call(who, m, p, body);
- assert.match(r.headers.get('cache-control') || '', /no-store/, `${m} ${p} (${r.status}): Cache-Control no-store`);
- }
-
- // ── Logs, outbox and audit tables ────────────────────────────────
- const all = { ...needlesFor('nobody'), 'verification key': w.verificationKey };
- delete all['verification key']; // stored in its own table by design; only its readers are checked above
- const logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);
- assert.deepStrictEqual(logHits, [], 'nothing secret was logged');
- const tables = (await w.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND (table_name LIKE '%outbox%' OR table_name LIKE '%audit%' OR table_name LIKE '%log%' OR table_name LIKE '%usage%' OR table_name LIKE '%changes%' OR table_name LIKE '%alerts%' OR table_name LIKE '%revisions%' OR table_name = 'notifications')").all()).map((r) => r.name);
- assert.ok(tables.includes('network_event_outbox') && tables.includes('dev_audit') && tables.includes('audit_log'), tables.join(','));
- const rowHits = [];
- for (const t of tables) {
- const text = JSON.stringify(await w.db.prepare(`SELECT * FROM "${t}"`).all());
- for (const l of crawler.leaks({ text, headers: {} }, all)) rowHits.push(`${t}: ${l.label}`);
- }
- assert.deepStrictEqual(rowHits, [], 'no secret in the outbox, audit or log tables');
- assert.ok((await w.db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get()).n > 0, 'the outbox was written meanwhile');
- } finally {
- await w.stop();
- }
-
- // ── Without key files: the ephemeral HS256 secret is never published (a child of its own, so its
- // database and process are not the seeded world's) ───
- const bare = await bootServer({ child: true });
- try {
- const r = await fetch(`${bare.base}/api/.well-known/jwks`);
- const body = await r.json();
- const k = body.public_key;
- if (k && !String(k).includes('BEGIN')) {
- // What the published "key" would let anyone do: sign a session for the first admin.
- const forged = jwt.sign({ sub: 1, id: 1, username: 'admin', role: 'admin' }, k, { algorithm: 'HS256', issuer: 'https://openvibe.network', expiresIn: '1h' });
- const me = await fetch(`${bare.base}/api/auth/me`, { headers: { authorization: `Bearer ${forged}` } });
- assert.notStrictEqual(me.status, 200, 'a session signed with the published HS256 secret is refused');
- }
- assert.ok(!k || String(k).includes('BEGIN PUBLIC KEY'), 'a keyless Network publishes no HS256 secret as its public key');
- } finally {
- await bare.stop();
- }
- out(`security secrets: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
-})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
+'use strict';
+// Shared fixtures and route expansion for the module-scoped security checks.
+const assert = require('assert');
+const { buildWorld } = require('./security-world');
+const crawler = require('./security-crawl');
+
+const out = (...a) => process.stdout.write(a.join(' ') + '\n');
+
+async function withWorld(label, check) {
+ const t0 = Date.now();
+ const w = await buildWorld({ label });
+ try {
+ await check(w);
+ out(`security ${label}: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
+ } finally {
+ await w.stop();
+ }
+}
+
+async function prepareClosedIncident(w) {
+ const inc = await w.call('staff', 'POST', '/api/v1/status/incidents', { kind: 'incident', title: 'long-closed-incident-title', severity: 'minor', state: 'investigating', services: ['network'], message: 'x' });
+ assert.strictEqual(inc.status, 201, inc.text);
+ const closed = await w.call('staff', 'POST', `/api/v1/status/incidents/${inc.body.id}/updates`, { state: 'resolved', message: 'fixed' });
+ assert.strictEqual(closed.status, 200, closed.text);
+ const longAgo = new Date(Date.now() - 40 * 864e5).toISOString();
+ await w.db.prepare('UPDATE status_incidents SET starts_at = ?, updated_at = ?, ends_at = ? WHERE id = ?').run(longAgo, longAgo, longAgo, inc.body.id);
+ await w.db.prepare('UPDATE status_incident_updates SET at = ? WHERE incident_id = ?').run(longAgo, inc.body.id);
+}
+
+function privateValues(w, name) {
+ const { users, dev } = w;
+ return ({
+ project: [dev.PA.id, 'prj_00000000000000000000000000'],
+ app: [dev.PA.app, 'app_00000000000000000000000000'],
+ credential: [dev.PA.credential, 'crd_x'],
+ capability: ['media.object.upload', 'x'],
+ subject: [users.alice.subject, 'usr_x'],
+ target: [users.alice.subject, users.carol.subject, 'x'],
+ type: ['user', 'channel'],
+ creator: [users.alice.subject, 'alice'],
+ id: [String(users.alice.id), String(users.carol.id), '1', 'x'],
+ userId: [String(users.alice.id), String(users.carol.id)],
+ username: ['alice', 'carol'],
+ name: ['alice', 'carol'],
+ ns: ['ai.preferences', 'chat.tts_defaults', 'x'],
+ namespace: ['general', 'x'],
+ token: ['x'],
+ idOrSlug: ['default', 'x'],
+ serviceId: ['live', 'x'],
+ domain: ['openvibe.live'],
+ topic: ['live.stream.started'],
+ category: ['system'],
+ key: ['OV_LIVE_URL'],
+ })[name] || ['1'];
+}
+
+function privateArea(path) {
+ if (path.startsWith('/api/v1/projects')) return 'projects';
+ if (path.match(/^\/api\/(auth|notifications|history|modules|coins|push|v1\/me|v1\/follows|v1\/status)(\/|\?|$)/)) return 'personal';
+ return 'platform';
+}
+
+async function privateCrawl(w, area) {
+ const { users, dev } = w;
+ await prepareClosedIncident(w);
+ const alice = {
+ 'alice email': users.alice.email,
+ 'alice module data': 'alice-private-perspective',
+ 'alice notification': 'alice-private-notification',
+ 'alice session device': 'alice-laptop',
+ 'alice linked account': 'alice-games-771',
+ 'alice history': 'alice-private-history',
+ 'alice push endpoint': w.pushSub.endpoint,
+ 'alice project': 'alice-private-project',
+ 'alice app': 'alice-private-app',
+ 'bob project': 'bob-private-project',
+ 'bob app': 'bob-private-app',
+ 'long-closed incident': 'long-closed-incident-title',
+ 'carol email': users.carol.email,
+ 'staff email': users.staff.email,
+ };
+ const allPaths = crawler.pathsFor(w.routes, (name) => privateValues(w, name), { method: 'get', query: 'all=1&limit=100&user_id=' + users.alice.id + '&subject=' + users.alice.subject, extra: ['/api/v1/projects?all=1', '/api/v1/status/incidents'] });
+ const paths = allPaths.filter((p) => privateArea(p) === area);
+ const people = { anonymous: w.callers.anonymous, user: w.callers.user, app: w.callers.app, service: w.callers.service };
+ assert.ok(people.app && people.app.authorization, 'a developer app token to crawl with');
+ const needlesFor = (who) => {
+ const n = { ...alice };
+ if (who === 'user') { delete n['bob project']; delete n['bob app']; }
+ return n;
+ };
+ const res = await crawler.crawl(w.srv.base, paths, people, needlesFor);
+ out(`private ${area} crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(res.statuses)}`);
+ assert.deepStrictEqual(res.found, [], `private data reached the wrong caller:\n${res.found.join('\n')}`);
+ assert.ok(res.answered === paths.length * Object.keys(people).length && (res.statuses['2xx'] || 0) > (area === 'platform' ? 200 : 0));
+ return { res, people };
+}
+
+async function privateMine(w, who, p, needle) {
+ const r = await w.call(who, 'GET', p);
+ assert.ok(r.text.includes(needle), `${who} sees ${needle} at ${p} (${r.status})`);
+}
+
+function checkPrivateStatuses(w, res) {
+ const st = res.byPath;
+ const bad = [];
+ for (const [k, s] of Object.entries(st)) {
+ const [who, , p] = k.split(' ');
+ const path = p.split('?')[0];
+ if (who === 'service') continue;
+ if (/^\/internal\//.test(path) && s < 400) bad.push(`${k} → ${s}`);
+ if (/^\/api\/(admin|v1\/staff)\//.test(path) && path !== '/api/v1/staff/capabilities' && s < 400) bad.push(`${k} → ${s}`);
+ if (path.startsWith(`/api/v1/projects/${w.dev.PA.id}`)) {
+ const want = who === 'anonymous' || who === 'app' ? [401] : [404];
+ if (!want.includes(s)) bad.push(`${k} → ${s} (want ${want})`);
+ }
+ if (/^\/api\/(notifications|history|modules|coins\/me|push\/status|auth\/sessions|auth\/me|v1\/me\/)/.test(path) && !/^\/api\/modules\/[^/]+\/public\//.test(path) && who !== 'user' && s < 400) bad.push(`${k} → ${s}`);
+ }
+ assert.deepStrictEqual(bad, [], `private reads answered:\n${bad.join('\n')}`);
+}
+
+function secretsValues(w, name) {
+ const { users, dev } = w;
+ return ({
+ project: [dev.PA.id, dev.PB.id, 'prj_00000000000000000000000000', '..%2f'],
+ app: [dev.PA.app, dev.PB.app, 'app_00000000000000000000000000', 'x'],
+ credential: [dev.PA.credential, dev.PB.credential, 'crd_x'],
+ capability: ['media.object.upload', 'identity.subject.resolve', 'x'],
+ subject: [users.alice.subject, users.bob.subject, 'usr_x'],
+ target: [users.alice.subject, users.bob.subject, 'x'],
+ type: ['user', 'channel', 'x'],
+ creator: [users.alice.subject, 'alice', 'x'],
+ id: [String(users.alice.id), String(users.owner.id), '1', '999999', 'x', '-1'],
+ userId: [String(users.alice.id), String(users.bob.id), '999999'],
+ username: ['alice', 'rootowner', 'nobody'],
+ name: ['alice', 'live', 'network', 'nobody'],
+ ns: ['ai.preferences', 'chat.tts_defaults', 'live.profile', 'x'],
+ namespace: ['general', 'network', 'x'],
+ key: ['DEPLOY_CLOUDFLARE_TOKEN', 'OV_LIVE_URL', 'x'],
+ token: ['not-a-token', 'x'],
+ idOrSlug: ['default', 'x'],
+ serviceId: ['live', 'network', 'x'],
+ domain: ['openvibe.live', 'x'],
+ topic: ['live.stream.started', 'x'],
+ category: ['system', 'x'],
+ })[name] || ['1', 'x'];
+}
+
+function secretsPeople(w) { return { ...w.callers, key: { 'x-internal-key': 'retired-key' } }; }
+
+function secretsNeedlesFor(w, people) {
+ const tokenOf = (h) => String((h && h.authorization) || '').replace(/^Bearer /, '');
+ return (who) => {
+ const n = { ...w.secrets };
+ for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s bearer token`] = tokenOf(h);
+ if (who !== 'admin' && who !== 'owner') n['verification key'] = w.verificationKey;
+ return n;
+ };
+}
+
+function secretsPaths(w) {
+ const extra = ['/api/nope', '/internal/nope', '/INTERNAL/users/1', '/.env', '/.git/config', '/package.json', '/server/config.js',
+ '/data/network.db', '/data/keys/private.pem', '/keys/private.pem', '/private.pem', '/network.db', '/data/avatars/..%2fnetwork.db',
+ '/data/avatars/%2e%2e/network.db', '/shared/..%2f..%2fpackage.json', '/node_modules/.package-lock.json', '/api/.well-known/jwks',
+ '/.well-known/openid-configuration', '/oauth/.well-known/openid-configuration', '/api/ready', '/metrics', '/release.json', '/status',
+ '/api/v1/status', '/api/admin/secrets', '/api/admin/settings', '/api/admin/config', '/api/admin/url-registry', '/api/admin/email',
+ '/api/admin/discord/', '/api/admin/integrations/github/', '/api/admin/deploy/config', '/internal/url-registry/resolved',
+ '/internal/integrations/github-token', '/api/setup/status', '/oauth/authorize?client_id=live&redirect_uri=https%3A%2F%2Fevil.test%2F&response_type=code',
+ '/oauth/client-info?client_id=live', `/oauth/client-info?client_id=${w.dev.PA.app}`, '/api/auth/anon-identities', '/sso/check', '/fedcm/accounts'];
+ return crawler.pathsFor(w.routes, (name) => secretsValues(w, name), { method: 'get', query: 'limit=5&all=1&debug=1&include=secret', extra });
+}
+
+async function checkSecretsStored(w, needlesFor) {
+ const all = { ...needlesFor('nobody'), 'verification key': w.verificationKey };
+ delete all['verification key']; // stored in its own table by design; only its readers are checked above
+ const logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);
+ assert.deepStrictEqual(logHits, [], 'nothing secret was logged');
+ const tables = (await w.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND (table_name LIKE '%outbox%' OR table_name LIKE '%audit%' OR table_name LIKE '%log%' OR table_name LIKE '%usage%' OR table_name LIKE '%changes%' OR table_name LIKE '%alerts%' OR table_name LIKE '%revisions%' OR table_name = 'notifications')").all()).map((r) => r.name);
+ assert.ok(tables.includes('network_event_outbox') && tables.includes('dev_audit') && tables.includes('audit_log'), tables.join(','));
+ const rowHits = [];
+ for (const t of tables) {
+ const text = JSON.stringify(await w.db.prepare(`SELECT * FROM "${t}"`).all());
+ for (const l of crawler.leaks({ text, headers: {} }, all)) rowHits.push(`${t}: ${l.label}`);
+ }
+ assert.deepStrictEqual(rowHits, [], 'no secret in the outbox, audit or log tables');
+ assert.ok((await w.db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get()).n > 0, 'the outbox was written meanwhile');
+}
+
+module.exports = { withWorld, out, privateCrawl, privateMine, checkPrivateStatuses, secretsValues, secretsPeople, secretsNeedlesFor, secretsPaths, checkSecretsStored };