openvibers/openvibe.live Commits

Staff scope on streams: an admin may moderate another streamer's slot or stream (edit, end, delete, read status) but never the site owner's, and nobody acts as another streamer: regenerating a stream key, the ingest endpoint, going live on another's slot, heartbeats, call settings and WHIP publishing are the streamer's alone (they had an admin bypass). test/staff-stream-scope.test.js

OP OpenVibers committed 24 Sept 2026 · 80f42213f3 · 3 files, +95 −12


Changes

ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex ca2132b8..a3bbc552 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -88,6 +88,17 @@ const MAX_PANELS_LENGTH = 20000;
const { pushBulkNotification } = require('../utils/notify');
const { publicManagedStream, publicChannel, publicStream } = require('../web/serializers');
++/**
++ * Staff may moderate someone else's stream or slot (edit, end, delete, read status) but never the site
++ * owner's, and never act AS its streamer: reading or regenerating a stream key, the ingest endpoint,
++ * going live on a slot, heartbeats, call settings and WHIP publishing stay with the streamer alone.
++ */
++function staffMayModerate(actor, ownerUserId) {
++ if (!actor || actor.role !== 'admin') return false;
++ const target = db.getUserById(ownerUserId);
++ return !(target && target.is_owner);
++}
++
/** A control profile id a slot may point at: one of the caller's own (admins may use anyone's). */
function ownControlConfigId(req, raw) {
if (raw === null || raw === undefined || raw === '') return null;
@@@ -1684,7 +1695,7 @@ router.put('/managed/:id', requireAuth, (req, res) => {
const msId = parseInt(req.params.id);
const ms = db.getManagedStreamById(msId);
if (!ms) return res.status(404).json({ error: 'Managed stream not found' });
-- if (ms.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (ms.user_id !== req.user.id && !staffMayModerate(req.user, ms.user_id)) {
return res.status(403).json({ error: 'Not your managed stream' });
}
@@@ -1844,7 +1855,7 @@ router.delete('/managed/:id', requireAuth, (req, res) => {
const msId = parseInt(req.params.id);
const ms = db.getManagedStreamById(msId);
if (!ms) return res.status(404).json({ error: 'Managed stream not found' });
-- if (ms.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (ms.user_id !== req.user.id && !staffMayModerate(req.user, ms.user_id)) {
return res.status(403).json({ error: 'Not your managed stream' });
}
@@@ -1869,7 +1880,7 @@ router.post('/managed/:id/regenerate-key', requireAuth, async (req, res) => {
const msId = parseInt(req.params.id);
const ms = db.getManagedStreamById(msId);
if (!ms) return res.status(404).json({ error: 'Managed stream not found' });
-- if (ms.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (ms.user_id !== req.user.id) {
return res.status(403).json({ error: 'Not your managed stream' });
}
@@@ -1952,7 +1963,7 @@ router.post('/', requireAuth, (req, res) => {
if (managedStreamId) {
managedStream = db.getManagedStreamById(managedStreamId);
if (!managedStream) return res.status(404).json({ error: 'Managed stream not found' });
-- if (managedStream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (managedStream.user_id !== req.user.id) {
return res.status(403).json({ error: 'Not your managed stream' });
}
} else {
@@@ -1996,7 +2007,7 @@ router.post('/', requireAuth, (req, res) => {
if (!config) {
return res.status(404).json({ error: 'Control config not found' });
}
-- if (config.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (config.user_id !== req.user.id) {
return res.status(403).json({ error: 'Not authorized for this control profile' });
}
}
@@@ -2091,7 +2102,7 @@ router.put('/:id', requireAuth, (req, res) => {
try {
const stream = db.getStreamById(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream.user_id !== req.user.id && !staffMayModerate(req.user, stream.user_id)) {
return res.status(403).json({ error: 'Not your stream' });
}
@@@ -2142,7 +2153,7 @@ router.delete('/:id', requireAuth, (req, res) => {
try {
const stream = db.getStreamById(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream.user_id !== req.user.id && !staffMayModerate(req.user, stream.user_id)) {
return res.status(403).json({ error: 'Not your stream' });
}
@@@ -2189,7 +2200,7 @@ router.get('/:id/endpoint', requireAuth, async (req, res) => {
try {
const stream = db.getStreamById(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream.user_id !== req.user.id) {
return res.status(403).json({ error: 'Not your stream' });
}
@@@ -2287,7 +2298,7 @@ router.post('/:id/heartbeat', requireAuth, (req, res) => {
try {
const stream = db.getStreamById(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream.user_id !== req.user.id) {
return res.status(403).json({ error: 'Not your stream' });
}
if (!stream.is_live) return res.status(400).json({ error: 'Stream is not live' });
@@@ -2305,7 +2316,7 @@ router.get('/:id/rtmp-status', requireAuth, (req, res) => {
try {
const stream = db.getStreamById(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream.user_id !== req.user.id && !staffMayModerate(req.user, stream.user_id)) {
return res.status(403).json({ error: 'Not your stream' });
}
if (stream.protocol !== 'rtmp') {
@@@ -2422,7 +2433,7 @@ router.put('/:id/call', requireAuth, (req, res) => {
try {
const stream = db.getStreamById(req.params.id);
if (!stream) return res.status(404).json({ error: 'Stream not found' });
-- if (stream.user_id !== req.user.id && req.user.role !== 'admin') {
++ if (stream.user_id !== req.user.id) {
return res.status(403).json({ error: 'Not your stream' });
}
if (!stream.is_live) {
ddiff --git a/server/streaming/whip-handler.js b/server/streaming/whip-handler.js
iindex 3b5d161e..e924b586 100644
---- a/server/streaming/whip-handler.js
++++ b/server/streaming/whip-handler.js
@@@ -808,7 +808,7 @@ async function handleWhipPost(req, res) {
}
stream = db.getStreamById(streamId);
if (!stream) return sendWhipError(res, 404, 'stream_not_found', 'Stream not found');
-- if (stream.user_id !== user.id && user.role !== 'admin') {
++ if (stream.user_id !== user.id) { // only the streamer publishes into their own stream (never staff)
return sendWhipError(res, 403, 'not_your_stream', 'Not your stream');
}
if (!stream.is_live) return sendWhipError(res, 409, 'stream_not_live', 'Stream is not live — go live first');
ddiff --git a/test/staff-stream-scope.test.js b/test/staff-stream-scope.test.js
nnew file mode 100644
iindex 00000000..2dcd5a6a
---- /dev/null
++++ b/test/staff-stream-scope.test.js
@@@ -0,0 +1,72 @@
++'use strict';
++
++// Staff may moderate someone else's stream or slot (edit, end, delete) but never the site owner's, and
++// never act as its streamer: stream keys, the ingest endpoint and heartbeats stay with the streamer.
++
++const assert = require('assert');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const fs = require('fs');
++
++const tmp = path.join(os.tmpdir(), `ov-staff-scope-${process.pid}.db`);
++process.env.DB_PATH = tmp;
++process.env.NODE_ENV = 'test';
++console.log = () => {}; 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; 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, name, role, isOwner = 0) => raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner, stream_key)
++ VALUES (?, ?, ?, ?, 'x', ?, ?, ?)`).run(id, name, name, `${name}@x`, role, isOwner, `key-${name}`);
++addUser(1, 'siteowner', 'admin', 1);
++addUser(2, 'anadmin', 'admin');
++addUser(3, 'streamer', 'streamer');
++const slotOf = (uid, slug) => Number(db.createManagedStream({ user_id: uid, slug, title: slug, protocol: 'webrtc', stream_key: `sk-${slug}` }).lastInsertRowid);
++const slotStreamer = slotOf(3, 's3');
++const slotOwner = slotOf(1, 's1');
++const liveOf = (uid, ms) => Number(db.createStream({ user_id: uid, managed_stream_id: ms, title: 't', protocol: 'webrtc' }).lastInsertRowid);
++const streamStreamer = liveOf(3, slotStreamer);
++const streamOwner = liveOf(1, slotOwner);
++
++const express = require('express');
++const app = express();
++app.use(express.json());
++app.use('/api/streams', require('../server/streaming/routes'));
++const server = http.createServer(app);
++
++function call(method, p, user, body) {
++ return new Promise((resolve, reject) => {
++ const data = body ? JSON.stringify(body) : null;
++ const req = http.request(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { 'x-test-user': String(user), 'content-type': 'application/json' } }, (res) => {
++ let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve(res.statusCode));
++ });
++ req.on('error', reject); if (data) req.write(data); req.end();
++ });
++}
++
++server.listen(0, '127.0.0.1', async () => {
++ try {
++ // Moderation of another streamer's slot: allowed for an admin.
++ assert.notStrictEqual(await call('PUT', `/api/streams/managed/${slotStreamer}`, 2, { title: 'moderated' }), 403, 'an admin may edit another streamer\'s slot');
++ // ...but never the site owner's.
++ assert.strictEqual(await call('PUT', `/api/streams/managed/${slotOwner}`, 2, { title: 'x' }), 403, 'an admin may not edit the owner\'s slot');
++ assert.strictEqual(await call('PUT', `/api/streams/${streamOwner}`, 2, { title: 'x' }), 403, 'an admin may not edit the owner\'s live stream');
++ assert.strictEqual(await call('DELETE', `/api/streams/${streamOwner}`, 2), 403, 'an admin may not end the owner\'s stream');
++ // Acting as the streamer: never, not even for admins.
++ assert.strictEqual(await call('POST', `/api/streams/managed/${slotStreamer}/regenerate-key`, 2), 403, 'an admin may not regenerate someone else\'s stream key');
++ assert.strictEqual(await call('GET', `/api/streams/${streamStreamer}/endpoint`, 2), 403, 'an admin may not read someone else\'s ingest endpoint');
++ assert.strictEqual(await call('POST', `/api/streams/${streamStreamer}/heartbeat`, 2), 403, 'an admin may not heartbeat someone else\'s stream');
++ // The streamer keeps everything on their own stream.
++ assert.notStrictEqual(await call('GET', `/api/streams/${streamStreamer}/endpoint`, 3), 403);
++ assert.notStrictEqual(await call('POST', `/api/streams/managed/${slotStreamer}/regenerate-key`, 3), 403);
++ // Even the site owner does not act as another streamer.
++ assert.strictEqual(await call('POST', `/api/streams/managed/${slotStreamer}/regenerate-key`, 1), 403);
++ console.info('staff stream scope: all checks passed');
++ } finally { server.close(); try { fs.rmSync(tmp, { force: true }); } catch { /* */ } }
++});