openvibers/openvibe.network Commits

Private-bypass and IDOR suites (roadmap WS-R task 5). test/security-private.test.js crawls every GET route Express knows after boot as anonymous, another user, a developer app and a service principal, and no answer may carry alice's private data (email, module data, notification, session device, linked account, history, push endpoint), a project's names to a non-member, or an incident closed 40 days ago; by status, /internal/* never answers 2xx without the key or a capability token, staff pages never answer non-staff, a non-member gets 404 on every read of a project (existence not disclosed) and anonymous 401, personal lists refuse anonymous and app tokens; blocks and follows lists stay their owner's, public module reads carry only public fields; positive controls show each owner does see the data. test/security-idor.test.js swaps ids between two people, two projects and two apps: bob against every write route of alice's project (rename, archive, allowance, environment policy, members, apps, credentials, grants, quotas, export tokens) and alice's ids under his own project's paths, against alice's notifications, session, history, profile, modules, follows, blocks and push subscription, and service principals against another app's notifications, coins, linked accounts, modules and legacy ids. Each refusal leaves the rows as they were and carries no secret; positive controls show each route works for its owner. All held. The shared fixture gives the seeded data distinctive markers and follows channels.

OP OpenVibers committed 26 Sept 2026 · fd866c0499 · 4 files, +347 −5


Changes

ddiff --git a/package.json b/package.json
iindex 1993d7cd..f22abcb0 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
nnew file mode 100644
iindex 00000000..e4c992ce
---- /dev/null
++++ b/test/security-idor.test.js
@@@ -0,0 +1,182 @@
++'use strict';
++// IDOR (roadmap WS-R task 5): ids swapped between two people, two developer projects and two apps,
++// and service principals acting outside their own app. With the seeded world of
++// test/security-world.js (alice owns project PA with app AA, bob owns PB with AB):
++// - bob tries every write route of alice's project with its ids: rename, archive, allowance,
++// environment policy, members (add himself as owner, demote or remove alice), apps (create, edit,
++// revoke), credentials (rotate, revoke), grants (request, approve, deny, revoke), quotas, export
++// tokens; and the same ids under his own project's paths (PB/apps/AA, PB/apps/AB/credentials/<AA's>)
++// - bob tries alice's notifications (read, dismiss, batch), session (revoke), history entry, profile
++// (ids in the body), modules, follows, blocks and push subscription
++// - a service principal pushes a notification, credits coins, links an account, writes a module and
++// maps legacy ids for another app or system
++// Every refusal leaves the database as it was (the rows are compared before and after), carries no
++// token or secret, and positive controls show each route works for its owner.
++// node test/security-idor.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: 'idor' });
++ try {
++ const { users, dev, db } = w;
++ const PA = dev.PA.id; const AA = dev.PA.app; const PB = dev.PB.id; const AB = dev.PB.app;
++ const DEV_TABLES = ['dev_projects', 'dev_project_members', 'dev_apps', 'dev_credentials', 'dev_grants', 'dev_quotas'];
++ const snap = (tables, where = {}) => JSON.stringify(tables.map((t) => db.prepare(`SELECT * FROM "${t}" ${where[t] || ''}`).all()));
++ const secretNeedles = { ...w.secrets };
++ const refused = [];
++ const expectRefused = async (who, method, p, body, { tables, where, codes = [401, 403, 404] } = {}) => {
++ const before = tables ? snap(tables, where) : null;
++ const r = await w.call(who, method, p, body);
++ if (!codes.includes(r.status)) refused.push(`${method} ${p} as ${typeof who === 'string' ? who : 'token'} → ${r.status} ${r.text.slice(0, 160)}`);
++ if (tables && snap(tables, where) !== before) refused.push(`${method} ${p}: the database changed on a refusal`);
++ for (const l of crawler.leaks(r, secretNeedles)) refused.push(`${method} ${p}: the refusal carries ${l.label}`);
++ if (/"(client_secret|access_token|refresh_token)"/.test(r.text)) refused.push(`${method} ${p}: the refusal carries a token field`);
++ return r;
++ };
++ const ok = async (who, method, p, body, status = [200, 201, 204]) => {
++ const r = await w.call(who, method, p, body);
++ assert.ok([].concat(status).includes(r.status), `positive control ${method} ${p}: ${r.status} ${r.text.slice(0, 200)}`);
++ return r;
++ };
++
++ // ── Developer projects: bob against alice's ids ──────────────────
++ const devWrites = [
++ ['PATCH', `/api/v1/projects/${PA}`, { name: 'pwned' }],
++ ['POST', `/api/v1/projects/${PA}/archive`, {}],
++ ['PUT', `/api/v1/projects/${PA}/allowance`, { capabilities: ['network.coins.credit'] }],
++ ['PUT', `/api/v1/projects/${PA}/environment-policy`, { policy: 'sandbox+production' }],
++ ['POST', `/api/v1/projects/${PA}/members`, { username: 'bob', role: 'owner' }],
++ ['PATCH', `/api/v1/projects/${PA}/members/${users.alice.subject}`, { role: 'viewer' }],
++ ['DELETE', `/api/v1/projects/${PA}/members/${users.alice.subject}`],
++ ['POST', `/api/v1/projects/${PA}/apps`, { name: 'bob-in-alice', environment: 'sandbox', type: 'confidential' }],
++ ['PATCH', `/api/v1/projects/${PA}/apps/${AA}`, { name: 'pwned', redirect_uris: ['https://evil.test/cb'] }],
++ ['DELETE', `/api/v1/projects/${PA}/apps/${AA}`],
++ ['POST', `/api/v1/projects/${PA}/apps/${AA}/credentials/rotate`, {}],
++ ['POST', `/api/v1/projects/${PA}/apps/${AA}/credentials/${dev.PA.credential}/revoke`, {}],
++ ['POST', `/api/v1/projects/${PA}/apps/${AA}/grants`, { capability: 'identity.subject.resolve' }],
++ ['POST', `/api/v1/projects/${PA}/apps/${AA}/grants/media.object.upload/approve`, {}],
++ ['POST', `/api/v1/projects/${PA}/apps/${AA}/grants/media.object.upload/deny`, {}],
++ ['DELETE', `/api/v1/projects/${PA}/apps/${AA}/grants/media.object.upload`],
++ ['PUT', `/api/v1/projects/${PA}/quotas/media.object.upload`, { limit: 1, window: 'day' }],
++ ['DELETE', `/api/v1/projects/${PA}/quotas/media.object.upload`],
++ ['POST', `/api/v1/projects/${PA}/export-tokens`, { audience: 'openvibe.media', env: 'sandbox' }],
++ // alice's ids under bob's own project
++ ['GET', `/api/v1/projects/${PB}/apps/${AA}`],
++ ['GET', `/api/v1/projects/${PB}/apps/${AA}/credentials`],
++ ['PATCH', `/api/v1/projects/${PB}/apps/${AA}`, { name: 'pwned' }],
++ ['DELETE', `/api/v1/projects/${PB}/apps/${AA}`],
++ ['POST', `/api/v1/projects/${PB}/apps/${AA}/credentials/rotate`, {}],
++ ['POST', `/api/v1/projects/${PB}/apps/${AB}/credentials/${dev.PA.credential}/revoke`, {}],
++ ['POST', `/api/v1/projects/${PB}/apps/${AA}/grants`, { capability: 'identity.subject.resolve' }],
++ ['DELETE', `/api/v1/projects/${PB}/apps/${AA}/grants/media.object.upload`],
++ ['PATCH', `/api/v1/projects/${PB}/members/${users.alice.subject}`, { role: 'viewer' }],
++ ['DELETE', `/api/v1/projects/${PB}/members/${users.alice.subject}`],
++ ];
++ for (const [m, p, body] of devWrites) await expectRefused('bob', m, p, body, { tables: DEV_TABLES });
++ // Reads of alice's project under either path: 404 for bob.
++ for (const p of [`/api/v1/projects/${PA}`, `/api/v1/projects/${PA}/members`, `/api/v1/projects/${PA}/apps`, `/api/v1/projects/${PA}/apps/${AA}`, `/api/v1/projects/${PA}/apps/${AA}/credentials`,
++ `/api/v1/projects/${PA}/apps/${AA}/grants`, `/api/v1/projects/${PA}/quotas`, `/api/v1/projects/${PA}/usage`, `/api/v1/projects/${PA}/audit`]) {
++ await expectRefused('bob', 'GET', p, undefined, { codes: [404] });
++ }
++ // A token for bob's app cannot be had with alice's credential, nor alice's with bob's.
++ for (const [cid, sec] of [[AB, dev.PA.secret2], [AA, dev.PB.secret]]) {
++ const r = await w.call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: 'client_credentials', client_id: cid, client_secret: sec, audience: 'openvibe.media' }));
++ assert.strictEqual(r.status, 401, 'one app\'s secret is not another\'s');
++ }
++ // Rotation overlap: both of alice's secrets work for her app (the old one until it expires).
++ for (const sec of [dev.PA.secret, dev.PA.secret2]) {
++ const r = await w.call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: 'client_credentials', client_id: AA, client_secret: sec, audience: 'openvibe.media' }));
++ assert.strictEqual(r.status, 200, 'both overlapping secrets work');
++ }
++
++ // Positive controls: the same routes work for their owner (and staff where staff decides).
++ await ok('alice', 'PATCH', `/api/v1/projects/${PA}`, { name: 'alice-private-project-renamed' });
++ await ok('alice', 'POST', `/api/v1/projects/${PA}/members`, { username: 'carol', role: 'viewer' });
++ await ok('alice', 'PATCH', `/api/v1/projects/${PA}/members/${users.carol.subject}`, { role: 'developer' });
++ await ok('alice', 'PATCH', `/api/v1/projects/${PA}/apps/${AA}`, { name: 'alice-private-app-renamed' });
++ await ok('alice', 'DELETE', `/api/v1/projects/${PA}/apps/${AA}/grants/media.object.upload`);
++ await ok('alice', 'POST', `/api/v1/projects/${PA}/apps/${AA}/grants`, { capability: 'media.object.upload' });
++ await ok('alice', 'POST', `/api/v1/projects/${PA}/export-tokens`, { audience: 'openvibe.media', env: 'sandbox' });
++ await ok('staff', 'PUT', `/api/v1/projects/${PA}/quotas/media.object.upload`, { limit: 5, window: 'day' }, [200, 201, 400]);
++ await ok('alice', 'POST', `/api/v1/projects/${PA}/apps/${AA}/credentials/${dev.PA.credential}/revoke`, {});
++ await ok('bob', 'POST', `/api/v1/projects/${PB}/apps/${AB}/credentials/rotate`, {}, 201);
++ await ok('alice', 'DELETE', `/api/v1/projects/${PA}/members/${users.carol.subject}`);
++ assert.strictEqual(db.prepare('SELECT name FROM dev_projects WHERE id = ?').get(PA).name, 'alice-private-project-renamed');
++ assert.ok(db.prepare('SELECT revoked_at FROM dev_credentials WHERE id = ?').get(dev.PA.credential).revoked_at, 'alice revoked her own credential');
++
++ // ── Personal data: bob against alice's ids ───────────────────────
++ const notif = db.prepare('SELECT id FROM notifications WHERE user_id = ? ORDER BY created_at DESC').get(users.alice.id);
++ const session = db.prepare('SELECT id FROM user_sessions WHERE user_id = ? AND is_active = 1').get(users.alice.id);
++ const hist = db.prepare('SELECT id FROM user_history WHERE user_id = ?').get(users.alice.id);
++ assert.ok(notif && session && hist, 'alice has a notification, a session and a history entry');
++ const ALICE = {
++ tables: ['notifications', 'user_sessions', 'user_history', 'users', 'user_modules', 'user_follows', 'user_blocks', 'push_subscriptions', 'linked_accounts'],
++ where: {
++ notifications: `WHERE user_id = ${users.alice.id}`, user_sessions: `WHERE user_id = ${users.alice.id}`, user_history: `WHERE user_id = ${users.alice.id}`,
++ users: `WHERE id = ${users.alice.id}`, user_modules: `WHERE subject_id = '${users.alice.subject}'`, user_follows: `WHERE follower_subject = '${users.alice.subject}'`,
++ user_blocks: `WHERE blocker_subject = '${users.alice.subject}'`, push_subscriptions: `WHERE user_id = ${users.alice.id}`, linked_accounts: `WHERE user_id = ${users.alice.id}`,
++ },
++ };
++ // Column names differ between modules; fall back to the whole table when a guess is wrong.
++ for (const t of ALICE.tables) { try { db.prepare(`SELECT * FROM "${t}" ${ALICE.where[t]}`).all(); } catch { ALICE.where[t] = ''; } }
++ const aliceBefore = snap(ALICE.tables, ALICE.where);
++ const personal = [
++ ['POST', `/api/notifications/${notif.id}/read`, {}],
++ ['POST', `/api/notifications/${notif.id}/dismiss`, {}],
++ ['POST', '/api/notifications/read-batch', { ids: [notif.id] }],
++ ['DELETE', `/api/auth/sessions/${session.id}`],
++ ['DELETE', `/api/history/${hist.id}`],
++ ['PUT', '/api/auth/profile', { id: users.alice.id, user_id: users.alice.id, username: 'alice', bio: 'bob-was-here' }],
++ ['PUT', `/api/modules/ai.preferences?subject=${users.alice.subject}`, { subject: users.alice.subject, subject_id: users.alice.subject, data: { style: 'formal' } }],
++ ['DELETE', `/api/modules/ai.preferences?subject=${users.alice.subject}`],
++ ['DELETE', `/api/v1/me/follows/channel/${users.bob.subject}?follower=${users.alice.subject}`],
++ ['DELETE', `/api/v1/me/blocks/${users.carol.subject}?blocker=${users.alice.subject}`],
++ ['POST', '/api/push/unsubscribe', { endpoint: w.pushSub.endpoint, user_id: users.alice.id }],
++ ['DELETE', `/api/notifications?user_id=${users.alice.id}`],
++ ['DELETE', `/api/history?user_id=${users.alice.id}`],
++ ];
++ for (const [m, p, body] of personal) {
++ const r = await w.call('bob', m, p, body, m === 'PUT' && p.startsWith('/api/modules') ? { 'if-match': '0' } : {});
++ for (const l of crawler.leaks(r, secretNeedles)) refused.push(`${m} ${p}: carries ${l.label}`);
++ if (snap(ALICE.tables, ALICE.where) !== aliceBefore) { refused.push(`${m} ${p} as bob changed alice's data (${r.status})`); break; }
++ }
++ // Positive controls: alice can do each of these to her own data.
++ await ok('alice', 'POST', `/api/notifications/${notif.id}/read`, {});
++ assert.strictEqual(db.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id).is_read, 1);
++ await ok('alice', 'DELETE', `/api/history/${hist.id}`);
++ assert.ok(!db.prepare('SELECT id FROM user_history WHERE id = ?').get(hist.id));
++ await ok('alice', 'DELETE', `/api/auth/sessions/${session.id}`);
++ assert.strictEqual(db.prepare('SELECT is_active FROM user_sessions WHERE id = ?').get(session.id).is_active, 0);
++ assert.strictEqual(db.prepare('SELECT bio FROM users WHERE id = ?').get(users.bob.id).bio, 'bob-was-here', 'the ids in the body were ignored: bob edited his own profile');
++
++ // ── Service principals outside their own app ───────────────────
++ const live = { authorization: `Bearer ${w.liveToken}` };
++ const media = { authorization: `Bearer ${await w.serviceToken('media')}` };
++ const credit = (app) => ({ user_id: users.alice.id, app_id: app, amount: 5, reason: 'idor test', idempotency_key: `k-${app}-${Date.now()}` });
++ const svcTables = { tables: ['notifications', 'wallets', 'coin_transactions', 'linked_accounts', 'user_modules', 'identity_legacy_map'] };
++ await expectRefused(live, 'POST', '/internal/notifications/push', { user_id: users.alice.id, type: 'system', title: 'spoofed', message: 'x', service: 'games' }, svcTables);
++ await expectRefused(live, 'POST', '/internal/coins/credit', credit('games'), svcTables);
++ await expectRefused(live, 'POST', '/internal/link-account', { user_id: users.alice.id, service: 'games', service_user_id: 'spoof-1' }, svcTables);
++ await expectRefused(live, 'PUT', `/internal/modules/ai.preferences/${users.alice.subject}`, { data: { style: 'formal' } }, svcTables);
++ await expectRefused(media, 'POST', '/internal/identity/legacy-map', { entries: [{ network_user_id: users.alice.id, source_system: 'live', source_type: 'user', source_id: '4242' }] }, svcTables);
++ await expectRefused(media, 'POST', '/internal/coins/credit', credit('media'), svcTables); // no network.coins.credit
++ // A developer app token is no service principal.
++ await expectRefused({ authorization: `Bearer ${w.appToken}` }, 'POST', '/internal/notifications/push', { user_id: users.alice.id, title: 'x', message: 'x', service: 'live' }, svcTables);
++ await expectRefused({ authorization: `Bearer ${w.appToken}` }, 'GET', `/internal/identity/resolve?user_id=${users.alice.id}`);
++ // Positive controls: in their own name they may.
++ await ok(live, 'POST', '/internal/notifications/push', { user_id: users.alice.id, type: 'system', category: 'system', title: 'fine', message: 'x', service: 'live' });
++ await ok(live, 'POST', '/internal/coins/credit', credit('live'));
++ await ok(live, 'POST', '/internal/link-account', { user_id: users.alice.id, service: 'live', service_user_id: 'alice-live-1' });
++ await ok(live, 'PUT', `/internal/modules/live.profile/${users.alice.subject}`, { data: { followers: 3, is_streamer: false } });
++
++ assert.deepStrictEqual(refused, [], `IDOR:\n${refused.join('\n')}`);
++ out(`security idor: ${devWrites.length + personal.length + 8} swapped-id attempts refused, all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
++ } finally {
++ await w.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
nnew file mode 100644
iindex 00000000..ea88e5a8
---- /dev/null
++++ b/test/security-private.test.js
@@@ -0,0 +1,158 @@
++'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();
++ db.prepare('UPDATE status_incidents SET starts_at = ?, updated_at = ?, ends_at = ? WHERE id = ?').run(longAgo, longAgo, longAgo, inc.body.id);
++ 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');
++ await mine({ 'x-internal-key': w.sentinels.INTERNAL_API_KEY }, `/internal/users/${users.alice.id}/linked-accounts`, 'alice-games-771');
++ 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.
++ for (const p of ['/internal/stats', `/internal/users/${users.alice.id}`, `/internal/users/${users.alice.id}/linked-accounts`, '/internal/anon-list', '/internal/integrations/github-token', `/internal/notifications/unread/${users.alice.id}`]) {
++ assert.strictEqual(st[`service GET ${p}`], 403, `${p} needs the legacy key or its own capability`);
++ }
++
++ // 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();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-world.js b/test/security-world.js
iindex bfff2223..cb677a9d 100644
---- a/test/security-world.js
++++ b/test/security-world.js
@@@ -149,8 +149,8 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
const must = (r, status, what) => { if (r.status !== status) throw new Error(`${what}: ${r.status} ${r.text.slice(0, 300)}`); return r; };
const dev = {};
for (const [owner, name] of [['alice', 'PA'], ['bob', 'PB']]) {
-- const p = must(await call(owner, 'POST', '/api/v1/projects', { name: `${owner} project` }), 201, `${owner} project`).body;
-- const a = once(`app creation ${name}`, must(await call(owner, 'POST', `/api/v1/projects/${p.id}/apps`, { name: `${owner} app`, environment: 'sandbox', type: 'confidential' }), 201, `${owner} app`));
++ const p = must(await call(owner, 'POST', '/api/v1/projects', { name: `${owner}-private-project` }), 201, `${owner} project`).body;
++ const a = once(`app creation ${name}`, must(await call(owner, 'POST', `/api/v1/projects/${p.id}/apps`, { name: `${owner}-private-app`, environment: 'sandbox', type: 'confidential' }), 201, `${owner} app`));
dev[name] = { id: p.id, app: a.body.id, clientId: a.body.oauth_client_id || a.body.client_id || a.body.id, secret: a.body.credential.client_secret, credential: a.body.credential.id, owner };
secrets[`dev credential ${name}`] = dev[name].secret;
}
@@@ -168,10 +168,10 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
const appToken = appTok.status === 200 ? appTok.body.access_token : null;
// ── Per-person data ──────────────────────────────────────────────
-- await call('alice', 'PUT', '/api/modules/ai.preferences', { data: { style: 'casual', history: false } }, { 'if-match': '0' });
++ await call('alice', 'PUT', '/api/modules/ai.preferences', { data: { style: 'casual', perspective: 'alice-private-perspective', history: false } }, { 'if-match': '0' });
await call('alice', 'PUT', '/api/modules/chat.tts_defaults', { data: { send: true, volume: 37 } }, { 'if-match': '0' });
await call('alice', 'PUT', `/api/v1/me/blocks/${users.carol.subject}`, {});
-- await call('alice', 'PUT', `/api/v1/me/follows/user/${users.bob.subject}`, {});
++ await call('alice', 'PUT', `/api/v1/me/follows/channel/${users.bob.subject}`, {});
await call({ authorization: `Bearer ${liveToken}` }, 'POST', '/internal/notifications/push', { user_id: users.alice.id, type: 'system', category: 'system', title: 'alice-private-notification', message: 'for alice only', service: 'live' });
const sess = once('session creation', await call('alice', 'POST', '/api/auth/sessions', { device_name: 'alice-laptop' }));
if (sess.body && sess.body.session_token) secrets['alice session token'] = sess.body.session_token;
@@@ -216,6 +216,8 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
return {
dir: srv.dir, srv, db, keys, vapid, issuer: ISSUER, routes, sentinels, secrets, users, dev, callers, clientSecrets,
liveToken, appToken, anonToken, verificationKey: vk, pushSub, shownOnce, call, serviceToken, sign, S,
++ // Every TCP connection the server made so far, as 'host port' lines (the preload logs them).
++ connects: () => { try { return fs.readFileSync(dump.OV_CONNECT_LOG, 'utf8').split('\n').filter(Boolean); } catch { return []; } },
stop: async () => { try { db.close(); } catch { /* */ } await srv.stop(); try { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(srv.dir, { recursive: true, force: true }); } catch { /* */ } },
};
}