openvibers/openvibe.blog Commits
Security regression suites for the four classes that apply to the Blog (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, each request from a fresh client address so the per-address limits are not what is read.
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 Blog's Express app (t.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;++}++++/** 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,95 @@++'use strict';++/**++ * One author cannot act on another's blog or posts by swapping ids (roadmap WS-R task 5, the IDOR++ * class). security.test.js and delegation.test.js pin role and delegation rules; this suite puts two++ * authors side by side. Bob owns his own blog (so blog-level checks pass for him there) and tries++ * every write that takes an id or a handle with Ann's instead: over the API (her posts by id: edit,++ * publish, unpublish, schedule, revert, review, media; her blog: settings, members, theme, new++ * posts, AI drafts) and over the editor forms with his own valid form token (edit, publish,++ * unpublish, delete, revert, review, schedule; her blog's new-post, settings and members forms).++ * Every refusal must leave every table as it was; controls show the routes work for their owner.++ *++ * node test/security-idor.test.js++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { nextAddress } = require('./security-crawl');++++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');++++(async () => {++ // An AI endpoint is configured (nobody answers there): the AI-draft route then decides who may write.++ const t = await boot({ env: { OV_AI_INTERNAL_URL: 'http://127.0.0.1:9' } });++ const ann = t.network.addUser('ann');++ const bob = t.network.addUser('bob');++ const cat = t.network.addUser('cat');++ await t.get('/api/v1/blogs', { as: ann, json: {} });++ await t.get('/api/v1/blogs', { as: bob, json: {} });++ const mk = async (as, handle, title, visibility = 'public', publish = true) => {++ const p = (await t.get(`/api/v1/blogs/${handle}/posts`, { as, json: { title, body: LONG, visibility } })).json().post;++ if (publish) await t.get(`/api/v1/posts/${p.id}/publish`, { as, json: {} });++ return p;++ };++ const annPost = await mk(ann, 'ann', 'Ann Public');++ const annPrivate = await mk(ann, 'ann', 'Ann Private', 'private');++ const annDraft = await mk(ann, 'ann', 'Ann Draft', 'public', false);++ await mk(bob, 'bob', 'Bob Post');++++ const db = t.ctx.store.db;++ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT IN ('event_outbox')").all().map((r) => r.name);++ const snapshot = () => Object.fromEntries(tables.map((n) => [n, db.prepare(`SELECT * FROM "${n}"`).all()]));++ const same = (before, label) => { const after = snapshot(); for (const n of tables) assert.deepStrictEqual(after[n], before[n], `${label}: ${n} changed`); };++ const refused = (r, what) => assert.ok([401, 403, 404, 409].includes(r.status), `${what}: ${r.status} ${r.text.slice(0, 160)}`);++ const api = (method, p, json, as = bob) => t.get(`/api/v1${p}`, { as, method, json: json || {}, headers: { 'x-forwarded-for': nextAddress() } });++ // The editor's forms live under /write.++ const form = (p, fields, as = bob) => t.get(`/write${p}`, { as, form: { _csrf: t.csrf(as), ...fields }, headers: { origin: 'https://openvibe.blog', 'x-forwarded-for': nextAddress() } });++++ await check('API: Bob cannot touch Ann\'s posts by id or her blog by handle', async () => {++ const before = snapshot();++ for (const p of [annPost, annPrivate, annDraft]) {++ refused(await api('PATCH', `/posts/${p.id}`, { title: 'pwned', body: `${LONG} pwned`, visibility: 'public', expected_revision: 1 }), `PATCH ${p.title}`);++ refused(await api('POST', `/posts/${p.id}/publish`, {}), `publish ${p.title}`);++ refused(await api('POST', `/posts/${p.id}/unpublish`, {}), `unpublish ${p.title}`);++ refused(await api('POST', `/posts/${p.id}/schedule`, { revision: 1, run_at: new Date(Date.now() + 3600e3).toISOString() }), `schedule ${p.title}`);++ refused(await api('POST', `/posts/${p.id}/revert`, { to_revision: 1, expected_revision: 1 }), `revert ${p.title}`);++ refused(await api('POST', `/posts/${p.id}/reviews`, { revision: 1, decision: 'approve' }), `review ${p.title}`);++ refused(await api('POST', `/posts/${p.id}/media/att_x/remove`, {}), `media ${p.title}`);++ refused(await api('DELETE', `/posts/${p.id}`, {}), `DELETE ${p.title}`);++ }++ refused(await api('PATCH', '/blogs/ann', { title: 'pwned', description: 'pwned' }), 'PATCH blog');++ refused(await api('PUT', `/blogs/ann/members/${bob.subject}`, { role: 'owner' }), 'make self owner');++ refused(await api('PUT', `/blogs/ann/members/${cat.subject}`, { role: 'editor' }), 'add Cat');++ refused(await api('DELETE', `/blogs/ann/members/${ann.subject}`), 'remove Ann');++ refused(await api('PUT', '/blogs/ann/theme', { theme: 'dark' }), 'theme');++ refused(await api('POST', '/blogs/ann/posts', { title: 'Bob in Ann', body: LONG }), 'post into Ann\'s blog');++ refused(await api('POST', '/blogs/ann/posts/ai-draft', { prompt: 'x' }), 'AI draft into Ann\'s blog');++ same(before, 'API');++ });++++ await check('forms: Bob, with his own valid form token, cannot post to Ann\'s posts or blog', async () => {++ const before = snapshot();++ for (const p of [annPost, annPrivate, annDraft]) {++ refused(await form(`/posts/${p.id}`, { title: 'pwned', body: `${LONG} pwned`, expectedRevision: '1', visibility: 'public' }), `edit form ${p.title}`);++ for (const a of ['publish', 'unpublish', 'delete', 'revert', 'review', 'schedule', 'unschedule']) {++ refused(await form(`/posts/${p.id}/${a}`, { revision: '1', toRevision: '1', expectedRevision: '1', decision: 'approve', confirm: '1', runAt: new Date(Date.now() + 3600e3).toISOString() }), `${a} form ${p.title}`);++ }++ }++ refused(await form('/@ann/new', { title: 'Bob in Ann', body: LONG }), 'new-post form');++ refused(await form('/@ann/settings', { title: 'pwned' }), 'settings form');++ refused(await form('/@ann/members', { username: 'cat', role: 'editor' }), 'members form');++ refused(await form('/@ann/members/remove', { subject: ann.subject }), 'remove-member form');++ refused(await form('/@ann/ai-draft', { prompt: 'x' }), 'AI-draft form');++ same(before, 'forms');++ });++++ await check('controls: Ann can do what Bob could not', async () => {++ const r = await api('PATCH', '/blogs/ann', { title: 'Ann Blog 2' }, ann);++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));++ const f = await form(`/posts/${annDraft.id}/publish`, { revision: '1' }, ann);++ assert.ok([200, 303].includes(f.status), `publish form: ${f.status}`);++ assert.strictEqual((await t.get('/@ann/ann-draft')).status, 200, 'the draft is published by its author');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++/**++ * Private, draft, members-only and unlisted posts reach nobody who may not read them, on any path++ * (roadmap WS-R task 5, the private-object class). privacy.test.js and members-vip.test.js pin the++ * rules on the known surfaces (cache headers, Search, feeds, sitemaps, the teaser); this suite++ * covers the class: Carol's blog holds a private post, a draft, a members-only post, an unlisted++ * post and an unpublished second revision of a public post, 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 posts' ids, slugs, revision numbers, tags and categories in++ * every parameter, plus the machine surfaces, as anonymous, a signed-in reader and the author of++ * another blog. The private post, the draft and the unpublished revision never appear; the members++ * post's text never does (its title is the teaser, by design); the unlisted post is reachable by its++ * own address (by design) but on no list, feed, sitemap or search. Public events and Search++ * documents carry none of it.++ *++ * node test/security-private.test.js++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { getPaths, crawl } = require('./security-crawl');++++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');++++(async () => {++ const t = await boot({ entitlementCheck: async () => false });++ const carol = t.network.addUser('carol');++ const reader = t.network.addUser('reader');++ const dave = t.network.addUser('dave');++ await t.get('/api/v1/blogs', { as: carol, json: {} });++ await t.get('/api/v1/blogs', { as: dave, json: {} });++ const make = async (title, words, visibility, { publish = true, extra = {} } = {}) => {++ const r = await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title, body: `${words} ${LONG}`, visibility, tags: ['sectag'], category: 'seccat', ...extra } });++ assert.ok([200, 201].includes(r.status), r.text.slice(0, 200));++ const p = r.json().post;++ if (publish) await t.get(`/api/v1/posts/${p.id}/publish`, { as: carol, json: {} });++ return p;++ };++ const SECRET = {++ privTitle: 'Private Diary Title', privWords: 'private-diary-words', draftTitle: 'Draft Idea Title', draftWords: 'draft-idea-words',++ membersWords: 'members-body-words', rev2Words: 'unpublished-revision-words', unlistedWords: 'unlisted-body-words',++ };++ const pub = await make('Open Post', 'open-control-words', 'public');++ const priv = await make(SECRET.privTitle, SECRET.privWords, 'private');++ const draft = await make(SECRET.draftTitle, SECRET.draftWords, 'public', { publish: false });++ const mem = await make('Members Post', SECRET.membersWords, 'members', { extra: { entitlement_key: 'vip:carol' } });++ const unl = await make('Unlisted Post', SECRET.unlistedWords, 'unlisted');++ const edit = await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { body: `${SECRET.rev2Words} ${LONG}`, expected_revision: 1 } });++ const posts = [priv, draft, mem, unl, pub];++++ await check('control: the author reads all of it; the open post is public', async () => {++ assert.ok((await t.get(`/api/v1/posts/${priv.id}`, { as: carol })).text.includes(SECRET.privWords));++ assert.ok((await t.get(`/api/v1/posts/${draft.id}`, { as: carol })).text.includes(SECRET.draftWords));++ assert.strictEqual((await t.get('/@carol/open-post')).status, 200);++ assert.strictEqual(edit.status, 200, `the unpublished second revision exists: ${edit.status} ${edit.text.slice(0, 160)}`);++ assert.ok((await t.get(`/api/v1/posts/${pub.id}/revisions/2`, { as: carol })).text.includes(SECRET.rev2Words));++ });++++ await check('every GET route, page and machine surface: nothing private, draft, members-only or unpublished reaches anonymous, a reader or another author', async () => {++ const values = (name) => {++ if (name === 'handle') return ['carol'];++ if (name === 'slug') return posts.map((p) => p.slug);++ if (name === 'id') return posts.map((p) => p.id);++ if (name === 'n') return [1, 2, 3, 1, 2];++ if (name === 'tag') return ['sectag'];++ if (name === 'category') return ['seccat'];++ if (name === 'who' || name === 'subject') return [carol.subject, 'carol'];++ return posts.map((p) => p.id);++ };++ const paths = getPaths(t.app, values, {++ query: 'q=diary&page=2&before=9999999999999&include_private=1&visibility=private&drafts=1',++ extra: ['/sitemap.xml', '/sitemaps/posts.xml', '/sitemaps/blogs.xml', '/llms.txt', '/updates', '/', '/@carol', '/@carol?page=2', '/blogs/carol/feed',++ '/api/v1/blogs/carol/posts?status=draft', '/api/v1/blogs/carol/posts?visibility=private', '/tags/sectag', '/@carol/tags/sectag', '/@carol/categories/seccat',++ `/api/v1/posts/${pub.id}/revisions/2`, `/api/v1/posts/${pub.id}/diff?from=1&to=2`, `/api/v1/posts/${pub.id}/preview`, '/@carol/open-post?revision=2'],++ });++ const r = await crawl(t, paths, { anonymous: null, reader, 'another author': dave }, () => {++ const n = { ...SECRET };++ delete n.unlistedWords; // reachable at its own address, by design (checked below on the lists)++ return n;++ });++ 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 unlisted post is on no list, feed, sitemap, tag page or search', async () => {++ for (const p of ['/', '/updates', '/@carol', '/blogs/carol/feed', '/sitemap.xml', '/sitemaps/posts.xml', '/llms.txt', '/tags/sectag', '/@carol/tags/sectag',++ '/@carol/categories/seccat', '/api/v1/blogs/carol/posts', '/api/v1/blogs/carol', '/authors/carol', `/@carol/authors/${carol.subject}`]) {++ for (const as of [null, reader]) {++ const r = await t.get(p, as ? { as } : {});++ assert.ok(!r.text.includes(SECRET.unlistedWords) && !r.text.includes(unl.slug), `${p} (${as ? 'reader' : 'anonymous'}) lists the unlisted post`);++ }++ }++ });++++ await check('public events and Search documents carry none of it', async () => {++ const text = JSON.stringify(t.events().filter((e) => e.visibility === 'public' || /index_document\.upserted$/.test(e.event_type)));++ assert.ok(text.includes('Open Post') || text.includes('open-control-words'), 'the open post is there (control)');++ const hits = Object.entries(SECRET).filter(([, w]) => text.includes(w)).map(([k]) => k);++ assert.deepStrictEqual(hits, []);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++/**++ * Blog's secrets never leave in a response or an event (roadmap WS-R task 5, the internal-secret++ * class). The Blog boots with sentinels as its Network OAuth client secret, its form (CSRF) secret++ * and its changelog GitHub token; Network refuses the sentinel client secret, so every call the Blog++ * makes to Community and Media with a service token fails too, and those failures are among the++ * paths read. Every route the booted app has (listed from Express's router stack,++ * test/security-crawl.js) is requested as anonymous, a reader, an author and Network staff, with++ * real and nonsense ids, plus the probes, unknown paths, the sign-in callback with a forged code,++ * and every write route with a broken body and with a well-formed one. No body or header may carry a++ * sentinel (a form token derived from the form secret is fine; the secret itself is not), nor may++ * any event in the outbox.++ *++ * node test/security-secrets.test.js++ */++const assert = require('assert');++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-blog-oauth-client',++ BLOG_FORM_SECRET: 'sentinel-not-a-secret-blog-form',++ CHANGELOG_GITHUB_TOKEN: 'sentinel-not-a-secret-blog-github',++};++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');++++(async () => {++ const t = await boot({ env: SECRETS });++ const author = t.network.addUser('author');++ const reader = t.network.addUser('reader');++ const staff = t.network.addUser('staffer', { role: 'admin' });++ await t.get('/api/v1/blogs', { as: author, json: {} });++ const post = (await t.get('/api/v1/blogs/author/posts', { as: author, json: { title: 'A Post', body: LONG, visibility: 'public' } })).json().post;++ await t.get(`/api/v1/posts/${post.id}/publish`, { as: author, json: {} });++ const people = { anonymous: null, reader, author, staff };++++ await check('every GET route, page and probe, as four people: no sentinel', async () => {++ const nonsense = ['-1', 'pst_00000000000000000000000000', "'\"<x>", 'x'.repeat(300)];++ const values = (name) => (name === 'handle' ? ['author', ...nonsense] : name === 'slug' ? [post.slug, ...nonsense] : [post.id, 1, ...nonsense]);++ const paths = getPaths(t.app, values, {++ query: 'q=x&next=%2F&page=-1&code=forged&state=forged',++ extra: ['/api/health', '/api/ready', '/api/nope', '/nope/nope', '/.env', '/api/%', '/callback?code=forged&state=forged', '/login?next=/', '/changelog', '/me'],++ });++ const r = await crawl(t, 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, broken and well-formed, anonymous and as the author: no sentinel in the answer', async () => {++ const found = [];++ const values = (name) => (name === 'handle' ? ['author'] : name === 'slug' ? [post.slug] : name === 'subject' ? [reader.subject] : name === 'aid' ? ['att_x'] : [post.id]);++ for (const route of listRoutes(t.app)) {++ 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, author]) {++ for (const raw of ['{"broken": ', JSON.stringify({ title: 'x', body: LONG, visibility: 'public', expected_revision: 1, media_id: 'med_x', comment: 'hi' })]) {++ const r = await t.get(p, { method: method.toUpperCase(), ...(as ? { as } : {}), body: raw, headers: { 'content-type': 'application/json', origin: 'https://openvibe.blog', '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 events outbox carries no sentinel', async () => {++ const text = JSON.stringify(t.events());++ for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `outbox carries ${k}`);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * The Blog fetches no URL a user chose (roadmap WS-R task 5, the SSRF class). The URLs people type++ * here (links and images in a post, a cover, a comment) are stored and rendered, never fetched by++ * the Blog: media is attached by OpenVibe.Media object id and read from Media; the changelog reads++ * GitHub (fixed host) for the repositories Network's registry names. This suite writes posts full of++ * internal addresses in every spelling (loopback, decimal, octal, hex, IPv6, mapped, metadata,++ * private ranges, file:, gopher:) and drives every path that handles a post: create, edit, publish,++ * the page, the feed, the sitemap, llms.txt, the API, comments. Every outbound request the process++ * makes is recorded, and none may go to any of those addresses. 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 or fixed hosts, not a user's choice.++ *++ * node test/security-ssrf.test.js++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++// Record every outbound fetch before anything captures fetch.++const outbound = [];++const realFetch = globalThis.fetch;++globalThis.fetch = (url, opts) => { outbound.push(String((url && url.url) || url)); return realFetch(url, opts); };++++const { boot, check, done } = require('./helpers/boot');++const { nextAddress } = require('./security-crawl');++++const PROBE = '/ssrf-probe-path';++const INTERNAL = [++ `http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`, `http://0177.0.0.1${PROBE}`, `http://0x7f000001${PROBE}`, `http://127.1${PROBE}`,++ `http://0.0.0.0${PROBE}`, `http://[::1]${PROBE}`, `http://[::ffff:127.0.0.1]${PROBE}`, `http://[::ffff:7f00:1]${PROBE}`, `http://169.254.169.254/latest/meta-data${PROBE}`,++ `http://[fd00::1]${PROBE}`, `http://10.0.0.1${PROBE}`, `http://192.168.1.1${PROBE}`, `http://localhost:4001${PROBE}`, `file:///etc/passwd${PROBE}`, `gopher://127.0.0.1:6379/_x${PROBE}`,++];++const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');++++(async () => {++ const t = await boot();++ const carol = t.network.addUser('carol');++ const reader = t.network.addUser('reader');++ const opts = (as, extra = {}) => ({ as, headers: { 'x-forwarded-for': nextAddress() }, ...extra });++++ await check('posts full of internal URLs are stored and rendered, never fetched', async () => {++ await t.get('/api/v1/blogs', opts(carol, { json: {} }));++ const body = `${LONG}\n\n${INTERNAL.map((u) => ` [link](${u}) <img src="${u}">`).join('\n')}`;++ const r = await t.get('/api/v1/blogs/carol/posts', opts(carol, { json: { title: 'Linky', body, visibility: 'public', cover_url: INTERNAL[0], canonical_url: INTERNAL[1], image: INTERNAL[2] } }));++ assert.ok([200, 201].includes(r.status), r.text.slice(0, 200));++ const post = r.json().post;++ await t.get(`/api/v1/posts/${post.id}/publish`, opts(carol, { json: {} }));++ await t.get(`/api/v1/posts/${post.id}`, opts(carol, { method: 'PATCH', json: { body: `${body}\nedited`, expected_revision: 1 } }));++ for (const p of ['/@carol/linky', '/@carol', '/blogs/carol/feed', '/sitemap.xml', '/sitemaps/posts.xml', '/llms.txt', `/api/v1/posts/${post.id}`, `/api/v1/posts/${post.id}/revisions`, '/updates', '/']) {++ await t.get(p, opts(reader));++ await t.get(p, { headers: { 'x-forwarded-for': nextAddress() } });++ }++ await t.get('/@carol/linky/comments', opts(reader, { form: { _csrf: t.csrf(reader), body: INTERNAL.join(' ') }, headers: { origin: 'https://openvibe.blog', 'x-forwarded-for': nextAddress() } }));++ const hit = outbound.filter((u) => u.includes(PROBE));++ assert.deepStrictEqual(hit, [], 'the Blog fetched a URL from a post');++ });++++ await check('ratchet: every file that makes an outbound request itself is reviewed', () => {++ const REVIEWED = {++ 'server/auth/sso.js': 'Network JWKS, OAuth token and revoke (configured)',++ 'server/changelog.js': 'Network registry (configured) and api.github.com (fixed host) for the repositories the registry names',++ 'server/clients/community.js': 'OpenVibe.Community (configured)',++ 'server/clients/media.js': 'OpenVibe.Media (configured)',++ 'server/clients/network.js': 'OpenVibe.Network (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 >= 5, `the scan finds the known sites (${found.join(', ')})`);++ 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');++ });++++ 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 Blog's Express app (t.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;
+}
+
+/** 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,95 @@
@@ -0,0 +1,95 @@
+'use strict';
+/**
+ * One author cannot act on another's blog or posts by swapping ids (roadmap WS-R task 5, the IDOR
+ * class). security.test.js and delegation.test.js pin role and delegation rules; this suite puts two
+ * authors side by side. Bob owns his own blog (so blog-level checks pass for him there) and tries
+ * every write that takes an id or a handle with Ann's instead: over the API (her posts by id: edit,
+ * publish, unpublish, schedule, revert, review, media; her blog: settings, members, theme, new
+ * posts, AI drafts) and over the editor forms with his own valid form token (edit, publish,
+ * unpublish, delete, revert, review, schedule; her blog's new-post, settings and members forms).
+ * Every refusal must leave every table as it was; controls show the routes work for their owner.
+ *
+ * node test/security-idor.test.js
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { nextAddress } = require('./security-crawl');
+
+const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
+
+(async () => {
+ // An AI endpoint is configured (nobody answers there): the AI-draft route then decides who may write.
+ const t = await boot({ env: { OV_AI_INTERNAL_URL: 'http://127.0.0.1:9' } });
+ const ann = t.network.addUser('ann');
+ const bob = t.network.addUser('bob');
+ const cat = t.network.addUser('cat');
+ await t.get('/api/v1/blogs', { as: ann, json: {} });
+ await t.get('/api/v1/blogs', { as: bob, json: {} });
+ const mk = async (as, handle, title, visibility = 'public', publish = true) => {
+ const p = (await t.get(`/api/v1/blogs/${handle}/posts`, { as, json: { title, body: LONG, visibility } })).json().post;
+ if (publish) await t.get(`/api/v1/posts/${p.id}/publish`, { as, json: {} });
+ return p;
+ };
+ const annPost = await mk(ann, 'ann', 'Ann Public');
+ const annPrivate = await mk(ann, 'ann', 'Ann Private', 'private');
+ const annDraft = await mk(ann, 'ann', 'Ann Draft', 'public', false);
+ await mk(bob, 'bob', 'Bob Post');
+
+ const db = t.ctx.store.db;
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT IN ('event_outbox')").all().map((r) => r.name);
+ const snapshot = () => Object.fromEntries(tables.map((n) => [n, db.prepare(`SELECT * FROM "${n}"`).all()]));
+ const same = (before, label) => { const after = snapshot(); for (const n of tables) assert.deepStrictEqual(after[n], before[n], `${label}: ${n} changed`); };
+ const refused = (r, what) => assert.ok([401, 403, 404, 409].includes(r.status), `${what}: ${r.status} ${r.text.slice(0, 160)}`);
+ const api = (method, p, json, as = bob) => t.get(`/api/v1${p}`, { as, method, json: json || {}, headers: { 'x-forwarded-for': nextAddress() } });
+ // The editor's forms live under /write.
+ const form = (p, fields, as = bob) => t.get(`/write${p}`, { as, form: { _csrf: t.csrf(as), ...fields }, headers: { origin: 'https://openvibe.blog', 'x-forwarded-for': nextAddress() } });
+
+ await check('API: Bob cannot touch Ann\'s posts by id or her blog by handle', async () => {
+ const before = snapshot();
+ for (const p of [annPost, annPrivate, annDraft]) {
+ refused(await api('PATCH', `/posts/${p.id}`, { title: 'pwned', body: `${LONG} pwned`, visibility: 'public', expected_revision: 1 }), `PATCH ${p.title}`);
+ refused(await api('POST', `/posts/${p.id}/publish`, {}), `publish ${p.title}`);
+ refused(await api('POST', `/posts/${p.id}/unpublish`, {}), `unpublish ${p.title}`);
+ refused(await api('POST', `/posts/${p.id}/schedule`, { revision: 1, run_at: new Date(Date.now() + 3600e3).toISOString() }), `schedule ${p.title}`);
+ refused(await api('POST', `/posts/${p.id}/revert`, { to_revision: 1, expected_revision: 1 }), `revert ${p.title}`);
+ refused(await api('POST', `/posts/${p.id}/reviews`, { revision: 1, decision: 'approve' }), `review ${p.title}`);
+ refused(await api('POST', `/posts/${p.id}/media/att_x/remove`, {}), `media ${p.title}`);
+ refused(await api('DELETE', `/posts/${p.id}`, {}), `DELETE ${p.title}`);
+ }
+ refused(await api('PATCH', '/blogs/ann', { title: 'pwned', description: 'pwned' }), 'PATCH blog');
+ refused(await api('PUT', `/blogs/ann/members/${bob.subject}`, { role: 'owner' }), 'make self owner');
+ refused(await api('PUT', `/blogs/ann/members/${cat.subject}`, { role: 'editor' }), 'add Cat');
+ refused(await api('DELETE', `/blogs/ann/members/${ann.subject}`), 'remove Ann');
+ refused(await api('PUT', '/blogs/ann/theme', { theme: 'dark' }), 'theme');
+ refused(await api('POST', '/blogs/ann/posts', { title: 'Bob in Ann', body: LONG }), 'post into Ann\'s blog');
+ refused(await api('POST', '/blogs/ann/posts/ai-draft', { prompt: 'x' }), 'AI draft into Ann\'s blog');
+ same(before, 'API');
+ });
+
+ await check('forms: Bob, with his own valid form token, cannot post to Ann\'s posts or blog', async () => {
+ const before = snapshot();
+ for (const p of [annPost, annPrivate, annDraft]) {
+ refused(await form(`/posts/${p.id}`, { title: 'pwned', body: `${LONG} pwned`, expectedRevision: '1', visibility: 'public' }), `edit form ${p.title}`);
+ for (const a of ['publish', 'unpublish', 'delete', 'revert', 'review', 'schedule', 'unschedule']) {
+ refused(await form(`/posts/${p.id}/${a}`, { revision: '1', toRevision: '1', expectedRevision: '1', decision: 'approve', confirm: '1', runAt: new Date(Date.now() + 3600e3).toISOString() }), `${a} form ${p.title}`);
+ }
+ }
+ refused(await form('/@ann/new', { title: 'Bob in Ann', body: LONG }), 'new-post form');
+ refused(await form('/@ann/settings', { title: 'pwned' }), 'settings form');
+ refused(await form('/@ann/members', { username: 'cat', role: 'editor' }), 'members form');
+ refused(await form('/@ann/members/remove', { subject: ann.subject }), 'remove-member form');
+ refused(await form('/@ann/ai-draft', { prompt: 'x' }), 'AI-draft form');
+ same(before, 'forms');
+ });
+
+ await check('controls: Ann can do what Bob could not', async () => {
+ const r = await api('PATCH', '/blogs/ann', { title: 'Ann Blog 2' }, ann);
+ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
+ const f = await form(`/posts/${annDraft.id}/publish`, { revision: '1' }, ann);
+ assert.ok([200, 303].includes(f.status), `publish form: ${f.status}`);
+ assert.strictEqual((await t.get('/@ann/ann-draft')).status, 200, 'the draft is published by its author');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+/**
+ * Private, draft, members-only and unlisted posts reach nobody who may not read them, on any path
+ * (roadmap WS-R task 5, the private-object class). privacy.test.js and members-vip.test.js pin the
+ * rules on the known surfaces (cache headers, Search, feeds, sitemaps, the teaser); this suite
+ * covers the class: Carol's blog holds a private post, a draft, a members-only post, an unlisted
+ * post and an unpublished second revision of a public post, 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 posts' ids, slugs, revision numbers, tags and categories in
+ * every parameter, plus the machine surfaces, as anonymous, a signed-in reader and the author of
+ * another blog. The private post, the draft and the unpublished revision never appear; the members
+ * post's text never does (its title is the teaser, by design); the unlisted post is reachable by its
+ * own address (by design) but on no list, feed, sitemap or search. Public events and Search
+ * documents carry none of it.
+ *
+ * node test/security-private.test.js
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { getPaths, crawl } = require('./security-crawl');
+
+const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
+
+(async () => {
+ const t = await boot({ entitlementCheck: async () => false });
+ const carol = t.network.addUser('carol');
+ const reader = t.network.addUser('reader');
+ const dave = t.network.addUser('dave');
+ await t.get('/api/v1/blogs', { as: carol, json: {} });
+ await t.get('/api/v1/blogs', { as: dave, json: {} });
+ const make = async (title, words, visibility, { publish = true, extra = {} } = {}) => {
+ const r = await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title, body: `${words} ${LONG}`, visibility, tags: ['sectag'], category: 'seccat', ...extra } });
+ assert.ok([200, 201].includes(r.status), r.text.slice(0, 200));
+ const p = r.json().post;
+ if (publish) await t.get(`/api/v1/posts/${p.id}/publish`, { as: carol, json: {} });
+ return p;
+ };
+ const SECRET = {
+ privTitle: 'Private Diary Title', privWords: 'private-diary-words', draftTitle: 'Draft Idea Title', draftWords: 'draft-idea-words',
+ membersWords: 'members-body-words', rev2Words: 'unpublished-revision-words', unlistedWords: 'unlisted-body-words',
+ };
+ const pub = await make('Open Post', 'open-control-words', 'public');
+ const priv = await make(SECRET.privTitle, SECRET.privWords, 'private');
+ const draft = await make(SECRET.draftTitle, SECRET.draftWords, 'public', { publish: false });
+ const mem = await make('Members Post', SECRET.membersWords, 'members', { extra: { entitlement_key: 'vip:carol' } });
+ const unl = await make('Unlisted Post', SECRET.unlistedWords, 'unlisted');
+ const edit = await t.get(`/api/v1/posts/${pub.id}`, { as: carol, method: 'PATCH', json: { body: `${SECRET.rev2Words} ${LONG}`, expected_revision: 1 } });
+ const posts = [priv, draft, mem, unl, pub];
+
+ await check('control: the author reads all of it; the open post is public', async () => {
+ assert.ok((await t.get(`/api/v1/posts/${priv.id}`, { as: carol })).text.includes(SECRET.privWords));
+ assert.ok((await t.get(`/api/v1/posts/${draft.id}`, { as: carol })).text.includes(SECRET.draftWords));
+ assert.strictEqual((await t.get('/@carol/open-post')).status, 200);
+ assert.strictEqual(edit.status, 200, `the unpublished second revision exists: ${edit.status} ${edit.text.slice(0, 160)}`);
+ assert.ok((await t.get(`/api/v1/posts/${pub.id}/revisions/2`, { as: carol })).text.includes(SECRET.rev2Words));
+ });
+
+ await check('every GET route, page and machine surface: nothing private, draft, members-only or unpublished reaches anonymous, a reader or another author', async () => {
+ const values = (name) => {
+ if (name === 'handle') return ['carol'];
+ if (name === 'slug') return posts.map((p) => p.slug);
+ if (name === 'id') return posts.map((p) => p.id);
+ if (name === 'n') return [1, 2, 3, 1, 2];
+ if (name === 'tag') return ['sectag'];
+ if (name === 'category') return ['seccat'];
+ if (name === 'who' || name === 'subject') return [carol.subject, 'carol'];
+ return posts.map((p) => p.id);
+ };
+ const paths = getPaths(t.app, values, {
+ query: 'q=diary&page=2&before=9999999999999&include_private=1&visibility=private&drafts=1',
+ extra: ['/sitemap.xml', '/sitemaps/posts.xml', '/sitemaps/blogs.xml', '/llms.txt', '/updates', '/', '/@carol', '/@carol?page=2', '/blogs/carol/feed',
+ '/api/v1/blogs/carol/posts?status=draft', '/api/v1/blogs/carol/posts?visibility=private', '/tags/sectag', '/@carol/tags/sectag', '/@carol/categories/seccat',
+ `/api/v1/posts/${pub.id}/revisions/2`, `/api/v1/posts/${pub.id}/diff?from=1&to=2`, `/api/v1/posts/${pub.id}/preview`, '/@carol/open-post?revision=2'],
+ });
+ const r = await crawl(t, paths, { anonymous: null, reader, 'another author': dave }, () => {
+ const n = { ...SECRET };
+ delete n.unlistedWords; // reachable at its own address, by design (checked below on the lists)
+ return n;
+ });
+ 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 unlisted post is on no list, feed, sitemap, tag page or search', async () => {
+ for (const p of ['/', '/updates', '/@carol', '/blogs/carol/feed', '/sitemap.xml', '/sitemaps/posts.xml', '/llms.txt', '/tags/sectag', '/@carol/tags/sectag',
+ '/@carol/categories/seccat', '/api/v1/blogs/carol/posts', '/api/v1/blogs/carol', '/authors/carol', `/@carol/authors/${carol.subject}`]) {
+ for (const as of [null, reader]) {
+ const r = await t.get(p, as ? { as } : {});
+ assert.ok(!r.text.includes(SECRET.unlistedWords) && !r.text.includes(unl.slug), `${p} (${as ? 'reader' : 'anonymous'}) lists the unlisted post`);
+ }
+ }
+ });
+
+ await check('public events and Search documents carry none of it', async () => {
+ const text = JSON.stringify(t.events().filter((e) => e.visibility === 'public' || /index_document\.upserted$/.test(e.event_type)));
+ assert.ok(text.includes('Open Post') || text.includes('open-control-words'), 'the open post is there (control)');
+ const hits = Object.entries(SECRET).filter(([, w]) => text.includes(w)).map(([k]) => k);
+ assert.deepStrictEqual(hits, []);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+/**
+ * Blog's secrets never leave in a response or an event (roadmap WS-R task 5, the internal-secret
+ * class). The Blog boots with sentinels as its Network OAuth client secret, its form (CSRF) secret
+ * and its changelog GitHub token; Network refuses the sentinel client secret, so every call the Blog
+ * makes to Community and Media with a service token fails too, and those failures are among the
+ * paths read. Every route the booted app has (listed from Express's router stack,
+ * test/security-crawl.js) is requested as anonymous, a reader, an author and Network staff, with
+ * real and nonsense ids, plus the probes, unknown paths, the sign-in callback with a forged code,
+ * and every write route with a broken body and with a well-formed one. No body or header may carry a
+ * sentinel (a form token derived from the form secret is fine; the secret itself is not), nor may
+ * any event in the outbox.
+ *
+ * node test/security-secrets.test.js
+ */
+const assert = require('assert');
+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-blog-oauth-client',
+ BLOG_FORM_SECRET: 'sentinel-not-a-secret-blog-form',
+ CHANGELOG_GITHUB_TOKEN: 'sentinel-not-a-secret-blog-github',
+};
+const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
+
+(async () => {
+ const t = await boot({ env: SECRETS });
+ const author = t.network.addUser('author');
+ const reader = t.network.addUser('reader');
+ const staff = t.network.addUser('staffer', { role: 'admin' });
+ await t.get('/api/v1/blogs', { as: author, json: {} });
+ const post = (await t.get('/api/v1/blogs/author/posts', { as: author, json: { title: 'A Post', body: LONG, visibility: 'public' } })).json().post;
+ await t.get(`/api/v1/posts/${post.id}/publish`, { as: author, json: {} });
+ const people = { anonymous: null, reader, author, staff };
+
+ await check('every GET route, page and probe, as four people: no sentinel', async () => {
+ const nonsense = ['-1', 'pst_00000000000000000000000000', "'\"<x>", 'x'.repeat(300)];
+ const values = (name) => (name === 'handle' ? ['author', ...nonsense] : name === 'slug' ? [post.slug, ...nonsense] : [post.id, 1, ...nonsense]);
+ const paths = getPaths(t.app, values, {
+ query: 'q=x&next=%2F&page=-1&code=forged&state=forged',
+ extra: ['/api/health', '/api/ready', '/api/nope', '/nope/nope', '/.env', '/api/%', '/callback?code=forged&state=forged', '/login?next=/', '/changelog', '/me'],
+ });
+ const r = await crawl(t, 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, broken and well-formed, anonymous and as the author: no sentinel in the answer', async () => {
+ const found = [];
+ const values = (name) => (name === 'handle' ? ['author'] : name === 'slug' ? [post.slug] : name === 'subject' ? [reader.subject] : name === 'aid' ? ['att_x'] : [post.id]);
+ for (const route of listRoutes(t.app)) {
+ 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, author]) {
+ for (const raw of ['{"broken": ', JSON.stringify({ title: 'x', body: LONG, visibility: 'public', expected_revision: 1, media_id: 'med_x', comment: 'hi' })]) {
+ const r = await t.get(p, { method: method.toUpperCase(), ...(as ? { as } : {}), body: raw, headers: { 'content-type': 'application/json', origin: 'https://openvibe.blog', '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 events outbox carries no sentinel', async () => {
+ const text = JSON.stringify(t.events());
+ for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `outbox carries ${k}`);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * The Blog fetches no URL a user chose (roadmap WS-R task 5, the SSRF class). The URLs people type
+ * here (links and images in a post, a cover, a comment) are stored and rendered, never fetched by
+ * the Blog: media is attached by OpenVibe.Media object id and read from Media; the changelog reads
+ * GitHub (fixed host) for the repositories Network's registry names. This suite writes posts full of
+ * internal addresses in every spelling (loopback, decimal, octal, hex, IPv6, mapped, metadata,
+ * private ranges, file:, gopher:) and drives every path that handles a post: create, edit, publish,
+ * the page, the feed, the sitemap, llms.txt, the API, comments. Every outbound request the process
+ * makes is recorded, and none may go to any of those addresses. 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 or fixed hosts, not a user's choice.
+ *
+ * node test/security-ssrf.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+// Record every outbound fetch before anything captures fetch.
+const outbound = [];
+const realFetch = globalThis.fetch;
+globalThis.fetch = (url, opts) => { outbound.push(String((url && url.url) || url)); return realFetch(url, opts); };
+
+const { boot, check, done } = require('./helpers/boot');
+const { nextAddress } = require('./security-crawl');
+
+const PROBE = '/ssrf-probe-path';
+const INTERNAL = [
+ `http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`, `http://0177.0.0.1${PROBE}`, `http://0x7f000001${PROBE}`, `http://127.1${PROBE}`,
+ `http://0.0.0.0${PROBE}`, `http://[::1]${PROBE}`, `http://[::ffff:127.0.0.1]${PROBE}`, `http://[::ffff:7f00:1]${PROBE}`, `http://169.254.169.254/latest/meta-data${PROBE}`,
+ `http://[fd00::1]${PROBE}`, `http://10.0.0.1${PROBE}`, `http://192.168.1.1${PROBE}`, `http://localhost:4001${PROBE}`, `file:///etc/passwd${PROBE}`, `gopher://127.0.0.1:6379/_x${PROBE}`,
+];
+const LONG = Array.from({ length: 100 }, (_, i) => `w${i}`).join(' ');
+
+(async () => {
+ const t = await boot();
+ const carol = t.network.addUser('carol');
+ const reader = t.network.addUser('reader');
+ const opts = (as, extra = {}) => ({ as, headers: { 'x-forwarded-for': nextAddress() }, ...extra });
+
+ await check('posts full of internal URLs are stored and rendered, never fetched', async () => {
+ await t.get('/api/v1/blogs', opts(carol, { json: {} }));
+ const body = `${LONG}\n\n${INTERNAL.map((u) => ` [link](${u}) <img src="${u}">`).join('\n')}`;
+ const r = await t.get('/api/v1/blogs/carol/posts', opts(carol, { json: { title: 'Linky', body, visibility: 'public', cover_url: INTERNAL[0], canonical_url: INTERNAL[1], image: INTERNAL[2] } }));
+ assert.ok([200, 201].includes(r.status), r.text.slice(0, 200));
+ const post = r.json().post;
+ await t.get(`/api/v1/posts/${post.id}/publish`, opts(carol, { json: {} }));
+ await t.get(`/api/v1/posts/${post.id}`, opts(carol, { method: 'PATCH', json: { body: `${body}\nedited`, expected_revision: 1 } }));
+ for (const p of ['/@carol/linky', '/@carol', '/blogs/carol/feed', '/sitemap.xml', '/sitemaps/posts.xml', '/llms.txt', `/api/v1/posts/${post.id}`, `/api/v1/posts/${post.id}/revisions`, '/updates', '/']) {
+ await t.get(p, opts(reader));
+ await t.get(p, { headers: { 'x-forwarded-for': nextAddress() } });
+ }
+ await t.get('/@carol/linky/comments', opts(reader, { form: { _csrf: t.csrf(reader), body: INTERNAL.join(' ') }, headers: { origin: 'https://openvibe.blog', 'x-forwarded-for': nextAddress() } }));
+ const hit = outbound.filter((u) => u.includes(PROBE));
+ assert.deepStrictEqual(hit, [], 'the Blog fetched a URL from a post');
+ });
+
+ await check('ratchet: every file that makes an outbound request itself is reviewed', () => {
+ const REVIEWED = {
+ 'server/auth/sso.js': 'Network JWKS, OAuth token and revoke (configured)',
+ 'server/changelog.js': 'Network registry (configured) and api.github.com (fixed host) for the repositories the registry names',
+ 'server/clients/community.js': 'OpenVibe.Community (configured)',
+ 'server/clients/media.js': 'OpenVibe.Media (configured)',
+ 'server/clients/network.js': 'OpenVibe.Network (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 >= 5, `the scan finds the known sites (${found.join(', ')})`);
+ 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');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });