openvibers/openvibe.live Commits
IDOR and DM privacy suites (roadmap WS-R task 5: IDOR on objects, private room bypass). test/security-idor.test.js tries every write route that takes an object id with another streamer's id: VODs and clips through Live's Media proxy (edit, delete, publish, title, visibility, recut, trim, bulk actions with a list of ids, old-item cleanup, recording chunks and finalize into someone else's stream, a live thumbnail), streams (edit, end, delete, heartbeat, call settings), slots (edit, delete, key regeneration, profile, vibe-coding settings and events; account-key regeneration touches only the caller's key), restream destinations (edit, delete, start, stop, and lists), API tokens, channel emotes, ONVIF cameras and presets. Each refusal leaves the row as it was (or Media uncalled) and carries no key or title; positive controls show each route works for its owner; anonymous gets 401. All held.
Changes
nnew file mode 100644@@@ -0,0 +1,281 @@++'use strict';++/**++ * One streamer cannot act on another streamer's things by swapping ids (roadmap WS-R task 5, the++ * IDOR class). authorization.test.js pins the holes the 2026-09-16 audit found and++ * media-privacy.test.js the private-item ones; this suite walks every write route that takes an++ * object id in Live and tries it with the other streamer's id:++ *++ * - VODs and clips (Media's, through Live's proxy): edit, delete, publish, visibility, title,++ * recut, trim, bulk actions (which take a list of ids), thumbnail regeneration, and uploading++ * recording chunks / a live thumbnail into someone else's stream;++ * - streams and slots: edit, end, delete, the slot's settings, its stream key regeneration (the++ * answer would be the new key), the slot's vibe-coding settings;++ * - restream destinations (each holds a platform stream key): edit, delete, start, stop;++ * - API tokens, channel emotes, ONVIF cameras and their presets.++ *++ * Every refusal must leave the object exactly as it was (the row, or no call to Media at all) and++ * return nothing of it (no key); each area also has a positive control, so a route that simply++ * broke in this harness cannot pass for a refusal.++ *++ * The real routers run on a temp database; sign-in is stubbed by an `x-test-user` header (as in++ * authorization.test.js) and Media by an in-process stub that records every call.++ *++ * node test/security-idor.test.js++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = path.join(os.tmpdir(), `ov-idor-${process.pid}.db`);++process.env.DB_PATH = tmp;++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++console.error = () => {};++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++++const auth = require('../server/auth/auth');++const signIn = (req) => {++ const id = Number(req.headers['x-test-user'] || 0);++ const u = id ? db.getUserById(id) : null;++ if (u) { req.user = u; req.authSource = 'network'; }++ return u;++};++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));++auth.optionalAuth = (req, res, next) => { signIn(req); next(); };++++const addUser = (id, username, role) => raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role, stream_key, created_at)++ VALUES (?, ?, ?, ?, 'x', ?, ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@example.test`, role, `${String(id).repeat(32)}`);++const ALICE = 3, BOB = 4;++addUser(ALICE, 'alice', 'streamer');++addUser(BOB, 'bob', 'streamer');++db.ensureChannel(ALICE); db.ensureChannel(BOB);++const chanA = db.getChannelByUserId(ALICE);++const SLOT_KEY = 'ab'.repeat(20);++const slotA = Number(db.createManagedStream({ user_id: ALICE, channel_id: chanA.id, slug: 'main', title: 'Alice main', protocol: 'rtmp', stream_key: SLOT_KEY }).lastInsertRowid);++const streamA = Number(db.createStream({ user_id: ALICE, channel_id: chanA.id, managed_stream_id: slotA, title: 'Alice live', protocol: 'rtmp' }).lastInsertRowid);++const DEST_KEY = 'restream-idor-key-cdcd';++const destA = Number(db.createRestreamDestination(ALICE, { platform: 'custom', name: 'Alice mirror', server_url: 'rtmp://ingest.example.test/live', stream_key: DEST_KEY, managed_stream_id: slotA }).lastInsertRowid);++const tokenA = db.createApiToken(ALICE, 'Alice bot', ['chat', 'read']).id;++const emoteA = Number(raw.prepare("INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (?, 'aliceWave', '/api/emotes/file/none.png', ?)").run(ALICE, ALICE).lastInsertRowid);++const camA = Number(raw.prepare("INSERT INTO camera_profiles (user_id, stream_id, name, onvif_url, username, password_hash) VALUES (?, ?, 'Alice cam', 'http://camera.example.test', 'admin', 'x')").run(ALICE, streamA).lastInsertRowid);++const presetA = Number(raw.prepare("INSERT INTO camera_presets (camera_id, name, pan, tilt, zoom) VALUES (?, 'Desk', 0.5, 0.5, 0.5)").run(camA).lastInsertRowid);++// Bob has his own slot and stream, so "my slot" routes have something of his to compare against.++const chanB = db.getChannelByUserId(BOB);++const slotB = Number(db.createManagedStream({ user_id: BOB, channel_id: chanB.id, slug: 'main', title: 'Bob main', protocol: 'rtmp', stream_key: 'ef'.repeat(20) }).lastInsertRowid);++++// ── Media stub: records every call; only reads answer from the table ──++const media = require('../server/media-client');++const VODS = { 100: { id: 100, user_id: ALICE, stream_id: streamA, title: 'Alice VOD', visibility: 'public', is_public: 1, status: 'ready', duration_seconds: 60 } };++const CLIPS = { 200: { id: 200, user_id: ALICE, channel_user_id: ALICE, vod_id: 100, title: 'Alice clip', visibility: 'public', is_public: 1, status: 'ready', start_time: 0, end_time: 5 } };++const mediaCalls = [];++const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });++const READS = {++ getVod: async (id) => { const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; },++ getClip: async (id) => { const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; },++ listVods: async (q = {}) => ({ vods: Object.values(VODS).filter((v) => q.user_id == null || v.user_id === Number(q.user_id)).map((v) => ({ ...v, created_at: '2020-01-01 00:00:00' })) }),++ listClips: async (q = {}) => ({ clips: Object.values(CLIPS).filter((c) => q.user_id == null || c.user_id === Number(q.user_id)).map((c) => ({ ...c, created_at: '2020-01-01 00:00:00' })) }),++ getJob: async () => ({ status: 'done' }),++};++for (const k of Object.keys(media)) {++ if (typeof media[k] !== 'function' || /^[A-Z]/.test(k) || /Url$/.test(k) || ['request', 'proxy', 'actingUserFrom', '_formData', 'publicUrl'].includes(k)) continue;++ media[k] = async (...args) => { if (!READS[k]) mediaCalls.push([k, ...args]); return READS[k] ? READS[k](...args) : { id: 999, url: '/t/x.jpg', status: 'processing' }; };++}++const writesTo = (kind, id) => mediaCalls.filter(([k, ...a]) => !READS[k] && (k.toLowerCase().includes(kind) || k === 'generateThumbnail') && JSON.stringify(a).includes(String(id)));++++const principal = require('../server/net/network-principal');++principal.serviceHeaders = async () => ({ Authorization: 'Bearer test-service-token' });++const recorder = require('../server/streaming/recorder');++recorder.getActiveRecording = () => null;++recorder.finalizeStream = async () => null;++++const express = require('express');++const app = express();++app.use(express.json());++app.use('/api/auth', require('../server/auth/routes'));++app.use('/api/streams', require('../server/streaming/routes'));++app.use('/api/restream', require('../server/streaming/restream-routes'));++app.use('/api/vods', require('../server/media-proxy/vods'));++app.use('/api/clips', require('../server/media-proxy/clips'));++app.use('/api/thumbnails', require('../server/media-proxy/thumbnails'));++app.use('/api/emotes', require('../server/emotes/routes'));++app.use('/api/onvif', require('../server/controls/onvif-routes'));++app.use('/api/vibe-coding', require('../server/vibe-coding/routes'));++const server = http.createServer(app).listen(0, '127.0.0.1');++++let ipSeq = 0;++function call(method, p, user, body, { contentType = 'application/json', rawBody = null } = {}) {++ return new Promise((resolve, reject) => {++ const data = rawBody != null ? rawBody : (body ? JSON.stringify(body) : null);++ const headers = { 'content-type': contentType, 'cf-connecting-ip': `10.9.0.${++ipSeq % 250}` };++ if (user) headers['x-test-user'] = String(user);++ const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method, headers, agent: false }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++}++const refused = (r, what) => {++ assert.ok([401, 403, 404].includes(r.status), `${what}: expected a refusal, got ${r.status} ${r.text.slice(0, 200)}`);++ for (const k of [SLOT_KEY, DEST_KEY, 'Alice VOD', 'Alice clip']) assert.ok(!r.text.includes(k), `${what}: the answer carries "${k}"`);++};++const row = (table, id) => raw.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(id);++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', String(e.stack || e.message).split('\n').slice(0, 6).join('\n ')); }++}++++(async () => {++ await new Promise((r) => server.once('listening', r));++ quiet('idor: Bob uses Alice\'s ids');++++ await check('VODs: edit, delete, publish, bulk and old-VOD cleanup never reach Media for Alice\'s VOD', async () => {++ refused(await call('PUT', '/api/vods/100', BOB, { title: 'pwned', visibility: 'private' }), 'PUT vod');++ refused(await call('DELETE', '/api/vods/100', BOB), 'DELETE vod');++ refused(await call('POST', '/api/vods/100/publish', BOB), 'publish vod');++ const bulk = await call('POST', '/api/vods/bulk', BOB, { ids: [100], action: 'delete' });++ assert.deepStrictEqual([bulk.status, bulk.json && bulk.json.done], [200, 0], bulk.text);++ await call('POST', '/api/vods/bulk', BOB, { ids: [100], action: 'private' });++ await call('POST', '/api/vods/bulk-delete-old', BOB, { older_than_days: 1, vods: true, clips: true, action: 'delete' });++ assert.deepStrictEqual(writesTo('vod', 100), []);++ });++ await check('clips: title, visibility, delete, recut, trim and bulk never reach Media for Alice\'s clip', async () => {++ refused(await call('PUT', '/api/clips/200/title', BOB, { title: 'pwned' }), 'clip title');++ refused(await call('PUT', '/api/clips/200/visibility', BOB, { visibility: 'private' }), 'clip visibility');++ refused(await call('DELETE', '/api/clips/200', BOB), 'DELETE clip');++ refused(await call('POST', '/api/clips/200/recut', BOB), 'recut');++ const trim = await call('POST', '/api/vods/clips/200/trim', BOB, { start_time: 1, end_time: 2 });++ assert.ok([403, 404, 410, 400, 501].includes(trim.status), `trim: ${trim.status} ${trim.text.slice(0, 120)}`);++ const bulk = await call('POST', '/api/clips/bulk', BOB, { ids: [200], action: 'delete' });++ assert.deepStrictEqual([bulk.status, bulk.json && bulk.json.done], [200, 0], bulk.text);++ assert.deepStrictEqual(writesTo('clip', 200), []);++ });++ // (Regenerating a PUBLIC item's thumbnail is open to anyone on purpose: pages repair a broken++ // thumbnail lazily, the frame is derived from the video, and there are no custom thumbnails to++ // overwrite. Private items are media-privacy.test.js's.)++ await check('thumbnails: Bob cannot post a live thumbnail into Alice\'s stream', async () => {++ const before = mediaCalls.length;++ const r = await call('POST', `/api/thumbnails/live/${streamA}`, BOB, null, { contentType: 'multipart/form-data; boundary=x', rawBody: '--x\r\nContent-Disposition: form-data; name="thumbnail"; filename="t.jpg"\r\nContent-Type: image/jpeg\r\n\r\nJPEG\r\n--x--\r\n' });++ refused(r, 'live thumbnail');++ assert.strictEqual(mediaCalls.length, before, JSON.stringify(mediaCalls.slice(before)));++ });++ await check('recording chunks: Bob cannot upload into or finalize Alice\'s stream\'s VOD', async () => {++ const before = mediaCalls.length;++ refused(await call('POST', `/api/vods/stream/${streamA}/chunk`, BOB, null, { contentType: 'multipart/form-data; boundary=x', rawBody: '--x\r\nContent-Disposition: form-data; name="chunk"; filename="c.webm"\r\nContent-Type: video/webm\r\n\r\nDATA\r\n--x--\r\n' }), 'chunk');++ refused(await call('POST', `/api/vods/stream/${streamA}/finalize`, BOB, {}), 'finalize');++ assert.strictEqual(mediaCalls.length, before, JSON.stringify(mediaCalls.slice(before)));++ });++ await check('control: Alice can edit her own VOD and clip (the routes work in this harness)', async () => {++ assert.strictEqual((await call('PUT', '/api/vods/100', ALICE, { title: 'Alice renamed' })).status, 200);++ assert.strictEqual((await call('PUT', '/api/clips/200/title', ALICE, { title: 'Alice renamed' })).status, 200);++ assert.ok(writesTo('vod', 100).length >= 1 && writesTo('clip', 200).length >= 1);++ mediaCalls.length = 0;++ });++++ await check('streams: Bob cannot edit, end or delete Alice\'s stream', async () => {++ const before = row('streams', streamA);++ refused(await call('PUT', `/api/streams/${streamA}`, BOB, { title: 'pwned', is_nsfw: 1 }), 'PUT stream');++ refused(await call('DELETE', `/api/streams/${streamA}`, BOB), 'DELETE stream');++ refused(await call('POST', `/api/streams/${streamA}/heartbeat`, BOB, {}), 'heartbeat');++ refused(await call('PUT', `/api/streams/${streamA}/call`, BOB, { mode: 'open' }), 'call settings');++ assert.deepStrictEqual(row('streams', streamA), before);++ });++ await check('slots: Bob cannot edit, delete or regenerate the key of Alice\'s slot, and never sees a key', async () => {++ const before = row('managed_streams', slotA);++ refused(await call('PUT', `/api/streams/managed/${slotA}`, BOB, { title: 'pwned', stream_key: 'x'.repeat(40), user_id: BOB }), 'PUT slot');++ refused(await call('POST', `/api/streams/managed/${slotA}/regenerate-key`, BOB), 'regenerate slot key');++ refused(await call('DELETE', `/api/streams/managed/${slotA}`, BOB), 'DELETE slot');++ refused(await call('GET', `/api/streams/managed/${slotA}/profile`, BOB), 'slot profile');++ assert.deepStrictEqual(row('managed_streams', slotA), before);++ const mine = await call('GET', '/api/streams/managed', BOB);++ assert.strictEqual(mine.status, 200);++ assert.ok(!mine.text.includes(SLOT_KEY) && !mine.text.includes('Alice main'), 'Bob\'s slot list is his own');++ });++ await check('slots: Bob\'s account-key regeneration changes only his own key', async () => {++ const r = await call('POST', '/api/auth/stream-key/regenerate', BOB, { user_id: ALICE, managed_stream_id: slotA });++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));++ assert.strictEqual(db.getUserById(ALICE).stream_key, '3'.repeat(32));++ assert.strictEqual(row('managed_streams', slotA).stream_key, SLOT_KEY);++ });++ await check('vibe-coding: Bob cannot change Alice\'s slot settings', async () => {++ refused(await call('PUT', `/api/vibe-coding/managed/${slotA}/settings`, BOB, { enabled: true, repo: 'bob/pwn' }), 'PUT settings');++ refused(await call('GET', `/api/vibe-coding/managed/${slotA}/settings`, BOB), 'GET settings');++ refused(await call('GET', `/api/vibe-coding/managed/${slotA}/events`, BOB), 'GET events');++ assert.strictEqual((await call('GET', `/api/vibe-coding/managed/${slotA}/settings`, ALICE)).status, 200, 'control: Alice reads hers');++ });++ await check('control: Alice can edit her own slot', async () => {++ const r = await call('PUT', `/api/streams/managed/${slotA}`, ALICE, { title: 'Alice main' });++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));++ });++++ await check('restream destinations: Bob cannot edit, delete, start or stop Alice\'s, and his list holds none of hers', async () => {++ const before = row('restream_destinations', destA);++ refused(await call('PUT', `/api/restream/destinations/${destA}`, BOB, { server_url: 'rtmp://bob.example.test/live', stream_key: 'bob' }), 'PUT destination');++ refused(await call('POST', `/api/restream/destinations/${destA}/start`, BOB), 'start destination');++ refused(await call('POST', `/api/restream/destinations/${destA}/stop`, BOB), 'stop destination');++ refused(await call('DELETE', `/api/restream/destinations/${destA}`, BOB), 'DELETE destination');++ const after = row('restream_destinations', destA);++ assert.deepStrictEqual({ ...after, updated_at: null }, { ...before, updated_at: null });++ const list = await call('GET', '/api/restream/destinations?all=1', BOB);++ assert.strictEqual(list.status, 200);++ assert.ok(!list.text.includes('Alice mirror') && !list.text.includes(DEST_KEY));++ });++ await check('control: Alice sees her destination, with the key masked', async () => {++ const list = await call('GET', '/api/restream/destinations?all=1', ALICE);++ assert.ok(list.text.includes('Alice mirror'));++ assert.ok(!list.text.includes(DEST_KEY), 'even the owner gets a masked key back');++ });++++ await check('API tokens: Bob cannot revoke Alice\'s token', async () => {++ refused(await call('DELETE', `/api/auth/tokens/${tokenA}`, BOB), 'DELETE token');++ assert.strictEqual(row('api_tokens', tokenA).is_active, 1);++ });++ await check('emotes: Bob cannot rename or delete Alice\'s channel emote', async () => {++ const before = row('emotes', emoteA);++ refused(await call('PATCH', `/api/emotes/${emoteA}`, BOB, { code: 'bobPwn' }), 'PATCH emote');++ refused(await call('DELETE', `/api/emotes/${emoteA}`, BOB), 'DELETE emote');++ assert.deepStrictEqual(row('emotes', emoteA), before);++ });++ await check('ONVIF cameras: Bob cannot read, edit or delete Alice\'s camera or its presets', async () => {++ const before = [row('camera_profiles', camA), row('camera_presets', presetA)];++ refused(await call('GET', `/api/onvif/cameras/${camA}`, BOB), 'GET camera');++ refused(await call('GET', `/api/onvif/cameras/${camA}/presets`, BOB), 'GET presets');++ refused(await call('PUT', `/api/onvif/cameras/${camA}`, BOB, { name: 'pwned', onvif_url: 'http://bob.example.test' }), 'PUT camera');++ refused(await call('POST', `/api/onvif/cameras/${camA}/presets`, BOB, { name: 'x', pan: 0, tilt: 0, zoom: 0 }), 'POST preset');++ refused(await call('DELETE', `/api/onvif/cameras/${camA}/presets/${presetA}`, BOB), 'DELETE preset');++ refused(await call('DELETE', `/api/onvif/cameras/${camA}`, BOB), 'DELETE camera');++ assert.deepStrictEqual([row('camera_profiles', camA), row('camera_presets', presetA)], before);++ });++ await check('control: Alice can rename her emote, read her camera and revoke her token', async () => {++ assert.strictEqual((await call('PATCH', `/api/emotes/${emoteA}`, ALICE, { code: 'aliceWave2' })).status, 200);++ assert.strictEqual((await call('GET', `/api/onvif/cameras/${camA}`, ALICE)).status, 200);++ assert.strictEqual((await call('DELETE', `/api/auth/tokens/${tokenA}`, ALICE)).status, 200);++ });++ await check('anonymous callers get 401 on all of them', async () => {++ for (const [m, p] of [['PUT', '/api/vods/100'], ['DELETE', '/api/clips/200'], ['PUT', `/api/streams/managed/${slotA}`], ['POST', `/api/streams/managed/${slotA}/regenerate-key`],++ ['DELETE', `/api/restream/destinations/${destA}`], ['DELETE', `/api/auth/tokens/${tokenA}`], ['DELETE', `/api/emotes/${emoteA}`], ['DELETE', `/api/onvif/cameras/${camA}`]]) {++ const r = await call(m, p, null, {}).catch((e) => ({ status: 0, text: e.message }));++ assert.strictEqual(r.status, 401, `${m} ${p}: ${r.status} ${r.text}`);++ }++ assert.strictEqual(row('managed_streams', slotA).stream_key, SLOT_KEY);++ assert.ok(row('managed_streams', slotB));++ });++++ server.close();++ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }++ if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }++ quiet('\nsecurity-idor: all checks passed');++ process.exit(0);++})().catch((e) => { quiet(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,170 @@++'use strict';++/**++ * Direct messages stay between their participants on every HTTP path (roadmap WS-R task 5, the++ * private-room class). dm-delivery.test.js pins live delivery (dm.isParticipant before a socket++ * gets a message) and authorization.test.js that a 1:1 cannot grow a third person; this suite++ * walks every /api/dm route as someone outside the conversation, as a participant who was removed++ * from a group, and anonymously:++ *++ * read the conversation, its messages (every cursor form), the conversation list and the unread++ * count; post into it, mark it read, rename it, add themselves or someone else, remove a++ * participant, delete a participant's message.++ *++ * Each refusal leaves the conversation as it was and returns none of its text. (Private VODs and++ * clips are media-privacy.test.js's; with Chat serving /api/dm, OpenVibe.Chat has the same suite.)++ *++ * node test/security-private.test.js++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = path.join(os.tmpdir(), `ov-private-${process.pid}.db`);++process.env.DB_PATH = tmp;++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++console.error = () => {};++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++const dm = require('../server/chat/dm');++dm.ensureTables();++++const auth = require('../server/auth/auth');++const signIn = (req) => {++ const id = Number(req.headers['x-test-user'] || 0);++ const u = id ? db.getUserById(id) : null;++ if (u) { req.user = u; req.authSource = 'network'; }++ return u;++};++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));++auth.optionalAuth = (req, res, next) => { signIn(req); next(); };++++const addUser = (id, username, role = 'user') => raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)++ VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@example.test`, role);++const ANN = 11, BEN = 12, CAT = 13, EVE = 14, MOD = 15;++addUser(ANN, 'ann'); addUser(BEN, 'ben'); addUser(CAT, 'cat'); addUser(EVE, 'eve'); addUser(MOD, 'moddy', 'global_mod');++++// A 1:1 between Ann and Ben, and a group Ann made with Ben and Cat (Cat is removed later).++const directId = Number(dm.getOrCreateDirect(ANN, BEN));++const groupId = Number(dm.createConversation(ANN, [ANN, BEN, CAT], 'Plans'));++const say = (conv, who, text) => Number(dm.sendMessage(conv, who, text).id);++const msgDirect = say(directId, BEN, 'the secret direct message');++const msgGroupOld = say(groupId, BEN, 'the secret group message');++++const express = require('express');++const app = express();++app.use(express.json());++app.use('/api/dm', require('../server/chat/dm-routes'));++const server = http.createServer(app).listen(0, '127.0.0.1');++function call(method, p, user, body) {++ return new Promise((resolve, reject) => {++ const data = body ? JSON.stringify(body) : null;++ const headers = { 'content-type': 'application/json' };++ if (user) headers['x-test-user'] = String(user);++ const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method, headers, agent: false }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++}++const SECRETS = ['secret direct', 'secret group', 'Plans'];++const snapshot = () => ({++ conversations: raw.prepare('SELECT * FROM dm_conversations ORDER BY id').all(),++ participants: raw.prepare('SELECT conversation_id, user_id FROM dm_participants ORDER BY conversation_id, user_id').all(),++ messages: raw.prepare('SELECT id, conversation_id, sender_id, message FROM dm_messages ORDER BY id').all().map((m) => ({ ...m })),++});++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', String(e.stack || e.message).split('\n').slice(0, 6).join('\n ')); }++}++++/** Every DM path against conversation `id`, as `user`: all must refuse and carry nothing. */++async function outsider(user, id, label) {++ const before = snapshot();++ const reads = [++ ['GET', `/api/dm/conversations/${id}`], ['GET', `/api/dm/conversations/${id}/messages`],++ ['GET', `/api/dm/conversations/${id}/messages?before=999999`], ['GET', `/api/dm/conversations/${id}/messages?after=0`],++ ['GET', `/api/dm/conversations/${id}/messages?limit=500`],++ ];++ const writes = [++ ['POST', `/api/dm/conversations/${id}/messages`, { message: 'hi', text: 'hi' }],++ ['POST', `/api/dm/conversations/${id}/read`, {}],++ ['PATCH', `/api/dm/conversations/${id}`, { name: 'pwned' }],++ ['POST', `/api/dm/conversations/${id}/participants`, { user_id: user || EVE }],++ ['POST', `/api/dm/conversations/${id}/participants`, { user_id: MOD }],++ ['DELETE', `/api/dm/conversations/${id}/participants/${BEN}`],++ ['DELETE', `/api/dm/conversations/${id}/messages/${msgDirect}`],++ ['DELETE', `/api/dm/conversations/${id}/messages/${msgGroupOld}`],++ ];++ for (const [m, p, body] of [...reads, ...writes]) {++ const r = await call(m, p, user, body);++ assert.ok([401, 403, 404].includes(r.status), `${label}: ${m} ${p} → ${r.status} ${r.text.slice(0, 160)}`);++ for (const s of SECRETS) assert.ok(!r.text.includes(s), `${label}: ${m} ${p} carries "${s}"`);++ }++ assert.deepStrictEqual(snapshot(), before, `${label}: nothing changed`);++}++++(async () => {++ await new Promise((r) => server.once('listening', r));++ quiet('private: direct messages');++++ await check('control: participants read their conversations (the routes work in this harness)', async () => {++ const r = await call('GET', `/api/dm/conversations/${directId}/messages`, ANN);++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));++ assert.ok(r.text.includes('the secret direct message'));++ const g = await call('GET', `/api/dm/conversations/${groupId}/messages`, CAT);++ assert.strictEqual(g.status, 200);++ assert.ok(g.text.includes('the secret group message'));++ });++ await check('an outsider gets nothing from a 1:1 on any path, and changes nothing', () => outsider(EVE, directId, 'outsider/direct'));++ await check('an outsider gets nothing from a group on any path, and changes nothing', () => outsider(EVE, groupId, 'outsider/group'));++ await check('a global mod is an outsider to other people\'s DMs too', () => outsider(MOD, directId, 'mod/direct'));++ await check('anonymous gets 401 everywhere', () => outsider(null, directId, 'anonymous'));++ await check('the outsider\'s own lists hold none of it (conversations, unread, search)', async () => {++ const list = await call('GET', '/api/dm/conversations', EVE);++ assert.strictEqual(list.status, 200);++ for (const s of SECRETS) assert.ok(!list.text.includes(s), list.text.slice(0, 200));++ const unread = await call('GET', '/api/dm/unread', EVE);++ assert.strictEqual(unread.status, 200);++ assert.ok(!/[1-9]/.test(JSON.stringify(unread.json)), `unread: ${unread.text}`);++ const search = await call('GET', '/api/dm/users/search?q=ann', EVE);++ assert.ok(!search.text.includes('@example.test'), 'no email in user search');++ });++ await check('a participant removed from a group loses every path from then on', async () => {++ const r = await call('DELETE', `/api/dm/conversations/${groupId}/participants/${CAT}`, ANN);++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));++ say(groupId, BEN, 'the secret group message after cat left');++ await outsider(CAT, groupId, 'removed/group');++ const list = await call('GET', '/api/dm/conversations', CAT);++ assert.ok(!list.text.includes('Plans'), 'the group left her list');++ });++ await check('a participant of one conversation cannot reach another through it (ids swapped)', async () => {++ // Ann is in both; Eve starts her own 1:1 with Ann and tries Ann's other conversations' message ids.++ const mineId = Number(dm.getOrCreateDirect(EVE, ANN));++ const before = snapshot();++ const del = await call('DELETE', `/api/dm/conversations/${mineId}/messages/${msgDirect}`, EVE);++ assert.ok([403, 404].includes(del.status), `delete through her own conversation: ${del.status}`);++ const page = await call('GET', `/api/dm/conversations/${mineId}/messages?before=${msgDirect + 1}`, EVE);++ assert.strictEqual(page.status, 200);++ for (const s of SECRETS) assert.ok(!page.text.includes(s), `her own conversation's page carries "${s}"`);++ assert.deepStrictEqual(snapshot(), before);++ });++++ server.close();++ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }++ if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }++ quiet('\nsecurity-private: all checks passed');++ process.exit(0);++})().catch((e) => { quiet(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,281 @@
@@ -0,0 +1,281 @@
+'use strict';
+/**
+ * One streamer cannot act on another streamer's things by swapping ids (roadmap WS-R task 5, the
+ * IDOR class). authorization.test.js pins the holes the 2026-09-16 audit found and
+ * media-privacy.test.js the private-item ones; this suite walks every write route that takes an
+ * object id in Live and tries it with the other streamer's id:
+ *
+ * - VODs and clips (Media's, through Live's proxy): edit, delete, publish, visibility, title,
+ * recut, trim, bulk actions (which take a list of ids), thumbnail regeneration, and uploading
+ * recording chunks / a live thumbnail into someone else's stream;
+ * - streams and slots: edit, end, delete, the slot's settings, its stream key regeneration (the
+ * answer would be the new key), the slot's vibe-coding settings;
+ * - restream destinations (each holds a platform stream key): edit, delete, start, stop;
+ * - API tokens, channel emotes, ONVIF cameras and their presets.
+ *
+ * Every refusal must leave the object exactly as it was (the row, or no call to Media at all) and
+ * return nothing of it (no key); each area also has a positive control, so a route that simply
+ * broke in this harness cannot pass for a refusal.
+ *
+ * The real routers run on a temp database; sign-in is stubbed by an `x-test-user` header (as in
+ * authorization.test.js) and Media by an in-process stub that records every call.
+ *
+ * node test/security-idor.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = path.join(os.tmpdir(), `ov-idor-${process.pid}.db`);
+process.env.DB_PATH = tmp;
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+console.error = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+
+const auth = require('../server/auth/auth');
+const signIn = (req) => {
+ const id = Number(req.headers['x-test-user'] || 0);
+ const u = id ? db.getUserById(id) : null;
+ if (u) { req.user = u; req.authSource = 'network'; }
+ return u;
+};
+auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
+auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
+
+const addUser = (id, username, role) => raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role, stream_key, created_at)
+ VALUES (?, ?, ?, ?, 'x', ?, ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@example.test`, role, `${String(id).repeat(32)}`);
+const ALICE = 3, BOB = 4;
+addUser(ALICE, 'alice', 'streamer');
+addUser(BOB, 'bob', 'streamer');
+db.ensureChannel(ALICE); db.ensureChannel(BOB);
+const chanA = db.getChannelByUserId(ALICE);
+const SLOT_KEY = 'ab'.repeat(20);
+const slotA = Number(db.createManagedStream({ user_id: ALICE, channel_id: chanA.id, slug: 'main', title: 'Alice main', protocol: 'rtmp', stream_key: SLOT_KEY }).lastInsertRowid);
+const streamA = Number(db.createStream({ user_id: ALICE, channel_id: chanA.id, managed_stream_id: slotA, title: 'Alice live', protocol: 'rtmp' }).lastInsertRowid);
+const DEST_KEY = 'restream-idor-key-cdcd';
+const destA = Number(db.createRestreamDestination(ALICE, { platform: 'custom', name: 'Alice mirror', server_url: 'rtmp://ingest.example.test/live', stream_key: DEST_KEY, managed_stream_id: slotA }).lastInsertRowid);
+const tokenA = db.createApiToken(ALICE, 'Alice bot', ['chat', 'read']).id;
+const emoteA = Number(raw.prepare("INSERT INTO emotes (user_id, code, url, channel_owner_id) VALUES (?, 'aliceWave', '/api/emotes/file/none.png', ?)").run(ALICE, ALICE).lastInsertRowid);
+const camA = Number(raw.prepare("INSERT INTO camera_profiles (user_id, stream_id, name, onvif_url, username, password_hash) VALUES (?, ?, 'Alice cam', 'http://camera.example.test', 'admin', 'x')").run(ALICE, streamA).lastInsertRowid);
+const presetA = Number(raw.prepare("INSERT INTO camera_presets (camera_id, name, pan, tilt, zoom) VALUES (?, 'Desk', 0.5, 0.5, 0.5)").run(camA).lastInsertRowid);
+// Bob has his own slot and stream, so "my slot" routes have something of his to compare against.
+const chanB = db.getChannelByUserId(BOB);
+const slotB = Number(db.createManagedStream({ user_id: BOB, channel_id: chanB.id, slug: 'main', title: 'Bob main', protocol: 'rtmp', stream_key: 'ef'.repeat(20) }).lastInsertRowid);
+
+// ── Media stub: records every call; only reads answer from the table ──
+const media = require('../server/media-client');
+const VODS = { 100: { id: 100, user_id: ALICE, stream_id: streamA, title: 'Alice VOD', visibility: 'public', is_public: 1, status: 'ready', duration_seconds: 60 } };
+const CLIPS = { 200: { id: 200, user_id: ALICE, channel_user_id: ALICE, vod_id: 100, title: 'Alice clip', visibility: 'public', is_public: 1, status: 'ready', start_time: 0, end_time: 5 } };
+const mediaCalls = [];
+const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });
+const READS = {
+ getVod: async (id) => { const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; },
+ getClip: async (id) => { const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; },
+ listVods: async (q = {}) => ({ vods: Object.values(VODS).filter((v) => q.user_id == null || v.user_id === Number(q.user_id)).map((v) => ({ ...v, created_at: '2020-01-01 00:00:00' })) }),
+ listClips: async (q = {}) => ({ clips: Object.values(CLIPS).filter((c) => q.user_id == null || c.user_id === Number(q.user_id)).map((c) => ({ ...c, created_at: '2020-01-01 00:00:00' })) }),
+ getJob: async () => ({ status: 'done' }),
+};
+for (const k of Object.keys(media)) {
+ if (typeof media[k] !== 'function' || /^[A-Z]/.test(k) || /Url$/.test(k) || ['request', 'proxy', 'actingUserFrom', '_formData', 'publicUrl'].includes(k)) continue;
+ media[k] = async (...args) => { if (!READS[k]) mediaCalls.push([k, ...args]); return READS[k] ? READS[k](...args) : { id: 999, url: '/t/x.jpg', status: 'processing' }; };
+}
+const writesTo = (kind, id) => mediaCalls.filter(([k, ...a]) => !READS[k] && (k.toLowerCase().includes(kind) || k === 'generateThumbnail') && JSON.stringify(a).includes(String(id)));
+
+const principal = require('../server/net/network-principal');
+principal.serviceHeaders = async () => ({ Authorization: 'Bearer test-service-token' });
+const recorder = require('../server/streaming/recorder');
+recorder.getActiveRecording = () => null;
+recorder.finalizeStream = async () => null;
+
+const express = require('express');
+const app = express();
+app.use(express.json());
+app.use('/api/auth', require('../server/auth/routes'));
+app.use('/api/streams', require('../server/streaming/routes'));
+app.use('/api/restream', require('../server/streaming/restream-routes'));
+app.use('/api/vods', require('../server/media-proxy/vods'));
+app.use('/api/clips', require('../server/media-proxy/clips'));
+app.use('/api/thumbnails', require('../server/media-proxy/thumbnails'));
+app.use('/api/emotes', require('../server/emotes/routes'));
+app.use('/api/onvif', require('../server/controls/onvif-routes'));
+app.use('/api/vibe-coding', require('../server/vibe-coding/routes'));
+const server = http.createServer(app).listen(0, '127.0.0.1');
+
+let ipSeq = 0;
+function call(method, p, user, body, { contentType = 'application/json', rawBody = null } = {}) {
+ return new Promise((resolve, reject) => {
+ const data = rawBody != null ? rawBody : (body ? JSON.stringify(body) : null);
+ const headers = { 'content-type': contentType, 'cf-connecting-ip': `10.9.0.${++ipSeq % 250}` };
+ if (user) headers['x-test-user'] = String(user);
+ const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method, headers, agent: false }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+}
+const refused = (r, what) => {
+ assert.ok([401, 403, 404].includes(r.status), `${what}: expected a refusal, got ${r.status} ${r.text.slice(0, 200)}`);
+ for (const k of [SLOT_KEY, DEST_KEY, 'Alice VOD', 'Alice clip']) assert.ok(!r.text.includes(k), `${what}: the answer carries "${k}"`);
+};
+const row = (table, id) => raw.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(id);
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', String(e.stack || e.message).split('\n').slice(0, 6).join('\n ')); }
+}
+
+(async () => {
+ await new Promise((r) => server.once('listening', r));
+ quiet('idor: Bob uses Alice\'s ids');
+
+ await check('VODs: edit, delete, publish, bulk and old-VOD cleanup never reach Media for Alice\'s VOD', async () => {
+ refused(await call('PUT', '/api/vods/100', BOB, { title: 'pwned', visibility: 'private' }), 'PUT vod');
+ refused(await call('DELETE', '/api/vods/100', BOB), 'DELETE vod');
+ refused(await call('POST', '/api/vods/100/publish', BOB), 'publish vod');
+ const bulk = await call('POST', '/api/vods/bulk', BOB, { ids: [100], action: 'delete' });
+ assert.deepStrictEqual([bulk.status, bulk.json && bulk.json.done], [200, 0], bulk.text);
+ await call('POST', '/api/vods/bulk', BOB, { ids: [100], action: 'private' });
+ await call('POST', '/api/vods/bulk-delete-old', BOB, { older_than_days: 1, vods: true, clips: true, action: 'delete' });
+ assert.deepStrictEqual(writesTo('vod', 100), []);
+ });
+ await check('clips: title, visibility, delete, recut, trim and bulk never reach Media for Alice\'s clip', async () => {
+ refused(await call('PUT', '/api/clips/200/title', BOB, { title: 'pwned' }), 'clip title');
+ refused(await call('PUT', '/api/clips/200/visibility', BOB, { visibility: 'private' }), 'clip visibility');
+ refused(await call('DELETE', '/api/clips/200', BOB), 'DELETE clip');
+ refused(await call('POST', '/api/clips/200/recut', BOB), 'recut');
+ const trim = await call('POST', '/api/vods/clips/200/trim', BOB, { start_time: 1, end_time: 2 });
+ assert.ok([403, 404, 410, 400, 501].includes(trim.status), `trim: ${trim.status} ${trim.text.slice(0, 120)}`);
+ const bulk = await call('POST', '/api/clips/bulk', BOB, { ids: [200], action: 'delete' });
+ assert.deepStrictEqual([bulk.status, bulk.json && bulk.json.done], [200, 0], bulk.text);
+ assert.deepStrictEqual(writesTo('clip', 200), []);
+ });
+ // (Regenerating a PUBLIC item's thumbnail is open to anyone on purpose: pages repair a broken
+ // thumbnail lazily, the frame is derived from the video, and there are no custom thumbnails to
+ // overwrite. Private items are media-privacy.test.js's.)
+ await check('thumbnails: Bob cannot post a live thumbnail into Alice\'s stream', async () => {
+ const before = mediaCalls.length;
+ const r = await call('POST', `/api/thumbnails/live/${streamA}`, BOB, null, { contentType: 'multipart/form-data; boundary=x', rawBody: '--x\r\nContent-Disposition: form-data; name="thumbnail"; filename="t.jpg"\r\nContent-Type: image/jpeg\r\n\r\nJPEG\r\n--x--\r\n' });
+ refused(r, 'live thumbnail');
+ assert.strictEqual(mediaCalls.length, before, JSON.stringify(mediaCalls.slice(before)));
+ });
+ await check('recording chunks: Bob cannot upload into or finalize Alice\'s stream\'s VOD', async () => {
+ const before = mediaCalls.length;
+ refused(await call('POST', `/api/vods/stream/${streamA}/chunk`, BOB, null, { contentType: 'multipart/form-data; boundary=x', rawBody: '--x\r\nContent-Disposition: form-data; name="chunk"; filename="c.webm"\r\nContent-Type: video/webm\r\n\r\nDATA\r\n--x--\r\n' }), 'chunk');
+ refused(await call('POST', `/api/vods/stream/${streamA}/finalize`, BOB, {}), 'finalize');
+ assert.strictEqual(mediaCalls.length, before, JSON.stringify(mediaCalls.slice(before)));
+ });
+ await check('control: Alice can edit her own VOD and clip (the routes work in this harness)', async () => {
+ assert.strictEqual((await call('PUT', '/api/vods/100', ALICE, { title: 'Alice renamed' })).status, 200);
+ assert.strictEqual((await call('PUT', '/api/clips/200/title', ALICE, { title: 'Alice renamed' })).status, 200);
+ assert.ok(writesTo('vod', 100).length >= 1 && writesTo('clip', 200).length >= 1);
+ mediaCalls.length = 0;
+ });
+
+ await check('streams: Bob cannot edit, end or delete Alice\'s stream', async () => {
+ const before = row('streams', streamA);
+ refused(await call('PUT', `/api/streams/${streamA}`, BOB, { title: 'pwned', is_nsfw: 1 }), 'PUT stream');
+ refused(await call('DELETE', `/api/streams/${streamA}`, BOB), 'DELETE stream');
+ refused(await call('POST', `/api/streams/${streamA}/heartbeat`, BOB, {}), 'heartbeat');
+ refused(await call('PUT', `/api/streams/${streamA}/call`, BOB, { mode: 'open' }), 'call settings');
+ assert.deepStrictEqual(row('streams', streamA), before);
+ });
+ await check('slots: Bob cannot edit, delete or regenerate the key of Alice\'s slot, and never sees a key', async () => {
+ const before = row('managed_streams', slotA);
+ refused(await call('PUT', `/api/streams/managed/${slotA}`, BOB, { title: 'pwned', stream_key: 'x'.repeat(40), user_id: BOB }), 'PUT slot');
+ refused(await call('POST', `/api/streams/managed/${slotA}/regenerate-key`, BOB), 'regenerate slot key');
+ refused(await call('DELETE', `/api/streams/managed/${slotA}`, BOB), 'DELETE slot');
+ refused(await call('GET', `/api/streams/managed/${slotA}/profile`, BOB), 'slot profile');
+ assert.deepStrictEqual(row('managed_streams', slotA), before);
+ const mine = await call('GET', '/api/streams/managed', BOB);
+ assert.strictEqual(mine.status, 200);
+ assert.ok(!mine.text.includes(SLOT_KEY) && !mine.text.includes('Alice main'), 'Bob\'s slot list is his own');
+ });
+ await check('slots: Bob\'s account-key regeneration changes only his own key', async () => {
+ const r = await call('POST', '/api/auth/stream-key/regenerate', BOB, { user_id: ALICE, managed_stream_id: slotA });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
+ assert.strictEqual(db.getUserById(ALICE).stream_key, '3'.repeat(32));
+ assert.strictEqual(row('managed_streams', slotA).stream_key, SLOT_KEY);
+ });
+ await check('vibe-coding: Bob cannot change Alice\'s slot settings', async () => {
+ refused(await call('PUT', `/api/vibe-coding/managed/${slotA}/settings`, BOB, { enabled: true, repo: 'bob/pwn' }), 'PUT settings');
+ refused(await call('GET', `/api/vibe-coding/managed/${slotA}/settings`, BOB), 'GET settings');
+ refused(await call('GET', `/api/vibe-coding/managed/${slotA}/events`, BOB), 'GET events');
+ assert.strictEqual((await call('GET', `/api/vibe-coding/managed/${slotA}/settings`, ALICE)).status, 200, 'control: Alice reads hers');
+ });
+ await check('control: Alice can edit her own slot', async () => {
+ const r = await call('PUT', `/api/streams/managed/${slotA}`, ALICE, { title: 'Alice main' });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
+ });
+
+ await check('restream destinations: Bob cannot edit, delete, start or stop Alice\'s, and his list holds none of hers', async () => {
+ const before = row('restream_destinations', destA);
+ refused(await call('PUT', `/api/restream/destinations/${destA}`, BOB, { server_url: 'rtmp://bob.example.test/live', stream_key: 'bob' }), 'PUT destination');
+ refused(await call('POST', `/api/restream/destinations/${destA}/start`, BOB), 'start destination');
+ refused(await call('POST', `/api/restream/destinations/${destA}/stop`, BOB), 'stop destination');
+ refused(await call('DELETE', `/api/restream/destinations/${destA}`, BOB), 'DELETE destination');
+ const after = row('restream_destinations', destA);
+ assert.deepStrictEqual({ ...after, updated_at: null }, { ...before, updated_at: null });
+ const list = await call('GET', '/api/restream/destinations?all=1', BOB);
+ assert.strictEqual(list.status, 200);
+ assert.ok(!list.text.includes('Alice mirror') && !list.text.includes(DEST_KEY));
+ });
+ await check('control: Alice sees her destination, with the key masked', async () => {
+ const list = await call('GET', '/api/restream/destinations?all=1', ALICE);
+ assert.ok(list.text.includes('Alice mirror'));
+ assert.ok(!list.text.includes(DEST_KEY), 'even the owner gets a masked key back');
+ });
+
+ await check('API tokens: Bob cannot revoke Alice\'s token', async () => {
+ refused(await call('DELETE', `/api/auth/tokens/${tokenA}`, BOB), 'DELETE token');
+ assert.strictEqual(row('api_tokens', tokenA).is_active, 1);
+ });
+ await check('emotes: Bob cannot rename or delete Alice\'s channel emote', async () => {
+ const before = row('emotes', emoteA);
+ refused(await call('PATCH', `/api/emotes/${emoteA}`, BOB, { code: 'bobPwn' }), 'PATCH emote');
+ refused(await call('DELETE', `/api/emotes/${emoteA}`, BOB), 'DELETE emote');
+ assert.deepStrictEqual(row('emotes', emoteA), before);
+ });
+ await check('ONVIF cameras: Bob cannot read, edit or delete Alice\'s camera or its presets', async () => {
+ const before = [row('camera_profiles', camA), row('camera_presets', presetA)];
+ refused(await call('GET', `/api/onvif/cameras/${camA}`, BOB), 'GET camera');
+ refused(await call('GET', `/api/onvif/cameras/${camA}/presets`, BOB), 'GET presets');
+ refused(await call('PUT', `/api/onvif/cameras/${camA}`, BOB, { name: 'pwned', onvif_url: 'http://bob.example.test' }), 'PUT camera');
+ refused(await call('POST', `/api/onvif/cameras/${camA}/presets`, BOB, { name: 'x', pan: 0, tilt: 0, zoom: 0 }), 'POST preset');
+ refused(await call('DELETE', `/api/onvif/cameras/${camA}/presets/${presetA}`, BOB), 'DELETE preset');
+ refused(await call('DELETE', `/api/onvif/cameras/${camA}`, BOB), 'DELETE camera');
+ assert.deepStrictEqual([row('camera_profiles', camA), row('camera_presets', presetA)], before);
+ });
+ await check('control: Alice can rename her emote, read her camera and revoke her token', async () => {
+ assert.strictEqual((await call('PATCH', `/api/emotes/${emoteA}`, ALICE, { code: 'aliceWave2' })).status, 200);
+ assert.strictEqual((await call('GET', `/api/onvif/cameras/${camA}`, ALICE)).status, 200);
+ assert.strictEqual((await call('DELETE', `/api/auth/tokens/${tokenA}`, ALICE)).status, 200);
+ });
+ await check('anonymous callers get 401 on all of them', async () => {
+ for (const [m, p] of [['PUT', '/api/vods/100'], ['DELETE', '/api/clips/200'], ['PUT', `/api/streams/managed/${slotA}`], ['POST', `/api/streams/managed/${slotA}/regenerate-key`],
+ ['DELETE', `/api/restream/destinations/${destA}`], ['DELETE', `/api/auth/tokens/${tokenA}`], ['DELETE', `/api/emotes/${emoteA}`], ['DELETE', `/api/onvif/cameras/${camA}`]]) {
+ const r = await call(m, p, null, {}).catch((e) => ({ status: 0, text: e.message }));
+ assert.strictEqual(r.status, 401, `${m} ${p}: ${r.status} ${r.text}`);
+ }
+ assert.strictEqual(row('managed_streams', slotA).stream_key, SLOT_KEY);
+ assert.ok(row('managed_streams', slotB));
+ });
+
+ server.close();
+ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
+ if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }
+ quiet('\nsecurity-idor: all checks passed');
+ process.exit(0);
+})().catch((e) => { quiet(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
+'use strict';
+/**
+ * Direct messages stay between their participants on every HTTP path (roadmap WS-R task 5, the
+ * private-room class). dm-delivery.test.js pins live delivery (dm.isParticipant before a socket
+ * gets a message) and authorization.test.js that a 1:1 cannot grow a third person; this suite
+ * walks every /api/dm route as someone outside the conversation, as a participant who was removed
+ * from a group, and anonymously:
+ *
+ * read the conversation, its messages (every cursor form), the conversation list and the unread
+ * count; post into it, mark it read, rename it, add themselves or someone else, remove a
+ * participant, delete a participant's message.
+ *
+ * Each refusal leaves the conversation as it was and returns none of its text. (Private VODs and
+ * clips are media-privacy.test.js's; with Chat serving /api/dm, OpenVibe.Chat has the same suite.)
+ *
+ * node test/security-private.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = path.join(os.tmpdir(), `ov-private-${process.pid}.db`);
+process.env.DB_PATH = tmp;
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+console.error = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+const dm = require('../server/chat/dm');
+dm.ensureTables();
+
+const auth = require('../server/auth/auth');
+const signIn = (req) => {
+ const id = Number(req.headers['x-test-user'] || 0);
+ const u = id ? db.getUserById(id) : null;
+ if (u) { req.user = u; req.authSource = 'network'; }
+ return u;
+};
+auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
+auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
+
+const addUser = (id, username, role = 'user') => raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
+ VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@example.test`, role);
+const ANN = 11, BEN = 12, CAT = 13, EVE = 14, MOD = 15;
+addUser(ANN, 'ann'); addUser(BEN, 'ben'); addUser(CAT, 'cat'); addUser(EVE, 'eve'); addUser(MOD, 'moddy', 'global_mod');
+
+// A 1:1 between Ann and Ben, and a group Ann made with Ben and Cat (Cat is removed later).
+const directId = Number(dm.getOrCreateDirect(ANN, BEN));
+const groupId = Number(dm.createConversation(ANN, [ANN, BEN, CAT], 'Plans'));
+const say = (conv, who, text) => Number(dm.sendMessage(conv, who, text).id);
+const msgDirect = say(directId, BEN, 'the secret direct message');
+const msgGroupOld = say(groupId, BEN, 'the secret group message');
+
+const express = require('express');
+const app = express();
+app.use(express.json());
+app.use('/api/dm', require('../server/chat/dm-routes'));
+const server = http.createServer(app).listen(0, '127.0.0.1');
+function call(method, p, user, body) {
+ return new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const headers = { 'content-type': 'application/json' };
+ if (user) headers['x-test-user'] = String(user);
+ const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method, headers, agent: false }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text }); });
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+}
+const SECRETS = ['secret direct', 'secret group', 'Plans'];
+const snapshot = () => ({
+ conversations: raw.prepare('SELECT * FROM dm_conversations ORDER BY id').all(),
+ participants: raw.prepare('SELECT conversation_id, user_id FROM dm_participants ORDER BY conversation_id, user_id').all(),
+ messages: raw.prepare('SELECT id, conversation_id, sender_id, message FROM dm_messages ORDER BY id').all().map((m) => ({ ...m })),
+});
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', String(e.stack || e.message).split('\n').slice(0, 6).join('\n ')); }
+}
+
+/** Every DM path against conversation `id`, as `user`: all must refuse and carry nothing. */
+async function outsider(user, id, label) {
+ const before = snapshot();
+ const reads = [
+ ['GET', `/api/dm/conversations/${id}`], ['GET', `/api/dm/conversations/${id}/messages`],
+ ['GET', `/api/dm/conversations/${id}/messages?before=999999`], ['GET', `/api/dm/conversations/${id}/messages?after=0`],
+ ['GET', `/api/dm/conversations/${id}/messages?limit=500`],
+ ];
+ const writes = [
+ ['POST', `/api/dm/conversations/${id}/messages`, { message: 'hi', text: 'hi' }],
+ ['POST', `/api/dm/conversations/${id}/read`, {}],
+ ['PATCH', `/api/dm/conversations/${id}`, { name: 'pwned' }],
+ ['POST', `/api/dm/conversations/${id}/participants`, { user_id: user || EVE }],
+ ['POST', `/api/dm/conversations/${id}/participants`, { user_id: MOD }],
+ ['DELETE', `/api/dm/conversations/${id}/participants/${BEN}`],
+ ['DELETE', `/api/dm/conversations/${id}/messages/${msgDirect}`],
+ ['DELETE', `/api/dm/conversations/${id}/messages/${msgGroupOld}`],
+ ];
+ for (const [m, p, body] of [...reads, ...writes]) {
+ const r = await call(m, p, user, body);
+ assert.ok([401, 403, 404].includes(r.status), `${label}: ${m} ${p} → ${r.status} ${r.text.slice(0, 160)}`);
+ for (const s of SECRETS) assert.ok(!r.text.includes(s), `${label}: ${m} ${p} carries "${s}"`);
+ }
+ assert.deepStrictEqual(snapshot(), before, `${label}: nothing changed`);
+}
+
+(async () => {
+ await new Promise((r) => server.once('listening', r));
+ quiet('private: direct messages');
+
+ await check('control: participants read their conversations (the routes work in this harness)', async () => {
+ const r = await call('GET', `/api/dm/conversations/${directId}/messages`, ANN);
+ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
+ assert.ok(r.text.includes('the secret direct message'));
+ const g = await call('GET', `/api/dm/conversations/${groupId}/messages`, CAT);
+ assert.strictEqual(g.status, 200);
+ assert.ok(g.text.includes('the secret group message'));
+ });
+ await check('an outsider gets nothing from a 1:1 on any path, and changes nothing', () => outsider(EVE, directId, 'outsider/direct'));
+ await check('an outsider gets nothing from a group on any path, and changes nothing', () => outsider(EVE, groupId, 'outsider/group'));
+ await check('a global mod is an outsider to other people\'s DMs too', () => outsider(MOD, directId, 'mod/direct'));
+ await check('anonymous gets 401 everywhere', () => outsider(null, directId, 'anonymous'));
+ await check('the outsider\'s own lists hold none of it (conversations, unread, search)', async () => {
+ const list = await call('GET', '/api/dm/conversations', EVE);
+ assert.strictEqual(list.status, 200);
+ for (const s of SECRETS) assert.ok(!list.text.includes(s), list.text.slice(0, 200));
+ const unread = await call('GET', '/api/dm/unread', EVE);
+ assert.strictEqual(unread.status, 200);
+ assert.ok(!/[1-9]/.test(JSON.stringify(unread.json)), `unread: ${unread.text}`);
+ const search = await call('GET', '/api/dm/users/search?q=ann', EVE);
+ assert.ok(!search.text.includes('@example.test'), 'no email in user search');
+ });
+ await check('a participant removed from a group loses every path from then on', async () => {
+ const r = await call('DELETE', `/api/dm/conversations/${groupId}/participants/${CAT}`, ANN);
+ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
+ say(groupId, BEN, 'the secret group message after cat left');
+ await outsider(CAT, groupId, 'removed/group');
+ const list = await call('GET', '/api/dm/conversations', CAT);
+ assert.ok(!list.text.includes('Plans'), 'the group left her list');
+ });
+ await check('a participant of one conversation cannot reach another through it (ids swapped)', async () => {
+ // Ann is in both; Eve starts her own 1:1 with Ann and tries Ann's other conversations' message ids.
+ const mineId = Number(dm.getOrCreateDirect(EVE, ANN));
+ const before = snapshot();
+ const del = await call('DELETE', `/api/dm/conversations/${mineId}/messages/${msgDirect}`, EVE);
+ assert.ok([403, 404].includes(del.status), `delete through her own conversation: ${del.status}`);
+ const page = await call('GET', `/api/dm/conversations/${mineId}/messages?before=${msgDirect + 1}`, EVE);
+ assert.strictEqual(page.status, 200);
+ for (const s of SECRETS) assert.ok(!page.text.includes(s), `her own conversation's page carries "${s}"`);
+ assert.deepStrictEqual(snapshot(), before);
+ });
+
+ server.close();
+ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
+ if (failures) { quiet(`\n${failures} failure(s)`); process.exit(1); }
+ quiet('\nsecurity-private: all checks passed');
+ process.exit(0);
+})().catch((e) => { quiet(e); process.exit(1); });