openvibers/openvibe.live Commits

Three missing authorization checks, and the first-live button's icons

OP OpenVibers committed 16 Sept 2026 · 655adaa9eb · 6 files, +93 −8

WHIP slot auth: when the supplied key does not match the slot, the handler falls through to JWT
auth — and there it took a valid session token as proof the slot belonged to the caller. It did
not. Any signed-in account could POST an offer to /whip/<another streamer's slot id> and
autoCreateWhipSession() would open a publish session against that slot, under that slot's title,
category and NSFW flag. Ownership is now checked before a session is created.

/mod/chat/search and /mod/chat/user/:userId: every other route in mod-routes.js checks a
permission in its body; these two had none, so router.use(requireAuth) alone let any signed-in
account read any user's chat history across every channel. Both search the whole site and their
only caller is the staff console, so both now require a global moderator.

The About-update response returned db.getChannelByUsername() verbatim, and that query selects
u.stream_key. The endpoint is reachable by a channel's moderators — it reports edited_by_mod for
exactly that reason — so every mod of a channel was handed the streamer's ingest key. Redacted
the same way the rest of the file already does.

Three regression tests added to test/security-redaction.test.js (13 checks pass; each new one was
confirmed to fail with its fix reverted).

Also: .empty-state's `i { margin-bottom: 16px; opacity: 0.5 }` was landing on the new "Be the
first to go live" button's own glyphs, so the tower rode above the text and both icons came out
half-faded. Reset explicitly, and the tower now gets the same rounded tile the launch button uses.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/home-tour.css b/public/css/home-tour.css
iindex 7da42c31..ae0fc781 100644
---- a/public/css/home-tour.css
++++ b/public/css/home-tour.css
@@@ -431,15 +431,35 @@
.ovg-launch-go { position: relative; z-index: 2; color: var(--accent); transition: transform 0.2s; }
.ovg-launch:hover .ovg-launch-go { transform: translateX(3px); }
--/* The home page's empty state is a single centred pill — there is no progress to explain there. */
++/* The home page's empty state is a single centred pill — there is no progress to explain there.
++ It lives inside .empty-state, whose `.empty-state i { margin-bottom: 16px; opacity: 0.5 }` was
++ landing on the button's own glyphs: the tower rode 16px above the text baseline and both icons
++ came out half-faded. Those are reset explicitly rather than by accident of order, and the tower
++ gets the same rounded tile the launch button uses so the two read as one family. */
.ovg-firstlive {
-- display: inline-flex; align-items: center; gap: 10px;
-- margin-top: 6px; padding: 11px 20px; font-size: 0.92rem; font-weight: 800;
++ display: inline-flex; align-items: center; gap: 11px;
++ width: auto; max-width: min(340px, 100%);
++ margin: 10px auto 0; padding: 9px 16px 9px 10px;
++ font-size: 0.92rem; font-weight: 800; line-height: 1.2;
++ vertical-align: middle;
}
.ovg-firstlive > i, .ovg-firstlive > span { position: relative; z-index: 2; }
--.ovg-firstlive > i:first-of-type { color: var(--accent); }
--.ovg-firstlive-go { color: var(--accent); transition: transform 0.2s; }
++/* Undo the empty-state icon rules for this button's glyphs. */
++.empty-state .ovg-firstlive i { margin: 0; opacity: 1; }
++.ovg-firstlive > i:first-of-type {
++ width: 32px; height: 32px; flex: none; border-radius: 10px;
++ display: grid; place-items: center; font-size: 0.86rem;
++ color: var(--accent);
++ background: color-mix(in srgb, var(--accent) 18%, transparent);
++ border: 1px solid color-mix(in srgb, var(--accent) 36%, transparent);
++}
++.ovg-firstlive > span { flex: 1 1 auto; text-align: left; white-space: nowrap; }
++.ovg-firstlive-go { flex: none; font-size: 0.82rem; color: var(--accent); transition: transform 0.2s; }
.ovg-firstlive:hover .ovg-firstlive-go { transform: translateX(3px); }
++@media (max-width: 420px) {
++ .ovg-firstlive { font-size: 0.86rem; padding: 8px 13px 8px 8px; gap: 9px; }
++ .ovg-firstlive > i:first-of-type { width: 29px; height: 29px; font-size: 0.8rem; }
++}
@media (max-width: 560px) {
.ovg-launch { grid-template-columns: auto 1fr auto; gap: 10px; padding: 11px 12px; }
ddiff --git a/public/index.html b/public/index.html
iindex bd746da2..b1748b4e 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -90,7 +90,7 @@
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="/css/style.css?v=200">
<link rel="stylesheet" href="/css/home-fx.css?v=13">
-- <link rel="stylesheet" href="/css/home-tour.css?v=9">
++ <link rel="stylesheet" href="/css/home-tour.css?v=10">
<link rel="stylesheet" href="/css/i18n-star.css?v=11">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
ddiff --git a/server/admin/mod-routes.js b/server/admin/mod-routes.js
iindex 7b0b5e14..bc65bcc9 100644
---- a/server/admin/mod-routes.js
++++ b/server/admin/mod-routes.js
@@@ -341,7 +341,10 @@ router.delete('/ban/:id', (req, res) => {
});
// ── Search chat messages (all users) ─────────────────────────
--router.get('/chat/search', (req, res) => {
++// Unscoped: searches every channel's chat, by user or by text. Every other route in this file
++// checks a permission in its body; these two had none, so requireAuth alone let any signed-in
++// account read anyone's chat history site-wide. Their only caller is the staff console.
++router.get('/chat/search', permissions.requireGlobalMod, (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const offset = parseInt(req.query.offset || '0');
@@@ -372,7 +375,7 @@ router.get('/chat/search', (req, res) => {
});
// ── View a user's chat history ───────────────────────────────
--router.get('/chat/user/:userId', (req, res) => {
++router.get('/chat/user/:userId', permissions.requireGlobalMod, (req, res) => {
try {
const userId = parseInt(req.params.userId);
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex aac76901..8e0126a0 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -768,6 +768,11 @@ router.put('/channel/:username/about', requireAuth, (req, res) => {
}
const updated = db.getChannelByUsername(req.params.username);
++ // getChannelByUsername joins users and selects u.stream_key. This endpoint is reachable by
++ // a channel's moderators (that is what edited_by_mod reports), so returning the row as-is
++ // handed every mod the streamer's broadcast key — enough to publish to their channel. The
++ // rest of this file already redacts it the same way before sending a channel or stream.
++ if (updated) { delete updated.stream_key; delete updated.managed_stream_key; }
res.json({ channel: updated, edited_by_mod: !isOwner });
} catch (err) {
console.error('[Channel] about update error:', err.message);
ddiff --git a/server/streaming/whip-handler.js b/server/streaming/whip-handler.js
iindex 862a6a12..1f225108 100644
---- a/server/streaming/whip-handler.js
++++ b/server/streaming/whip-handler.js
@@@ -724,6 +724,16 @@ async function handleWhipPost(req, res) {
if (decoded) {
const user = resolveNetworkUser(decoded);
if (user) {
++ // A valid session token proves who the caller is, not that the slot is
++ // theirs. Without this check any signed-in account could POST an offer to
++ // /whip/<someone else's slot id> and autoCreateWhipSession() would happily
++ // open a session against that slot — publishing under another streamer's
++ // slot, with their title, category and NSFW flag. Ownership is the slot's
++ // user_id; a moderator on the channel is not a publisher.
++ if (managedStream.user_id !== user.id) {
++ logWhipStage('auth_slot_fail', pathParam, { reason: 'not_slot_owner', user_id: user.id, slot_id: slotId });
++ return sendWhipError(res, 403, 'not_slot_owner', 'This stream slot belongs to another account');
++ }
userId = user.id;
// Find live session for this slot owned by this user
const liveSessions = db.getLiveStreamsByUserId(user.id) || [];
ddiff --git a/test/security-redaction.test.js b/test/security-redaction.test.js
iindex 8e1573c6..b2c85c22 100644
---- a/test/security-redaction.test.js
++++ b/test/security-redaction.test.js
@@@ -120,4 +120,51 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
ok('both OAuth callback pages escape provider-supplied text');
}
++// ── WHIP: a valid session token is not proof the slot is yours ───────────────
++{
++ const src = fs.readFileSync(path.join(ROOT, 'server/streaming/whip-handler.js'), 'utf8');
++ // The JWT fall-through happens when the supplied key does not match the slot. Before the
++ // ownership check, any signed-in account could open a publish session against any slot id.
++ // Anchor on the call, not the name: the surrounding comment mentions it too.
++ const fall = src.match(/if \(managedStream\.stream_key !== streamKey\) \{[\s\S]*?stream = autoCreateWhipSession\(/);
++ assert(fall, 'the JWT fall-through path should still exist');
++ assert(/managedStream\.user_id !== user\.id/.test(fall[0]),
++ 'the JWT fall-through must reject a slot the caller does not own');
++ assert(/not_slot_owner/.test(fall[0]), 'the rejection should be explicit, not a generic 401');
++ assert(fall[0].indexOf('managedStream.user_id !== user.id') < fall[0].indexOf('stream = autoCreateWhipSession('),
++ 'ownership must be checked before a session is created');
++ ok('WHIP slot auth rejects a JWT holder who does not own the slot');
++}
++
++// ── Cross-channel chat logs are moderator data ───────────────────────────────
++{
++ const src = fs.readFileSync(path.join(ROOT, 'server/admin/mod-routes.js'), 'utf8');
++ for (const route of ["'/chat/search'", "'/chat/user/:userId'"]) {
++ const m = src.match(new RegExp(`router\\.get\\(${route.replace(/[/:]/g, m2 => '\\' + m2)},([^,]*),`));
++ assert(m, `${route} should still be registered`);
++ assert(/requireGlobalMod/.test(m[1]),
++ `${route} searches every channel and must require a global mod, not just a signed-in user`);
++ }
++ ok('unscoped chat-log endpoints require a global moderator');
++}
++
++// ── A channel moderator must not be handed the streamer's ingest key ─────────
++{
++ const db = fs.readFileSync(path.join(ROOT, 'server/db/database.js'), 'utf8');
++ const q = db.match(/function getChannelByUsername\(username\) \{[\s\S]*?\n\}/);
++ assert(q, 'getChannelByUsername should exist');
++ // If the query ever stops selecting the key this test can relax; while it does, every
++ // mod-reachable response built from it has to redact.
++ if (/u\.stream_key/.test(q[0])) {
++ const routes = fs.readFileSync(path.join(ROOT, 'server/streaming/routes.js'), 'utf8');
++ const about = routes.match(/const updated = db\.getChannelByUsername\(req\.params\.username\);[\s\S]{0,600}?res\.json\(\{ channel: updated/);
++ assert(about, 'the About-update response should still exist');
++ assert(/delete updated\.stream_key/.test(about[0]),
++ 'the About-update response is reachable by channel mods and must not include stream_key');
++ ok('the mod-reachable channel response redacts the ingest key');
++ } else {
++ ok('getChannelByUsername no longer selects stream_key');
++ }
++}
++
console.log(`\n${pass} checks passed`);