openvibers/openvibe.wiki Commits

Security regression suites for the four classes that apply to the Wiki (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), fills parameters with seeded values and GETs each path as several people, with the session cookie and the bearer token.

OP OpenVibers committed 26 Sept 2026 · 1e311db2ba · 5 files, +514 −0

test/security-private.test.js: a private space, and in a public space a private page, a members-only page, a draft, an unpublished second revision and an AI proposal on the private page, each with words found nowhere else; 249 paths (pages, history, diffs, sources, the API, search, sitemaps, feeds, llms.txt) as anonymous, a stranger and Network staff: none of it appears (the members page only to anonymous); public events and Search documents carry none of it.

test/security-secrets.test.js: a sentinel as the OAuth client secret and as the service token the Wiki presents to other services (which do get it: the sentinels are live); 308 paths as four people, every write route with a broken body and with one that makes the Wiki call a failing service, the forged sign-in callback: neither appears in a body, a header or the outbox.

test/security-idor.test.js: Bob owns his own space and tries every write that takes an id with Ann's: her pages by id over the API (edit, delete, revisions, publish, unpublish, schedule, revert, citations, review, media), her space (settings, roles, pages, import), her AI proposal, and the page forms, including his space's slug paired with her page's; every refusal leaves every wiki_* table as it was, with owner controls.

test/security-ssrf.test.js: citations and links to internal addresses in every spelling are stored or refused, never fetched, across create, edit, attach, publish, the page, its sources page, the API and the feeds; and a ratchet over the files that make outbound requests. All held; no fixes needed.

Changes

ddiff --git a/test/security-crawl.js b/test/security-crawl.js
nnew file mode 100644
iindex 0000000..66bf2af
---- /dev/null
++++ b/test/security-crawl.js
@@@ -0,0 +1,111 @@
++'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.js's boot() and req().
++ */
++
++/** 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 Wiki's Express app (h.app): [{ 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;
++}
++
++/**
++ * GET every path as every person ({ who: token | null }) through helpers.req, with the session
++ * cookie and the bearer token. `needlesFor(who)` names what that person must never see.
++ * Resolves { found, answered, statuses }.
++ */
++async function crawl(h, req, paths, people, needlesFor, { concurrency = 6 } = {}) {
++ const found = [];
++ const statuses = {};
++ let answered = 0;
++ for (const [who, token] 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 req(h, 'GET', p, token ? { token, cookie: `ov_token=${token}` } : {}); } 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 };
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
nnew file mode 100644
iindex 0000000..06a9557
---- /dev/null
++++ b/test/security-idor.test.js
@@@ -0,0 +1,109 @@
++'use strict';
++/**
++ * One person cannot act on another's wiki pages or spaces by swapping ids (roadmap WS-R task 5, the
++ * IDOR class). permissions.test.js walks the role matrix inside one space and delegation.test.js
++ * the service/app side; this suite puts two owners side by side. Bob owns his own space (so every
++ * space-level check passes for him there) and tries each write that takes an id with Ann's ids
++ * instead: over the API (her page by id: edit, delete, visibility, publish, unpublish, schedule,
++ * revert, revisions, citations, review, media, watch; her space: settings, roles, new pages,
++ * import; an AI proposal on her page), and over the page forms, including his own space's slug
++ * paired with her page's slug (/w/bob-space/<her slug>/…). Every refusal must leave all of the
++ * Wiki's tables as they were; controls show each route works for its owner.
++ *
++ * node test/security-idor.test.js
++ */
++const assert = require('assert');
++const H = require('./helpers');
++
++(async () => {
++ const h = await H.boot();
++ let failures = 0;
++ const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 10).join('\n ')}`); } };
++ try {
++ const ann = { kind: 'user', subject: H.subject(), staff: false };
++ const bob = { kind: 'user', subject: H.subject(), staff: false };
++ const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
++ const annSpace = h.svc.createSpace({ name: 'Ann Space', slug: 'ann-space' }, ann);
++ const annPage = h.svc.createPage(annSpace.id, { title: 'Ann Page', body: H.LONG, citations: cite }, ann).page;
++ h.svc.publish(annPage.id, {}, ann);
++ const annPrivate = h.svc.createPage(annSpace.id, { title: 'Ann Private', body: H.LONG, citations: cite, visibility: 'private' }, ann).page;
++ const bobSpace = h.svc.createSpace({ name: 'Bob Space', slug: 'bob-space' }, bob);
++ const bobPage = h.svc.createPage(bobSpace.id, { title: 'Bob Page', body: H.LONG, citations: cite }, bob).page;
++ h.svc.publish(bobPage.id, {}, bob);
++ const proposal = h.svc.propose({ space: annSpace.id, pageId: annPage.id, title: 'Ann Page', body: `${H.LONG} proposed`, summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });
++ const proposalId = proposal.id || (proposal.proposal && proposal.proposal.id);
++ const bobTok = H.userToken({ subject: bob.subject, username: 'bob' });
++ const annTok = H.userToken({ subject: ann.subject, username: 'ann' });
++
++ const tables = h.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'wiki_%' AND name NOT IN ('wiki_event_outbox', 'wiki_module_dirty', 'wiki_module_pushes')").all().map((r) => r.name);
++ const snapshot = () => Object.fromEntries(tables.map((t) => [t, h.db.prepare(`SELECT * FROM ${t}`).all()]));
++ const same = (before, label) => {
++ const after = snapshot();
++ for (const t of tables) assert.deepStrictEqual(after[t], before[t], `${label}: ${t} changed`);
++ };
++ const api = (method, p, body, token = bobTok) => H.req(h, method, `/api/v1${p}`, { token, body });
++ const form = (p, fields, token = bobTok) => H.req(h, 'POST', p, { cookie: H.cookieFor(token), form: fields, headers: { Origin: 'http://wiki.test' } });
++ const refused = (r, what) => assert.ok([401, 403, 404, 409].includes(r.status) || (r.status === 303 && !/\/w\/ann-space/.test(r.headers.get('location') || '')), `${what}: ${r.status} ${r.text.slice(0, 160)}`);
++
++ await check('API: Bob, owner of his own space, cannot touch Ann\'s page or space by id', async () => {
++ const before = snapshot();
++ for (const id of [annPage.id, annPrivate.id]) {
++ refused(await api('PATCH', `/pages/${id}`, { title: 'pwned', slug: 'pwned', visibility: 'public', noindex: true }), `PATCH ${id}`);
++ refused(await api('DELETE', `/pages/${id}`), `DELETE ${id}`);
++ refused(await api('POST', `/pages/${id}/revisions`, { expected_revision: 1, body: `${H.LONG} pwned`, citations: cite }), 'new revision');
++ refused(await api('POST', `/pages/${id}/publish`, { revision: 1 }), 'publish');
++ refused(await api('POST', `/pages/${id}/unpublish`, {}), 'unpublish');
++ refused(await api('POST', `/pages/${id}/schedule`, { revision: 1, run_at: new Date(Date.now() + 3600e3).toISOString() }), 'schedule');
++ refused(await api('POST', `/pages/${id}/revert`, { to_revision: 1, expected_revision: 1 }), 'revert');
++ refused(await api('POST', `/pages/${id}/revisions/1/citations`, { citations: cite }), 'citations');
++ refused(await api('POST', `/pages/${id}/revisions/1/review`, { decision: 'approve' }), 'review');
++ refused(await api('POST', `/pages/${id}/media`, { media_id: 'med_01J00000000000000000000000' }), 'media');
++ refused(await api('POST', `/pages/${id}/media/verify`, {}), 'media verify');
++ }
++ refused(await api('PATCH', '/spaces/ann-space', { name: 'pwned', visibility: 'private' }), 'PATCH space');
++ refused(await api('PUT', `/spaces/ann-space/roles/${bob.subject}`, { role: 'owner' }), 'grant self owner');
++ refused(await api('PUT', `/spaces/ann-space/roles/${ann.subject}`, { role: 'viewer' }), 'demote Ann');
++ refused(await api('POST', '/spaces/ann-space/pages', { title: 'Bob in Ann', body: H.LONG, citations: cite }), 'create in Ann\'s space');
++ refused(await api('POST', '/spaces/ann-space/import', { pages: [{ title: 'x', body: H.LONG }] }), 'import');
++ refused(await api('POST', `/proposals/${proposalId}/review`, { decision: 'approve' }), 'review Ann\'s proposal');
++ refused(await api('GET', `/pages/${annPrivate.id}`), 'read the private page');
++ refused(await api('GET', `/proposals/${proposalId}`), 'read Ann\'s proposal');
++ same(before, 'API');
++ });
++
++ await check('forms: Bob cannot post to Ann\'s pages, nor reach them through his own space\'s slug', async () => {
++ const before = snapshot();
++ for (const sp of ['ann-space', 'bob-space']) {
++ for (const slug of ['ann-page', 'ann-private']) {
++ refused(await form(`/w/${sp}/${slug}/edit`, { body: `${H.LONG} pwned`, summary: 'x', expected_revision: '1' }), `edit ${sp}/${slug}`);
++ refused(await form(`/w/${sp}/${slug}/settings`, { visibility: 'public', title: 'pwned' }), `settings ${sp}/${slug}`);
++ refused(await form(`/w/${sp}/${slug}/revert`, { to: '1' }), `revert ${sp}/${slug}`);
++ refused(await form(`/w/${sp}/${slug}/review`, { decision: 'approve', revision: '1' }), `review ${sp}/${slug}`);
++ }
++ }
++ refused(await form('/s/ann-space/settings', { name: 'pwned', visibility: 'private' }), 'space settings');
++ refused(await form('/s/ann-space/new', { title: 'Bob in Ann', body: H.LONG }), 'new page in Ann\'s space');
++ refused(await form('/s/ann-space/import', { bundle: '{}' }), 'import form');
++ refused(await form(`/proposals/${proposalId}`, { decision: 'approve' }), 'proposal form');
++ same(before, 'forms');
++ const r = await H.req(h, 'GET', '/w/bob-space/ann-private', { cookie: H.cookieFor(bobTok) });
++ assert.strictEqual(r.status, 404, 'Ann\'s slug under Bob\'s space is not her page');
++ });
++
++ await check('controls: Ann can do what Bob could not', async () => {
++ assert.strictEqual((await api('PATCH', '/spaces/ann-space', { name: 'Ann Space 2' }, annTok)).status, 200);
++ const f = await form('/s/ann-space/settings', { name: 'Ann Space 3', visibility: 'public' }, annTok);
++ assert.ok([200, 303].includes(f.status), `the settings form works for its owner: ${f.status} ${f.text.slice(0, 160)}`);
++ assert.strictEqual(h.db.prepare("SELECT name FROM wiki_spaces WHERE slug = 'ann-space'").get().name, 'Ann Space 3');
++ assert.strictEqual((await api('GET', `/pages/${annPrivate.id}`, undefined, annTok)).status, 200);
++ const cur = h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(annPage.id).n;
++ const r = await api('POST', `/pages/${annPage.id}/revisions`, { expected_revision: cur, body: `${H.LONG} by ann`, citations: cite }, annTok);
++ assert.ok([200, 201].includes(r.status), `${r.status} ${r.text.slice(0, 200)}`);
++ });
++ } finally {
++ await h.stop();
++ }
++ if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
++ console.log('\nsecurity-idor: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
nnew file mode 100644
iindex 0000000..c988d8c
---- /dev/null
++++ b/test/security-private.test.js
@@@ -0,0 +1,105 @@
++'use strict';
++/**
++ * Private, members-only and unpublished wiki content reaches nobody who may not read it, on any
++ * path (roadmap WS-R task 5, the private-object class). visibility.test.js, vip-gate.test.js and
++ * discussion-visibility.test.js pin the rules and the machine surfaces; this suite covers the
++ * class: it seeds a private space, and in a public space a private page, a members-only page, a
++ * draft and an unpublished second revision, plus an AI proposal against the private page, each
++ * with words found nowhere else. Then it GETs EVERY route the booted app has (listed from Express's
++ * router stack, test/security-crawl.js), with those spaces, slugs, page ids, revision numbers and
++ * the proposal id in every parameter, plus the machine surfaces (sitemaps, feeds, llms.txt, search),
++ * as anonymous, a signed-in stranger and Network staff (who get no silent access to user spaces).
++ * None of the words may appear (the members-only page's to anonymous: it is for any signed-in account). A viewer of the private space sees it (control), and the events
++ * outbox carries none of it (the Search index gets tombstones, never the text).
++ *
++ * node test/security-private.test.js
++ */
++const assert = require('assert');
++const H = require('./helpers');
++const { getPaths, crawl } = require('./security-crawl');
++
++(async () => {
++ const h = await H.boot();
++ const owner = { kind: 'user', subject: H.subject(), staff: false };
++ const friend = H.subject();
++ const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
++ const SECRET = {
++ spaceName: 'Vault Of Whispers', hiddenTitle: 'Hidden Ledger Title', hiddenWords: 'ledger-secret-words',
++ privateTitle: 'Private Plans Title', privateWords: 'private-plans-words', membersWords: 'members-only-words',
++ draftTitle: 'Draft Musings Title', draftWords: 'draft-musings-words', rev2Words: 'unpublished-revision-words',
++ proposalWords: 'proposal-secret-words',
++ };
++ const body = (w) => `${H.LONG} ${w} ${w}.`;
++ let failures = 0;
++ const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 10).join('\n ')}`); } };
++ try {
++ const vault = h.svc.createSpace({ name: SECRET.spaceName, slug: 'vault', visibility: 'private' }, owner);
++ h.svc.setRole(vault.id, friend, 'viewer', owner);
++ const hidden = h.svc.createPage(vault.id, { title: SECRET.hiddenTitle, body: body(SECRET.hiddenWords), citations: cite }, owner).page;
++ h.svc.publish(hidden.id, {}, owner);
++
++ const open = h.svc.createSpace({ name: 'Open Space', slug: 'open' }, owner);
++ const control = h.svc.createPage(open.id, { title: 'Open Page', body: body('open-control-words'), citations: cite }, owner).page;
++ h.svc.publish(control.id, {}, owner);
++ // Restricted from the start (a page that was public once was legitimately public then).
++ const priv = h.svc.createPage(open.id, { title: SECRET.privateTitle, body: body(SECRET.privateWords), citations: cite, visibility: 'private' }, owner).page;
++ h.svc.publish(priv.id, {}, owner);
++ const members = h.svc.createPage(open.id, { title: 'Members Page', body: body(SECRET.membersWords), citations: cite, visibility: 'members' }, owner).page;
++ h.svc.publish(members.id, {}, owner);
++ const draft = h.svc.createPage(open.id, { title: SECRET.draftTitle, body: body(SECRET.draftWords), citations: cite }, owner).page;
++ // A published page with a second, unpublished revision.
++ h.svc.editPage(control.id, { expectedRevision: 1, body: body(SECRET.rev2Words), summary: 'wip', citations: cite }, owner);
++ const proposal = h.svc.propose({ space: vault.id, pageId: hidden.id, title: SECRET.hiddenTitle, body: body(SECRET.proposalWords), summary: 'ai', citations: cite, workflow: { id: 'wf.test', runId: 'run_1' } }, { kind: 'system' });
++ const pageIds = [hidden.id, priv.id, draft.id, members.id, proposal.id || proposal.proposal && proposal.proposal.id];
++
++ const ownerTok = H.userToken({ subject: owner.subject, username: 'owner' });
++ await check('control: the owner and a viewer of the private space read it; the open page is public', async () => {
++ let r = await H.req(h, 'GET', '/w/vault/hidden-ledger-title', { cookie: H.cookieFor(H.userToken({ subject: friend, username: 'friend' })) });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.ok(r.text.includes(SECRET.hiddenWords));
++ r = await H.req(h, 'GET', `/api/v1/pages/${priv.id}`, { token: ownerTok });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
++ assert.ok(r.text.includes(SECRET.privateTitle));
++ assert.strictEqual((await H.req(h, 'GET', '/w/open/open-page')).status, 200);
++ });
++
++ await check('every GET route, page and machine surface: nothing of it reaches anonymous, a stranger or staff', async () => {
++ const values = (name) => {
++ if (name === 'space') return ['vault', 'open', 'open', 'open'];
++ if (name === 'slug') return ['hidden-ledger-title', 'private-plans-title', 'draft-musings-title', 'members-page', 'open-page'];
++ if (name === 'n' || name === 'a' || name === 'b') return [1, 2, 1, 2, 2];
++ if (name === 'subject') return [owner.subject, friend];
++ return pageIds;
++ };
++ const paths = getPaths(h.app, values, {
++ // (Queries use parts of the words: a page may echo its own query back.)
++ query: 'q=ledger&space=vault&revision=2&from=1&to=2',
++ extra: ['/sitemap.xml', '/sitemaps/spaces.xml', '/sitemaps/pages-1.xml', '/feed.atom', '/feed.json', '/llms.txt', '/recent', '/updates',
++ '/search?q=ledger', '/search?q=private-plans', '/search?q=draft-musings', '/search?q=Hidden+Ledger', '/api/v1/search?q=members-only',
++ '/api/v1/search?q=ledger', '/api/v1/search?q=unpublished-revision', '/api/v1/search?q=proposal-secret',
++ '/api/v1/spaces', '/s/vault', '/s/open', '/w/open/open-page/history', '/w/open/open-page/diff/1/2', '/w/open/open-page?revision=2',
++ '/w/open/open-page/compare?a=1&b=2', `/api/v1/pages/${control.id}/revisions/2`, `/api/v1/pages/${control.id}/diff?from=1&to=2`],
++ });
++ const staffTok = H.userToken({ subject: H.subject(), username: 'staffer', role: 'admin' });
++ const strangerTok = H.userToken({ subject: H.subject(), username: 'stranger' });
++ // A members page is for any signed-in account: only anonymous must not see it.
++ const signedIn = Object.fromEntries(Object.entries(SECRET).filter(([k]) => k !== 'membersWords'));
++ const r = await crawl(h, H.req, paths, { anonymous: null, stranger: strangerTok, staff: staffTok }, (who) => (who === 'anonymous' ? SECRET : signedIn));
++ 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('public events and Search index documents carry none of it (internal events are first-party only)', async () => {
++ const text = JSON.stringify(H.outbox(h).filter((e) => e.visibility === 'public' || e.event_type === 'wiki.index_document.upserted'));
++ assert.ok(text.includes('open-control-words') || text.includes('Open Page'), 'the public page is there (control)');
++ const hits = Object.entries(SECRET).filter(([, w]) => text.includes(w)).map(([k]) => k);
++ assert.deepStrictEqual(hits, []);
++ });
++ } finally {
++ await h.stop();
++ }
++ if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
++ console.log('\nsecurity-private: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
nnew file mode 100644
iindex 0000000..9eed859
---- /dev/null
++++ b/test/security-secrets.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++/**
++ * Wiki's secrets never leave in a response or an event (roadmap WS-R task 5, the internal-secret
++ * class). The Wiki boots with a sentinel as its Network OAuth client secret and as the service token
++ * it presents to Community, Sources, Media, VIP and Events; every outbound call it makes carries the
++ * token (the stub records them). Then every route the booted app has (listed from Express's router
++ * stack, test/security-crawl.js) is requested as anonymous, a user, the owner of a space and
++ * Network staff, with real and nonsense ids, plus the probes (/api/health, /api/ready), unknown
++ * paths, every write route with a broken body, and the sign-in callback with a forged code (it
++ * posts the client secret to Network: the error it answers with must not echo it). No body or
++ * header may contain either sentinel, nor may any event in the outbox.
++ *
++ * node test/security-secrets.test.js
++ */
++const assert = require('assert');
++const H = require('./helpers');
++const { getPaths, crawl, listRoutes, expand, leaks } = require('./security-crawl');
++
++const SECRETS = {
++ OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-wiki-oauth-client',
++ serviceToken: 'sentinel-not-a-secret-wiki-service-token',
++};
++
++(async () => {
++ const outbound = [];
++ // Every other service answers, badly: the error paths are what is being read.
++ const fetchStub = async (url, opts = {}) => {
++ outbound.push({ url: String(url), headers: opts.headers || {}, body: String(opts.body || '') });
++ return new Response(JSON.stringify({ error: 'upstream says no', detail: 'nope' }), { status: 500, headers: { 'content-type': 'application/json' } });
++ };
++ const tokens = { getToken: async () => SECRETS.serviceToken, authHeaders: async () => ({ Authorization: `Bearer ${SECRETS.serviceToken}` }), invalidate() {} };
++ const h = await H.boot({
++ env: { OV_OAUTH_CLIENT_SECRET: SECRETS.OV_OAUTH_CLIENT_SECRET, OV_OAUTH_CLIENT_ID: 'wiki', OV_NETWORK_INTERNAL_URL: 'http://network.test', OV_COMMUNITY_INTERNAL_URL: 'http://community.test', OV_SOURCES_INTERNAL_URL: 'http://sources.test', OV_MEDIA_INTERNAL_URL: 'http://media.test', OV_VIP_INTERNAL_URL: 'http://vip.test' },
++ fetch: fetchStub, tokens,
++ });
++ let failures = 0;
++ const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 10).join('\n ')}`); } };
++ try {
++ const owner = { kind: 'user', subject: H.subject(), staff: false };
++ const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
++ const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
++ const page = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner).page;
++ h.svc.publish(page.id, {}, owner);
++ const people = {
++ anonymous: null,
++ user: H.userToken({ subject: H.subject(), username: 'someone' }),
++ owner: H.userToken({ subject: owner.subject, username: 'owner' }),
++ staff: H.userToken({ subject: H.subject(), username: 'staffer', role: 'admin' }),
++ };
++
++ await check('every GET route, page and probe, as four people: neither secret appears', async () => {
++ const nonsense = ['-1', 'pg_00000000000000000000000000', "'\"<x>", 'x'.repeat(300)];
++ const values = (name) => (name === 'space' ? ['notes', ...nonsense] : name === 'slug' ? ['open-page', ...nonsense] : [page.id, 1, ...nonsense]);
++ const paths = getPaths(h.app, values, {
++ query: 'q=x&next=%2F&revision=-1&from=a&to=b',
++ extra: ['/api/health', '/api/ready', '/api/nope', '/nope/nope', '/.env', '/api/%', '/auth/callback?code=forged&state=forged', '/auth/login?next=/',
++ '/auth/logout', '/w/notes/open-page/discuss', `/api/v1/pages/${page.id}/revisions/1/citations`],
++ });
++ const r = await crawl(h, H.req, paths, people, () => SECRETS);
++ console.log(` (${paths.length} paths × 4 people; answers ${JSON.stringify(r.statuses)})`);
++ assert.ok(r.answered >= paths.length * 3);
++ assert.deepStrictEqual(r.found, []);
++ });
++
++ await check('every write route with a broken body, and with a body that makes the Wiki call another service: the answer names no secret', async () => {
++ const found = [];
++ const values = (name) => (name === 'space' ? ['notes'] : name === 'slug' ? ['open-page'] : [page.id]);
++ for (const route of listRoutes(h.app)) {
++ const methods = route.methods.filter((m) => ['post', 'put', 'patch', 'delete'].includes(m));
++ for (const method of methods) {
++ for (const p of expand(route.path, values)) {
++ for (const token of [null, people.owner]) {
++ for (const raw of ['{"broken": ', JSON.stringify({ title: 'x', body: H.LONG, media_id: 'med_x', citations: cite, decision: 'approve', visibility: 'public' })]) {
++ const res = await fetch(h.base + p, { method: method.toUpperCase(), headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}`, cookie: `ov_token=${token}` } : {}), origin: 'http://wiki.test' }, body: raw, redirect: 'manual' });
++ const text = await res.text();
++ for (const l of leaks({ text, headers: res.headers }, SECRETS)) found.push(`${method} ${p} → ${res.status} carries ${l.label}`);
++ }
++ }
++ }
++ }
++ }
++ assert.deepStrictEqual(found, []);
++ });
++
++ await check('the sentinels are live: the Wiki really sent them to the services it called', async () => {
++ const sent = JSON.stringify(outbound);
++ assert.ok(outbound.length > 0, 'the crawl made the Wiki call out');
++ assert.ok(sent.includes(SECRETS.serviceToken) || sent.includes(SECRETS.OV_OAUTH_CLIENT_SECRET), 'a credential went where it belongs');
++ });
++
++ await check('the events outbox carries neither', async () => {
++ const text = JSON.stringify(H.outbox(h));
++ for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `outbox carries ${k}`);
++ });
++ } finally {
++ await h.stop();
++ }
++ if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
++ console.log('\nsecurity-secrets: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
nnew file mode 100644
iindex 0000000..bd95370
---- /dev/null
++++ b/test/security-ssrf.test.js
@@@ -0,0 +1,88 @@
++'use strict';
++/**
++ * The Wiki fetches no URL a user chose (roadmap WS-R task 5, the SSRF class). Citations and links
++ * are the URLs people type here; they are stored, rendered as links and checked by OpenVibe.Sources
++ * (the citation inspector reads Sources' verdicts), never fetched by the Wiki itself. This suite
++ * cites internal addresses in every spelling (loopback, decimal, octal, IPv6, mapped, metadata,
++ * private ranges, file:, gopher:), then drives every path that handles citations: create, edit,
++ * attach, publish, the page, its sources page, the citation inspector, the API reads, the feeds.
++ * The Wiki's outbound fetch (injected, recorded here) must never be asked for any of them.
++ * And a ratchet: every file in server/ that makes an outbound request itself is on a reviewed list,
++ * with the reason its URLs are configured services and not a user's choice.
++ *
++ * node test/security-ssrf.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const H = require('./helpers');
++
++const INTERNAL = [
++ 'http://127.0.0.1:3000/internal/admin', 'http://2130706433/', 'http://0177.0.0.1/', 'http://0x7f000001/', 'http://127.1/', 'http://0.0.0.0/',
++ 'http://[::1]/', 'http://[::ffff:127.0.0.1]/', 'http://[::ffff:7f00:1]/', 'http://169.254.169.254/latest/meta-data/', 'http://[fd00::1]/',
++ 'http://10.0.0.1/', 'http://192.168.1.1/', 'http://localhost:4001/api', 'file:///etc/passwd', 'gopher://127.0.0.1:6379/_x',
++];
++
++(async () => {
++ const outbound = [];
++ const h = await H.boot({
++ env: { OV_SOURCES_INTERNAL_URL: 'http://sources.test', OV_COMMUNITY_INTERNAL_URL: 'http://community.test', OV_MEDIA_INTERNAL_URL: 'http://media.test' },
++ fetch: async (url, opts = {}) => { outbound.push(String(url)); return new Response(JSON.stringify({ items: [], verdicts: [] }), { status: 200, headers: { 'content-type': 'application/json' } }); },
++ });
++ let failures = 0;
++ const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 10).join('\n ')}`); } };
++ try {
++ const owner = { kind: 'user', subject: H.subject(), staff: false };
++ const tok = H.userToken({ subject: owner.subject, username: 'owner' });
++ const space = h.svc.createSpace({ name: 'Refs', slug: 'refs' }, owner);
++ const okCite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
++ const page = h.svc.createPage(space.id, { title: 'Cited Page', body: `${H.LONG} ${INTERNAL.map((u) => `[x](${u})`).join(' ')}`, citations: okCite }, owner).page;
++ h.svc.publish(page.id, {}, owner);
++
++ await check('internal citation URLs are refused or stored, never fetched, through every path that handles them', async () => {
++ for (const url of INTERNAL) {
++ // Through the API (attach to a revision), the service (a new revision) and the edit form.
++ await H.req(h, 'POST', `/api/v1/pages/${page.id}/revisions/1/citations`, { token: tok, body: { citations: [{ url, retrieved_at: '2026-09-20T00:00:00Z' }] } });
++ try { h.svc.editPage(page.id, { expectedRevision: h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n, body: `${H.LONG} see ${url}`, summary: 'cite', citations: [{ url, retrievedAt: '2026-09-20T00:00:00Z' }] }, owner); } catch { /* refused: fine */ }
++ }
++ const n = h.db.prepare('SELECT max(number) AS n FROM wiki_page_revisions WHERE entity_id = ?').get(page.id).n;
++ try { h.svc.publish(page.id, { revision: n }, owner); } catch { /* the gate may refuse: fine */ }
++ for (const p of ['/w/refs/cited-page', '/w/refs/cited-page/sources', '/w/refs/cited-page/history', `/api/v1/pages/${page.id}`, `/api/v1/pages/${page.id}/revisions/${n}/citations`,
++ `/api/v1/pages/${page.id}/revisions/1/citations`, '/feed.atom', '/feed.json', '/llms.txt', '/sitemaps/pages-1.xml']) {
++ await H.req(h, 'GET', p, { cookie: H.cookieFor(tok) });
++ await H.req(h, 'GET', p);
++ }
++ const hit = outbound.filter((u) => INTERNAL.some((i) => u.startsWith(i)) || /127\.0\.0\.1|localhost|169\.254|\[::|2130706433|0x7f|0177|10\.0\.0\.1|192\.168/.test(u));
++ assert.deepStrictEqual(hit, [], 'the Wiki fetched a cited URL');
++ assert.ok(outbound.every((u) => /^http:\/\/(sources|community|media)\.test\//.test(u)), `only the configured services were called: ${outbound.filter((u) => !/^http:\/\/(sources|community|media)\.test\//.test(u)).join(', ')}`);
++ });
++
++ await check('ratchet: every file that makes an outbound request itself is reviewed', () => {
++ const REVIEWED = {
++ 'server/auth/keys.js': 'Network JWKS (configured)',
++ 'server/auth/session.js': 'Network OAuth token and revoke (configured)',
++ 'server/integrations/platform.js': 'Community, Sources, Media, VIP (configured)',
++ 'server/integrations/projects-module.js': 'Network user modules (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.deepStrictEqual(found.filter((f) => !REVIEWED[f]).sort(), [], 'a new outbound request site: a user-chosen URL goes through openvibe-shared/egress; then add the file here with the reason');
++ });
++ } finally {
++ await h.stop();
++ }
++ if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
++ console.log('\nsecurity-ssrf: all checks passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });