openvibers/openvibe.codes Commits
Security regression suites for the four classes that apply to Codes (roadmap WS-R task 5). test/security-crawl.js lists every route of the booted app from Express's router stack (a route added later is crawled without anyone listing it) and GETs each path as several people, each request from a fresh client address.
Changes
nnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++/**++ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted++ * Wiki app (walking Express's router stack, so a route added later is crawled without anyone++ * listing it), fills route parameters with seeded values, and GETs each path as several people,++ * reporting any response whose body or headers carry a value that person must never see.++ * Not a test itself (no .test.js); used with test/helpers/boot.js.++ */++++/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */++function mountPath(layer) {++ if (!layer.regexp || layer.regexp.fast_slash) return '';++ let src = layer.regexp.source.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\/\?\(\?=\/\|\$\)$/i, '');++ let i = 0;++ src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => `:${(layer.keys[i++] || {}).name || 'param'}`);++ src = src.replace(/\\\//g, '/').replace(/\\\./g, '.').replace(/\\-/g, '-');++ return /[\\^$()|[\]*+?]/.test(src) ? null : src;++}++++/** Every route of the booted Codes app (captured from createApp): [{ path, methods }]. */++function listRoutes(app) {++ if (!app || !app._router) throw new Error('no Express app');++ const out = [];++ const walk = (stack, prefix) => {++ for (const layer of stack) {++ if (layer.route) {++ const methods = Object.keys(layer.route.methods).filter((m) => layer.route.methods[m]);++ for (const p of [].concat(layer.route.path)) if (typeof p === 'string') out.push({ path: prefix + p, methods });++ } else if (layer.handle && Array.isArray(layer.handle.stack)) {++ const mp = mountPath(layer);++ if (mp !== null) walk(layer.handle.stack, prefix + mp);++ } else {++ const mp = mountPath(layer);++ if (mp) out.push({ path: prefix + mp, methods: ['_all'] });++ }++ }++ };++ walk(app._router.stack, '');++ const seen = new Set();++ return out.filter((r) => { const k = `${r.methods.join(',')} ${r.path}`; if (seen.has(k)) return false; seen.add(k); return true; });++}++++/** Concrete paths for a template: candidate i of every parameter, for each i (no cross product). */++function expand(template, values) {++ const names = [];++ const t = `/${template.replace(/^\/+/, '')}`.replace(/\*/g, 'x').replace(/:([A-Za-z0-9_]+)\??(\([^)]*\))?/g, (m, n) => { names.push(n); return `:${n}`; });++ if (!names.length) return [t];++ const lists = names.map((n) => values(n));++ const width = Math.max(...lists.map((l) => l.length));++ const paths = new Set();++ for (let i = 0; i < width; i++) {++ let j = 0;++ paths.add(t.replace(/:([A-Za-z0-9_]+)/g, () => { const l = lists[j++]; return encodeURIComponent(String(l[Math.min(i, l.length - 1)])); }));++ }++ return [...paths];++}++++/** Every GET path: each GET route expanded, also with `query` appended, plus `extra`. */++function getPaths(app, values, { query = '', extra = [] } = {}) {++ const paths = new Set();++ for (const r of listRoutes(app)) {++ if (!r.methods.includes('get') && !r.methods.includes('_all')) continue;++ for (const p of expand(r.path, values)) {++ paths.add(p);++ if (query && !p.includes('?')) paths.add(`${p}?${query}`);++ }++ }++ for (const p of extra) paths.add(p);++ return [...paths];++}++++/** Which of `needles` ({ label: value }) a response carries: [{ label, where }]. */++function leaks(res, needles) {++ const found = [];++ const headerText = [...(res.headers && typeof res.headers.entries === 'function' ? res.headers.entries() : Object.entries(res.headers || {}))].map(([k, v]) => `${k}: ${v}`).join('\n');++ for (const [label, value] of Object.entries(needles)) {++ if (!value) continue;++ if (res.text && res.text.includes(value)) found.push({ label, where: 'body' });++ if (headerText.includes(value)) found.push({ label, where: 'headers' });++ }++ return found;++}++++/** A fresh client address per request (TRUST_PROXY is on in tests): the per-address rate limits are not what is being read. */++let seq = 0;++function nextAddress() { seq++; return `10.${(seq >> 16) & 255}.${(seq >> 8) & 255}.${seq & 255}`; }++++/**++ * GET every path as every person ({ who: user | token | null }) through t.get (a user object signs++ * in with the session cookie, a string is a bearer token). `needlesFor(who)` names what that person++ * must never see. Resolves { found, answered, statuses }.++ */++async function crawl(t, paths, people, needlesFor, { concurrency = 6 } = {}) {++ const found = [];++ const statuses = {};++ let answered = 0;++ for (const [who, as] of Object.entries(people)) {++ const needles = needlesFor(who);++ const queue = [...paths];++ await Promise.all(Array.from({ length: concurrency }, async () => {++ while (queue.length) {++ const p = queue.shift();++ let r;++ try { r = await t.get(p, { ...(as ? { as } : {}), headers: { 'x-forwarded-for': nextAddress() } }); } catch (e) { r = { status: 0, text: '', headers: {} }; }++ if (r.status) answered++;++ const cls = r.status ? `${String(r.status)[0]}xx` : 'none';++ statuses[cls] = (statuses[cls] || 0) + 1;++ for (const l of leaks(r, needles)) found.push(`${who}: GET ${p} → ${r.status} carries ${l.label} in its ${l.where}`);++ }++ }));++ }++ return { found, answered, statuses };++}++++module.exports = { listRoutes, expand, getPaths, leaks, crawl, nextAddress };nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++/**++ * One developer cannot act on another's projects, apps, credentials or releases by swapping ids++ * (roadmap WS-R task 5, the IDOR class). Project data lives at OpenVibe.Network (Codes asks with the++ * person's own token, so Network's rules apply); releases, trust tiers and playground runs are++ * Codes' own. Ann and Bob each have a project with an app; Ann has a draft release. Bob tries every++ * page and form that takes an id with Ann's instead (her project's pages, apps, members, credentials,++ * grants, redirects, playground, archive, export, delete), and the nested swap: his own project in the++ * path with Ann's app or credential id. Then Ann's releases: the draft's page and API, and publish,++ * deprecate, revoke. Every refusal leaves Network's projects, apps and credentials and every table of++ * Codes as they were, and shows nothing of Ann's (her app's name, its secret, the draft's notes);++ * controls show the routes work for their owner.++ *++ * node test/security-idor.test.js++ */++const assert = require('assert');++const manifests = require('../server/domain/manifests');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const ann = t.network.addUser('ann');++ const bob = t.network.addUser('bob');++ const annProject = await t.project(ann, 'Ann Secret Project');++ const annApp = await t.app(ann, annProject, { name: 'Ann Hidden App' });++ const bobProject = await t.project(bob, 'Bob Project');++ const bobApp = await t.app(bob, bobProject, { name: 'Bob App' });++ const annCred = [...t.network.state.creds.values()].find((c) => c.app_id === annApp.id);++ const manifest = JSON.stringify({ ...manifests.template('app', { appId: annApp.id, projectId: annProject, environment: 'sandbox', name: 'Ann Hidden App' }), version: '1.0.0' });++ const made = await t.get(`/projects/${annProject}/apps/${annApp.id}/releases`, { as: ann, form: { kind: 'app', manifest, intent: 'create', notes: 'ann-draft-notes' } });++ assert.strictEqual(made.status, 303, made.text.slice(0, 200));++ const annRelease = made.headers.get('location').split('/').pop();++++ const networkState = () => JSON.stringify([[...t.network.state.projects.entries()], [...t.network.state.members.entries()], [...t.network.state.apps.entries()], [...t.network.state.creds.entries()]]);++ const snapshot = () => networkState() + t.dbDump();++ const HERS = ['Ann Hidden App', 'ann-draft-notes', ...(annApp.secret ? [annApp.secret] : [])];++ const refused = (r, what) => {++ assert.ok([401, 403, 404].includes(r.status) || (r.status === 303 && /\/login|\/auth/.test(r.headers.get('location') || '')), `${what}: ${r.status} ${r.text.slice(0, 160)}`);++ for (const h of HERS) assert.ok(!r.text.includes(h), `${what}: the answer carries "${h}"`);++ };++++ await check('pages: Bob sees nothing of Ann\'s project, apps or releases', async () => {++ for (const p of ['', '/audit', '/usage', '/export', `/apps/${annApp.id}`, `/apps/${annApp.id}/playground`, `/apps/${annApp.id}/releases/new`]) {++ refused(await t.get(`/projects/${annProject}${p}`, { as: bob }), `GET project${p}`);++ }++ refused(await t.get(`/projects/${bobProject}/apps/${annApp.id}`, { as: bob }), 'Ann\'s app under Bob\'s project');++ refused(await t.get(`/releases/${annRelease}`, { as: bob }), 'Ann\'s draft release page');++ refused(await t.get(`/api/v1/releases/${annRelease}`), 'Ann\'s draft release API (anonymous)');++ const list = await t.get(`/api/v1/apps/${annApp.id}/releases`);++ assert.ok(!list.text.includes(annRelease) && !list.text.includes('ann-draft-notes'), 'the public release list holds no draft');++ });++++ await check('forms: Bob cannot change Ann\'s project, apps, members, credentials, grants or releases', async () => {++ const before = snapshot();++ const P = `/projects/${annProject}`;++ const A = `${P}/apps/${annApp.id}`;++ for (const [p, form] of [++ [`${P}/apps`, { name: 'Bob in Ann', environment: 'sandbox', type: 'confidential', redirect_uris: '' }],++ [`${P}/members`, { username: 'bob', role: 'owner' }],++ [`${P}/members/${ann.subject}/remove`, {}], [`${P}/members/${ann.subject}/role`, { role: 'viewer' }],++ [`${A}/credentials/rotate`, {}], [`${A}/credentials/${annCred ? annCred.id : 'cred_x'}/revoke`, {}],++ [`${A}/grants`, { capability: 'media.object.upload' }], [`${A}/redirects`, { redirect_uris: 'https://bob.example/cb' }],++ [`${A}/revoke`, { confirm: '1' }], [`${A}/releases`, { kind: 'app', manifest, intent: 'create' }],++ [`${A}/playground/events`, { event_type: 'x' }], [`${A}/playground/media`, {}],++ [`${P}/archive`, { confirm: '1' }], [`${P}/delete`, { confirm: '1', name: annProject }], [`${P}/export/archive`, {}],++ // His own project in the path, her ids after it.++ [`/projects/${bobProject}/apps/${annApp.id}/credentials/rotate`, {}],++ [`/projects/${bobProject}/apps/${annApp.id}/revoke`, { confirm: '1' }],++ [`/projects/${bobProject}/apps/${bobApp.id}/credentials/${annCred ? annCred.id : 'cred_x'}/revoke`, {}],++ [`/releases/${annRelease}/publish`, {}], [`/releases/${annRelease}/deprecate`, { reason: 'x' }], [`/releases/${annRelease}/revoke`, { reason: 'x' }],++ ]) {++ refused(await t.get(p, { as: bob, form }), `POST ${p}`);++ }++ assert.strictEqual(snapshot(), before, 'something of Ann\'s changed');++ });++++ await check('controls: Ann reads her project and publishes her release', async () => {++ const page = await t.get(`/projects/${annProject}/apps/${annApp.id}`, { as: ann });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Ann Hidden App'));++ const r = await t.get(`/releases/${annRelease}/publish`, { as: ann, form: {} });++ assert.strictEqual(r.status, 303, r.text.slice(0, 200));++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * A developer's projects, apps, credentials and draft releases reach nobody else, on any path++ * (roadmap WS-R task 5, the private-object class). Ann has a project with an app (its client secret++ * and credential), a member, a draft release with notes, and a playground page; Bob has his own++ * project. Every route the booted app has (listed from Express's router stack, test/security-crawl.js)++ * is requested with Ann's project, app, credential, release and subject ids in every parameter,++ * as anonymous, Bob and a signed-in person with no project. Ann's project and app names, the++ * secret and the draft's notes never appear (the public release list and pages carry++ * published releases only). A control shows Ann sees them.++ *++ * node test/security-private.test.js++ */++const assert = require('assert');++const manifests = require('../server/domain/manifests');++const appModule = require('../server/app');++let capturedApp = null;++const realCreate = appModule.createApp;++appModule.createApp = (...a) => { const built = realCreate(...a); capturedApp = built.app; return built; };++const { boot, check, done } = require('./helpers/boot');++const { getPaths, crawl } = require('./security-crawl');++++(async () => {++ const t = await boot();++ assert.ok(capturedApp, 'the Express app was captured');++ const ann = t.network.addUser('ann');++ const bob = t.network.addUser('bob');++ const nobody = t.network.addUser('nobody');++ const annProject = await t.project(ann, 'Ann Secret Project');++ const annApp = await t.app(ann, annProject, { name: 'Ann Hidden App' });++ await t.project(bob, 'Bob Project');++ const cred = [...t.network.state.creds.values()].find((c) => c.app_id === annApp.id);++ const manifest = JSON.stringify({ ...manifests.template('app', { appId: annApp.id, projectId: annProject, environment: 'sandbox', name: 'Ann Hidden App' }), version: '1.0.0' });++ const made = await t.get(`/projects/${annProject}/apps/${annApp.id}/releases`, { as: ann, form: { kind: 'app', manifest, intent: 'create', notes: 'ann-draft-notes' } });++ assert.strictEqual(made.status, 303, made.text.slice(0, 200));++ const release = made.headers.get('location').split('/').pop();++ // (Ids are not needles: a page may echo the address it was asked for.)++ const SECRET = { project: 'Ann Secret Project', app: 'Ann Hidden App', notes: 'ann-draft-notes', ...(annApp.secret ? { secret: annApp.secret } : {}) };++++ await check('control: Ann sees her project, app and draft', async () => {++ assert.ok((await t.get(`/projects/${annProject}`, { as: ann })).text.includes('Ann Secret Project'));++ assert.ok((await t.get(`/releases/${release}`, { as: ann })).text.includes('ann-draft-notes'));++ });++++ await check('every GET route: nothing of Ann\'s reaches anonymous, Bob or a person without a project', async () => {++ const values = (name) => {++ if (name === 'project') return [annProject];++ if (name === 'app') return [annApp.id];++ if (name === 'credential') return [cred ? cred.id : 'cred_x'];++ if (name === 'subject') return [ann.subject];++ if (name === 'id') return [release, annApp.id, annProject];++ return [annProject, annApp.id, release];++ };++ const paths = getPaths(capturedApp, values, {++ query: `project=${annProject}&app=${annApp.id}&release=${release}&q=Ann`,++ extra: [`/api/v1/releases/${release}`, `/api/v1/apps/${annApp.id}/releases`, `/api/v1/apps/${annApp.id}/trust`, `/apps/${annApp.id}/releases`, `/releases/${release}`,++ '/sitemap.xml', '/events', '/updates', '/staff', `/projects/${annProject}/export?format=json`],++ });++ const r = await crawl(t, paths, { anonymous: null, bob, nobody }, () => SECRET);++ console.log(` (${paths.length} paths × 3 people; answers ${JSON.stringify(r.statuses)})`);++ assert.ok(r.answered >= paths.length * 2);++ assert.deepStrictEqual(r.found, []);++ });++++ await check('the events outbox carries nothing of the draft (drafts emit nothing)', () => {++ const text = JSON.stringify(t.ctx.store.db.prepare('SELECT envelope FROM event_outbox').all());++ assert.ok(!text.includes('ann-draft-notes') && !text.includes(release));++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++/**++ * Codes' own secrets never leave in a response, an event or a log line (roadmap WS-R task 5, the++ * internal-secret class). secrets.test.js pins the secrets Network hands out (client secrets, typed++ * tokens); this suite covers Codes' environment: its Network OAuth client secret and its form secret++ * are sentinels, and every route the booted app has (listed from Express's router stack) is++ * requested as anonymous, a developer and staff with real and nonsense ids, plus the sign-in callback++ * with a forged code (it posts the client secret to Network), unknown paths and every write route++ * with a broken body. No body or header may carry either (a form token derived from the form secret++ * is fine; the secret is not), nor may the outbox or the captured log lines.++ *++ * node test/security-secrets.test.js++ */++const assert = require('assert');++const appModule = require('../server/app');++let capturedApp = null;++const realCreate = appModule.createApp;++appModule.createApp = (...a) => { const built = realCreate(...a); capturedApp = built.app; return built; };++const { boot, check, done } = require('./helpers/boot');++const { getPaths, crawl, listRoutes, expand, leaks, nextAddress } = require('./security-crawl');++++const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-codes-oauth-client', CODES_FORM_SECRET: 'sentinel-not-a-secret-codes-form' };++++(async () => {++ const t = await boot({ env: SECRETS });++ const dev = t.network.addUser('dev');++ const staff = t.network.addUser('root', { role: 'admin' });++ const project = await t.project(dev, 'Secret Test');++ const app = await t.app(dev, project, { name: 'Secret App' });++++ await check('every GET route, as three people, with real and nonsense ids: no sentinel', async () => {++ const nonsense = ['nope', "'\"<x>", 'x'.repeat(300)];++ const values = (name) => (name === 'project' ? [project, ...nonsense] : name === 'app' ? [app.id, ...nonsense] : [app.id, project, ...nonsense]);++ const paths = getPaths(capturedApp, values, {++ query: 'q=x&code=forged&state=forged&next=%2F',++ extra: ['/api/health', '/api/ready', '/metrics', '/release.json', '/auth/callback?code=forged&state=forged', '/callback?code=forged&state=forged', '/api/nope', '/nope', '/.env', '/api/%'],++ });++ const r = await crawl(t, paths, { anonymous: null, dev, staff }, () => SECRETS);++ assert.ok(r.answered >= paths.length * 2, `${r.answered} answers`);++ assert.deepStrictEqual(r.found, []);++ });++++ await check('every write route with a broken body, anonymous and as the developer: no sentinel', async () => {++ const found = [];++ const values = (name) => (name === 'project' ? [project] : name === 'app' ? [app.id] : ['x']);++ for (const route of listRoutes(capturedApp)) {++ for (const method of route.methods.filter((m) => ['post', 'put', 'patch', 'delete'].includes(m))) {++ for (const p of expand(route.path, values)) {++ for (const as of [null, dev]) {++ const r = await t.get(p, { method: method.toUpperCase(), ...(as ? { as } : {}), body: '{"broken": ', headers: { 'content-type': 'application/json', 'x-forwarded-for': nextAddress() } });++ for (const l of leaks(r, SECRETS)) found.push(`${method} ${p} → ${r.status} carries ${l.label}`);++ }++ }++ }++ }++ assert.deepStrictEqual(found, []);++ });++++ await check('the outbox and the captured log lines carry no sentinel', () => {++ const text = t.dbDump() + t.logs();++ for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `${k} in the database or the logs`);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * Codes fetches no URL a developer typed (roadmap WS-R task 5, the SSRF class). The URLs developers++ * give Codes (manifest homepage/icon/webhook fields, OAuth redirect URIs, the webhook tester's++ * delivery target) are validated and stored, or used to build a sample, never requested: every++ * outbound request of the process is recorded while manifests and forms full of internal addresses++ * in every spelling go through validate, create and publish, the redirect form and the webhook++ * tools, and none may go to any of them. And a ratchet: every file in server/ that makes an++ * outbound request itself is on a reviewed list with where it goes.++ *++ * node test/security-ssrf.test.js++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const outbound = [];++const realFetch = globalThis.fetch;++globalThis.fetch = (url, opts) => { outbound.push(String((url && url.url) || url)); return realFetch(url, opts); };++++const manifests = require('../server/domain/manifests');++const { boot, check, done } = require('./helpers/boot');++++const PROBE = '/ssrf-probe-path';++const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`, `http://0x7f000001${PROBE}`, `http://[::1]${PROBE}`, `http://[::ffff:127.0.0.1]${PROBE}`,++ `http://169.254.169.254/latest/meta-data${PROBE}`, `http://10.0.0.1${PROBE}`, `http://localhost:4001${PROBE}`, `file:///etc/passwd${PROBE}`, `gopher://127.0.0.1:6379/_x${PROBE}`];++++(async () => {++ const t = await boot();++ const dev = t.network.addUser('dev');++ const project = await t.project(dev, 'Probe Project');++ const app = await t.app(dev, project, { name: 'Probe App' });++ const base = `/projects/${project}/apps/${app.id}`;++++ await check('manifests, redirect URIs and the webhook tools full of internal URLs are never fetched', async () => {++ let v = 1;++ for (const u of INTERNAL) {++ const manifest = JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId: project, environment: 'sandbox', name: 'Probe App' }), version: `1.0.${v++}`,++ homepage: u, icon: u, icon_url: u, webhook_url: u, webhooks: [{ url: u, events: ['x'] }], support_url: u, privacy_policy_url: u });++ await t.get(`${base}/releases`, { as: dev, form: { kind: 'app', manifest, intent: 'validate' } });++ const made = await t.get(`${base}/releases`, { as: dev, form: { kind: 'app', manifest, intent: 'create' } });++ if (made.status === 303) await t.get(`/releases/${made.headers.get('location').split('/').pop()}/publish`, { as: dev, form: {} });++ await t.get('/manifests/validate', { form: { kind: 'app', manifest } });++ await t.get(`${base}/redirects`, { as: dev, form: { redirect_uris: u } });++ await t.get('/tools/webhooks/sample', { form: { url: u, endpoint: u, secret: 'sample-not-a-secret', event_type: 'codes.app.published' } });++ await t.get('/tools/webhooks/verify', { form: { url: u, endpoint: u, secret: 'sample-not-a-secret', body: '{}', signature: 't=1,v1=00' } });++ await t.get(`/tools/webhooks?url=${encodeURIComponent(u)}`);++ }++ const hit = outbound.filter((u) => u.includes(PROBE));++ assert.deepStrictEqual(hit, [], 'Codes fetched a URL a developer typed');++ });++++ await check('ratchet: every file that makes an outbound request itself is reviewed', () => {++ const REVIEWED = {++ 'server/auth/keys.js': 'Network JWKS (configured)',++ 'server/auth/sso.js': 'Network OAuth token and revoke (configured)',++ 'server/domain/limits.js': 'the services\' /limits.json (configured internal URLs)',++ 'server/domain/project-archive.js': 'no request: a shell snippet in the archive\'s README text',++ 'server/http/docs.js': 'Network changelog, Tools catalog and Billing policy (configured)',++ };++ const root = path.join(__dirname, '..');++ const found = [];++ const walk = (dir) => {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const f = path.join(dir, e.name);++ if (e.isDirectory()) walk(f);++ else if (e.name.endsWith('.js')) {++ const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/.*$/gm, '$1');++ if (/(^|[^.\w])(fetch|fetchImpl)\(|\bhttps?\.(get|request)\(|new WebSocket\(|require\(['"](axios|got|node-fetch|undici)['"]\)/m.test(src)) found.push(path.relative(root, f));++ }++ }++ };++ walk(path.join(root, 'server'));++ assert.ok(found.length >= 4, `the scan finds the known sites (${found.join(', ')})`);++ assert.deepStrictEqual(found.filter((f) => !REVIEWED[f]).sort(), [], 'a new outbound request site: a developer-chosen URL goes through openvibe-shared/egress; then add the file here with where it goes');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+/**
+ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted
+ * Wiki app (walking Express's router stack, so a route added later is crawled without anyone
+ * listing it), fills route parameters with seeded values, and GETs each path as several people,
+ * reporting any response whose body or headers carry a value that person must never see.
+ * Not a test itself (no .test.js); used with test/helpers/boot.js.
+ */
+
+/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */
+function mountPath(layer) {
+ if (!layer.regexp || layer.regexp.fast_slash) return '';
+ let src = layer.regexp.source.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\/\?\(\?=\/\|\$\)$/i, '');
+ let i = 0;
+ src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => `:${(layer.keys[i++] || {}).name || 'param'}`);
+ src = src.replace(/\\\//g, '/').replace(/\\\./g, '.').replace(/\\-/g, '-');
+ return /[\\^$()|[\]*+?]/.test(src) ? null : src;
+}
+
+/** Every route of the booted Codes app (captured from createApp): [{ path, methods }]. */
+function listRoutes(app) {
+ if (!app || !app._router) throw new Error('no Express app');
+ const out = [];
+ const walk = (stack, prefix) => {
+ for (const layer of stack) {
+ if (layer.route) {
+ const methods = Object.keys(layer.route.methods).filter((m) => layer.route.methods[m]);
+ for (const p of [].concat(layer.route.path)) if (typeof p === 'string') out.push({ path: prefix + p, methods });
+ } else if (layer.handle && Array.isArray(layer.handle.stack)) {
+ const mp = mountPath(layer);
+ if (mp !== null) walk(layer.handle.stack, prefix + mp);
+ } else {
+ const mp = mountPath(layer);
+ if (mp) out.push({ path: prefix + mp, methods: ['_all'] });
+ }
+ }
+ };
+ walk(app._router.stack, '');
+ const seen = new Set();
+ return out.filter((r) => { const k = `${r.methods.join(',')} ${r.path}`; if (seen.has(k)) return false; seen.add(k); return true; });
+}
+
+/** Concrete paths for a template: candidate i of every parameter, for each i (no cross product). */
+function expand(template, values) {
+ const names = [];
+ const t = `/${template.replace(/^\/+/, '')}`.replace(/\*/g, 'x').replace(/:([A-Za-z0-9_]+)\??(\([^)]*\))?/g, (m, n) => { names.push(n); return `:${n}`; });
+ if (!names.length) return [t];
+ const lists = names.map((n) => values(n));
+ const width = Math.max(...lists.map((l) => l.length));
+ const paths = new Set();
+ for (let i = 0; i < width; i++) {
+ let j = 0;
+ paths.add(t.replace(/:([A-Za-z0-9_]+)/g, () => { const l = lists[j++]; return encodeURIComponent(String(l[Math.min(i, l.length - 1)])); }));
+ }
+ return [...paths];
+}
+
+/** Every GET path: each GET route expanded, also with `query` appended, plus `extra`. */
+function getPaths(app, values, { query = '', extra = [] } = {}) {
+ const paths = new Set();
+ for (const r of listRoutes(app)) {
+ if (!r.methods.includes('get') && !r.methods.includes('_all')) continue;
+ for (const p of expand(r.path, values)) {
+ paths.add(p);
+ if (query && !p.includes('?')) paths.add(`${p}?${query}`);
+ }
+ }
+ for (const p of extra) paths.add(p);
+ return [...paths];
+}
+
+/** Which of `needles` ({ label: value }) a response carries: [{ label, where }]. */
+function leaks(res, needles) {
+ const found = [];
+ const headerText = [...(res.headers && typeof res.headers.entries === 'function' ? res.headers.entries() : Object.entries(res.headers || {}))].map(([k, v]) => `${k}: ${v}`).join('\n');
+ for (const [label, value] of Object.entries(needles)) {
+ if (!value) continue;
+ if (res.text && res.text.includes(value)) found.push({ label, where: 'body' });
+ if (headerText.includes(value)) found.push({ label, where: 'headers' });
+ }
+ return found;
+}
+
+/** A fresh client address per request (TRUST_PROXY is on in tests): the per-address rate limits are not what is being read. */
+let seq = 0;
+function nextAddress() { seq++; return `10.${(seq >> 16) & 255}.${(seq >> 8) & 255}.${seq & 255}`; }
+
+/**
+ * GET every path as every person ({ who: user | token | null }) through t.get (a user object signs
+ * in with the session cookie, a string is a bearer token). `needlesFor(who)` names what that person
+ * must never see. Resolves { found, answered, statuses }.
+ */
+async function crawl(t, paths, people, needlesFor, { concurrency = 6 } = {}) {
+ const found = [];
+ const statuses = {};
+ let answered = 0;
+ for (const [who, as] of Object.entries(people)) {
+ const needles = needlesFor(who);
+ const queue = [...paths];
+ await Promise.all(Array.from({ length: concurrency }, async () => {
+ while (queue.length) {
+ const p = queue.shift();
+ let r;
+ try { r = await t.get(p, { ...(as ? { as } : {}), headers: { 'x-forwarded-for': nextAddress() } }); } catch (e) { r = { status: 0, text: '', headers: {} }; }
+ if (r.status) answered++;
+ const cls = r.status ? `${String(r.status)[0]}xx` : 'none';
+ statuses[cls] = (statuses[cls] || 0) + 1;
+ for (const l of leaks(r, needles)) found.push(`${who}: GET ${p} → ${r.status} carries ${l.label} in its ${l.where}`);
+ }
+ }));
+ }
+ return { found, answered, statuses };
+}
+
+module.exports = { listRoutes, expand, getPaths, leaks, crawl, nextAddress };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * One developer cannot act on another's projects, apps, credentials or releases by swapping ids
+ * (roadmap WS-R task 5, the IDOR class). Project data lives at OpenVibe.Network (Codes asks with the
+ * person's own token, so Network's rules apply); releases, trust tiers and playground runs are
+ * Codes' own. Ann and Bob each have a project with an app; Ann has a draft release. Bob tries every
+ * page and form that takes an id with Ann's instead (her project's pages, apps, members, credentials,
+ * grants, redirects, playground, archive, export, delete), and the nested swap: his own project in the
+ * path with Ann's app or credential id. Then Ann's releases: the draft's page and API, and publish,
+ * deprecate, revoke. Every refusal leaves Network's projects, apps and credentials and every table of
+ * Codes as they were, and shows nothing of Ann's (her app's name, its secret, the draft's notes);
+ * controls show the routes work for their owner.
+ *
+ * node test/security-idor.test.js
+ */
+const assert = require('assert');
+const manifests = require('../server/domain/manifests');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const ann = t.network.addUser('ann');
+ const bob = t.network.addUser('bob');
+ const annProject = await t.project(ann, 'Ann Secret Project');
+ const annApp = await t.app(ann, annProject, { name: 'Ann Hidden App' });
+ const bobProject = await t.project(bob, 'Bob Project');
+ const bobApp = await t.app(bob, bobProject, { name: 'Bob App' });
+ const annCred = [...t.network.state.creds.values()].find((c) => c.app_id === annApp.id);
+ const manifest = JSON.stringify({ ...manifests.template('app', { appId: annApp.id, projectId: annProject, environment: 'sandbox', name: 'Ann Hidden App' }), version: '1.0.0' });
+ const made = await t.get(`/projects/${annProject}/apps/${annApp.id}/releases`, { as: ann, form: { kind: 'app', manifest, intent: 'create', notes: 'ann-draft-notes' } });
+ assert.strictEqual(made.status, 303, made.text.slice(0, 200));
+ const annRelease = made.headers.get('location').split('/').pop();
+
+ const networkState = () => JSON.stringify([[...t.network.state.projects.entries()], [...t.network.state.members.entries()], [...t.network.state.apps.entries()], [...t.network.state.creds.entries()]]);
+ const snapshot = () => networkState() + t.dbDump();
+ const HERS = ['Ann Hidden App', 'ann-draft-notes', ...(annApp.secret ? [annApp.secret] : [])];
+ const refused = (r, what) => {
+ assert.ok([401, 403, 404].includes(r.status) || (r.status === 303 && /\/login|\/auth/.test(r.headers.get('location') || '')), `${what}: ${r.status} ${r.text.slice(0, 160)}`);
+ for (const h of HERS) assert.ok(!r.text.includes(h), `${what}: the answer carries "${h}"`);
+ };
+
+ await check('pages: Bob sees nothing of Ann\'s project, apps or releases', async () => {
+ for (const p of ['', '/audit', '/usage', '/export', `/apps/${annApp.id}`, `/apps/${annApp.id}/playground`, `/apps/${annApp.id}/releases/new`]) {
+ refused(await t.get(`/projects/${annProject}${p}`, { as: bob }), `GET project${p}`);
+ }
+ refused(await t.get(`/projects/${bobProject}/apps/${annApp.id}`, { as: bob }), 'Ann\'s app under Bob\'s project');
+ refused(await t.get(`/releases/${annRelease}`, { as: bob }), 'Ann\'s draft release page');
+ refused(await t.get(`/api/v1/releases/${annRelease}`), 'Ann\'s draft release API (anonymous)');
+ const list = await t.get(`/api/v1/apps/${annApp.id}/releases`);
+ assert.ok(!list.text.includes(annRelease) && !list.text.includes('ann-draft-notes'), 'the public release list holds no draft');
+ });
+
+ await check('forms: Bob cannot change Ann\'s project, apps, members, credentials, grants or releases', async () => {
+ const before = snapshot();
+ const P = `/projects/${annProject}`;
+ const A = `${P}/apps/${annApp.id}`;
+ for (const [p, form] of [
+ [`${P}/apps`, { name: 'Bob in Ann', environment: 'sandbox', type: 'confidential', redirect_uris: '' }],
+ [`${P}/members`, { username: 'bob', role: 'owner' }],
+ [`${P}/members/${ann.subject}/remove`, {}], [`${P}/members/${ann.subject}/role`, { role: 'viewer' }],
+ [`${A}/credentials/rotate`, {}], [`${A}/credentials/${annCred ? annCred.id : 'cred_x'}/revoke`, {}],
+ [`${A}/grants`, { capability: 'media.object.upload' }], [`${A}/redirects`, { redirect_uris: 'https://bob.example/cb' }],
+ [`${A}/revoke`, { confirm: '1' }], [`${A}/releases`, { kind: 'app', manifest, intent: 'create' }],
+ [`${A}/playground/events`, { event_type: 'x' }], [`${A}/playground/media`, {}],
+ [`${P}/archive`, { confirm: '1' }], [`${P}/delete`, { confirm: '1', name: annProject }], [`${P}/export/archive`, {}],
+ // His own project in the path, her ids after it.
+ [`/projects/${bobProject}/apps/${annApp.id}/credentials/rotate`, {}],
+ [`/projects/${bobProject}/apps/${annApp.id}/revoke`, { confirm: '1' }],
+ [`/projects/${bobProject}/apps/${bobApp.id}/credentials/${annCred ? annCred.id : 'cred_x'}/revoke`, {}],
+ [`/releases/${annRelease}/publish`, {}], [`/releases/${annRelease}/deprecate`, { reason: 'x' }], [`/releases/${annRelease}/revoke`, { reason: 'x' }],
+ ]) {
+ refused(await t.get(p, { as: bob, form }), `POST ${p}`);
+ }
+ assert.strictEqual(snapshot(), before, 'something of Ann\'s changed');
+ });
+
+ await check('controls: Ann reads her project and publishes her release', async () => {
+ const page = await t.get(`/projects/${annProject}/apps/${annApp.id}`, { as: ann });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Ann Hidden App'));
+ const r = await t.get(`/releases/${annRelease}/publish`, { as: ann, form: {} });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 200));
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * A developer's projects, apps, credentials and draft releases reach nobody else, on any path
+ * (roadmap WS-R task 5, the private-object class). Ann has a project with an app (its client secret
+ * and credential), a member, a draft release with notes, and a playground page; Bob has his own
+ * project. Every route the booted app has (listed from Express's router stack, test/security-crawl.js)
+ * is requested with Ann's project, app, credential, release and subject ids in every parameter,
+ * as anonymous, Bob and a signed-in person with no project. Ann's project and app names, the
+ * secret and the draft's notes never appear (the public release list and pages carry
+ * published releases only). A control shows Ann sees them.
+ *
+ * node test/security-private.test.js
+ */
+const assert = require('assert');
+const manifests = require('../server/domain/manifests');
+const appModule = require('../server/app');
+let capturedApp = null;
+const realCreate = appModule.createApp;
+appModule.createApp = (...a) => { const built = realCreate(...a); capturedApp = built.app; return built; };
+const { boot, check, done } = require('./helpers/boot');
+const { getPaths, crawl } = require('./security-crawl');
+
+(async () => {
+ const t = await boot();
+ assert.ok(capturedApp, 'the Express app was captured');
+ const ann = t.network.addUser('ann');
+ const bob = t.network.addUser('bob');
+ const nobody = t.network.addUser('nobody');
+ const annProject = await t.project(ann, 'Ann Secret Project');
+ const annApp = await t.app(ann, annProject, { name: 'Ann Hidden App' });
+ await t.project(bob, 'Bob Project');
+ const cred = [...t.network.state.creds.values()].find((c) => c.app_id === annApp.id);
+ const manifest = JSON.stringify({ ...manifests.template('app', { appId: annApp.id, projectId: annProject, environment: 'sandbox', name: 'Ann Hidden App' }), version: '1.0.0' });
+ const made = await t.get(`/projects/${annProject}/apps/${annApp.id}/releases`, { as: ann, form: { kind: 'app', manifest, intent: 'create', notes: 'ann-draft-notes' } });
+ assert.strictEqual(made.status, 303, made.text.slice(0, 200));
+ const release = made.headers.get('location').split('/').pop();
+ // (Ids are not needles: a page may echo the address it was asked for.)
+ const SECRET = { project: 'Ann Secret Project', app: 'Ann Hidden App', notes: 'ann-draft-notes', ...(annApp.secret ? { secret: annApp.secret } : {}) };
+
+ await check('control: Ann sees her project, app and draft', async () => {
+ assert.ok((await t.get(`/projects/${annProject}`, { as: ann })).text.includes('Ann Secret Project'));
+ assert.ok((await t.get(`/releases/${release}`, { as: ann })).text.includes('ann-draft-notes'));
+ });
+
+ await check('every GET route: nothing of Ann\'s reaches anonymous, Bob or a person without a project', async () => {
+ const values = (name) => {
+ if (name === 'project') return [annProject];
+ if (name === 'app') return [annApp.id];
+ if (name === 'credential') return [cred ? cred.id : 'cred_x'];
+ if (name === 'subject') return [ann.subject];
+ if (name === 'id') return [release, annApp.id, annProject];
+ return [annProject, annApp.id, release];
+ };
+ const paths = getPaths(capturedApp, values, {
+ query: `project=${annProject}&app=${annApp.id}&release=${release}&q=Ann`,
+ extra: [`/api/v1/releases/${release}`, `/api/v1/apps/${annApp.id}/releases`, `/api/v1/apps/${annApp.id}/trust`, `/apps/${annApp.id}/releases`, `/releases/${release}`,
+ '/sitemap.xml', '/events', '/updates', '/staff', `/projects/${annProject}/export?format=json`],
+ });
+ const r = await crawl(t, paths, { anonymous: null, bob, nobody }, () => SECRET);
+ console.log(` (${paths.length} paths × 3 people; answers ${JSON.stringify(r.statuses)})`);
+ assert.ok(r.answered >= paths.length * 2);
+ assert.deepStrictEqual(r.found, []);
+ });
+
+ await check('the events outbox carries nothing of the draft (drafts emit nothing)', () => {
+ const text = JSON.stringify(t.ctx.store.db.prepare('SELECT envelope FROM event_outbox').all());
+ assert.ok(!text.includes('ann-draft-notes') && !text.includes(release));
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+/**
+ * Codes' own secrets never leave in a response, an event or a log line (roadmap WS-R task 5, the
+ * internal-secret class). secrets.test.js pins the secrets Network hands out (client secrets, typed
+ * tokens); this suite covers Codes' environment: its Network OAuth client secret and its form secret
+ * are sentinels, and every route the booted app has (listed from Express's router stack) is
+ * requested as anonymous, a developer and staff with real and nonsense ids, plus the sign-in callback
+ * with a forged code (it posts the client secret to Network), unknown paths and every write route
+ * with a broken body. No body or header may carry either (a form token derived from the form secret
+ * is fine; the secret is not), nor may the outbox or the captured log lines.
+ *
+ * node test/security-secrets.test.js
+ */
+const assert = require('assert');
+const appModule = require('../server/app');
+let capturedApp = null;
+const realCreate = appModule.createApp;
+appModule.createApp = (...a) => { const built = realCreate(...a); capturedApp = built.app; return built; };
+const { boot, check, done } = require('./helpers/boot');
+const { getPaths, crawl, listRoutes, expand, leaks, nextAddress } = require('./security-crawl');
+
+const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-codes-oauth-client', CODES_FORM_SECRET: 'sentinel-not-a-secret-codes-form' };
+
+(async () => {
+ const t = await boot({ env: SECRETS });
+ const dev = t.network.addUser('dev');
+ const staff = t.network.addUser('root', { role: 'admin' });
+ const project = await t.project(dev, 'Secret Test');
+ const app = await t.app(dev, project, { name: 'Secret App' });
+
+ await check('every GET route, as three people, with real and nonsense ids: no sentinel', async () => {
+ const nonsense = ['nope', "'\"<x>", 'x'.repeat(300)];
+ const values = (name) => (name === 'project' ? [project, ...nonsense] : name === 'app' ? [app.id, ...nonsense] : [app.id, project, ...nonsense]);
+ const paths = getPaths(capturedApp, values, {
+ query: 'q=x&code=forged&state=forged&next=%2F',
+ extra: ['/api/health', '/api/ready', '/metrics', '/release.json', '/auth/callback?code=forged&state=forged', '/callback?code=forged&state=forged', '/api/nope', '/nope', '/.env', '/api/%'],
+ });
+ const r = await crawl(t, paths, { anonymous: null, dev, staff }, () => SECRETS);
+ assert.ok(r.answered >= paths.length * 2, `${r.answered} answers`);
+ assert.deepStrictEqual(r.found, []);
+ });
+
+ await check('every write route with a broken body, anonymous and as the developer: no sentinel', async () => {
+ const found = [];
+ const values = (name) => (name === 'project' ? [project] : name === 'app' ? [app.id] : ['x']);
+ for (const route of listRoutes(capturedApp)) {
+ for (const method of route.methods.filter((m) => ['post', 'put', 'patch', 'delete'].includes(m))) {
+ for (const p of expand(route.path, values)) {
+ for (const as of [null, dev]) {
+ const r = await t.get(p, { method: method.toUpperCase(), ...(as ? { as } : {}), body: '{"broken": ', headers: { 'content-type': 'application/json', 'x-forwarded-for': nextAddress() } });
+ for (const l of leaks(r, SECRETS)) found.push(`${method} ${p} → ${r.status} carries ${l.label}`);
+ }
+ }
+ }
+ }
+ assert.deepStrictEqual(found, []);
+ });
+
+ await check('the outbox and the captured log lines carry no sentinel', () => {
+ const text = t.dbDump() + t.logs();
+ for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `${k} in the database or the logs`);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * Codes fetches no URL a developer typed (roadmap WS-R task 5, the SSRF class). The URLs developers
+ * give Codes (manifest homepage/icon/webhook fields, OAuth redirect URIs, the webhook tester's
+ * delivery target) are validated and stored, or used to build a sample, never requested: every
+ * outbound request of the process is recorded while manifests and forms full of internal addresses
+ * in every spelling go through validate, create and publish, the redirect form and the webhook
+ * tools, and none may go to any of them. And a ratchet: every file in server/ that makes an
+ * outbound request itself is on a reviewed list with where it goes.
+ *
+ * node test/security-ssrf.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const outbound = [];
+const realFetch = globalThis.fetch;
+globalThis.fetch = (url, opts) => { outbound.push(String((url && url.url) || url)); return realFetch(url, opts); };
+
+const manifests = require('../server/domain/manifests');
+const { boot, check, done } = require('./helpers/boot');
+
+const PROBE = '/ssrf-probe-path';
+const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`, `http://0x7f000001${PROBE}`, `http://[::1]${PROBE}`, `http://[::ffff:127.0.0.1]${PROBE}`,
+ `http://169.254.169.254/latest/meta-data${PROBE}`, `http://10.0.0.1${PROBE}`, `http://localhost:4001${PROBE}`, `file:///etc/passwd${PROBE}`, `gopher://127.0.0.1:6379/_x${PROBE}`];
+
+(async () => {
+ const t = await boot();
+ const dev = t.network.addUser('dev');
+ const project = await t.project(dev, 'Probe Project');
+ const app = await t.app(dev, project, { name: 'Probe App' });
+ const base = `/projects/${project}/apps/${app.id}`;
+
+ await check('manifests, redirect URIs and the webhook tools full of internal URLs are never fetched', async () => {
+ let v = 1;
+ for (const u of INTERNAL) {
+ const manifest = JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId: project, environment: 'sandbox', name: 'Probe App' }), version: `1.0.${v++}`,
+ homepage: u, icon: u, icon_url: u, webhook_url: u, webhooks: [{ url: u, events: ['x'] }], support_url: u, privacy_policy_url: u });
+ await t.get(`${base}/releases`, { as: dev, form: { kind: 'app', manifest, intent: 'validate' } });
+ const made = await t.get(`${base}/releases`, { as: dev, form: { kind: 'app', manifest, intent: 'create' } });
+ if (made.status === 303) await t.get(`/releases/${made.headers.get('location').split('/').pop()}/publish`, { as: dev, form: {} });
+ await t.get('/manifests/validate', { form: { kind: 'app', manifest } });
+ await t.get(`${base}/redirects`, { as: dev, form: { redirect_uris: u } });
+ await t.get('/tools/webhooks/sample', { form: { url: u, endpoint: u, secret: 'sample-not-a-secret', event_type: 'codes.app.published' } });
+ await t.get('/tools/webhooks/verify', { form: { url: u, endpoint: u, secret: 'sample-not-a-secret', body: '{}', signature: 't=1,v1=00' } });
+ await t.get(`/tools/webhooks?url=${encodeURIComponent(u)}`);
+ }
+ const hit = outbound.filter((u) => u.includes(PROBE));
+ assert.deepStrictEqual(hit, [], 'Codes fetched a URL a developer typed');
+ });
+
+ await check('ratchet: every file that makes an outbound request itself is reviewed', () => {
+ const REVIEWED = {
+ 'server/auth/keys.js': 'Network JWKS (configured)',
+ 'server/auth/sso.js': 'Network OAuth token and revoke (configured)',
+ 'server/domain/limits.js': 'the services\' /limits.json (configured internal URLs)',
+ 'server/domain/project-archive.js': 'no request: a shell snippet in the archive\'s README text',
+ 'server/http/docs.js': 'Network changelog, Tools catalog and Billing policy (configured)',
+ };
+ const root = path.join(__dirname, '..');
+ const found = [];
+ const walk = (dir) => {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const f = path.join(dir, e.name);
+ if (e.isDirectory()) walk(f);
+ else if (e.name.endsWith('.js')) {
+ const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/.*$/gm, '$1');
+ if (/(^|[^.\w])(fetch|fetchImpl)\(|\bhttps?\.(get|request)\(|new WebSocket\(|require\(['"](axios|got|node-fetch|undici)['"]\)/m.test(src)) found.push(path.relative(root, f));
+ }
+ }
+ };
+ walk(path.join(root, 'server'));
+ assert.ok(found.length >= 4, `the scan finds the known sites (${found.join(', ')})`);
+ assert.deepStrictEqual(found.filter((f) => !REVIEWED[f]).sort(), [], 'a new outbound request site: a developer-chosen URL goes through openvibe-shared/egress; then add the file here with where it goes');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });