openvibers/openvibe.live Commits
Three missing authorization checks, and the first-live button's icons
Changes
@@@ -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; }@@@ -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">@@@ -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);@@@ -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);@@@ -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 userconst liveSessions = db.getLiveStreamsByUserId(user.id) || [];@@@ -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`);
@@ -431,15 +431,35 @@
@@ -431,15 +431,35 @@
.ovg-launch-go { position: relative; z-index: 2; color: var(--accent); transition: transform 0.2s; }
.ovg-launch-go { position: relative; z-index: 2; color: var(--accent); transition: transform 0.2s; }
.ovg-launch:hover .ovg-launch-go { transform: translateX(3px); }
.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 {
.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, .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); }
.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) {
@media (max-width: 560px) {
.ovg-launch { grid-template-columns: auto 1fr auto; gap: 10px; padding: 11px 12px; }
.ovg-launch { grid-template-columns: auto 1fr auto; gap: 10px; padding: 11px 12px; }
@@ -90,7 +90,7 @@
@@ -90,7 +90,7 @@
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="stylesheet" href="/css/style.css?v=200">
<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-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="/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="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">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
@@ -341,7 +341,10 @@ router.delete('/ban/:id', (req, res) => {
@@ -341,7 +341,10 @@ router.delete('/ban/:id', (req, res) => {
});
});
// ── Search chat messages (all users) ─────────────────────────
// ── 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 {
try {
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const offset = parseInt(req.query.offset || '0');
const offset = parseInt(req.query.offset || '0');
@@ -372,7 +375,7 @@ router.get('/chat/search', (req, res) => {
@@ -372,7 +375,7 @@ router.get('/chat/search', (req, res) => {
});
});
// ── View a user's chat history ───────────────────────────────
// ── View a user's chat history ───────────────────────────────
-router.get('/chat/user/:userId', (req, res) => {
+router.get('/chat/user/:userId', permissions.requireGlobalMod, (req, res) => {
try {
try {
const userId = parseInt(req.params.userId);
const userId = parseInt(req.params.userId);
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
@@ -768,6 +768,11 @@ router.put('/channel/:username/about', requireAuth, (req, res) => {
@@ -768,6 +768,11 @@ router.put('/channel/:username/about', requireAuth, (req, res) => {
}
}
const updated = db.getChannelByUsername(req.params.username);
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 });
res.json({ channel: updated, edited_by_mod: !isOwner });
} catch (err) {
} catch (err) {
console.error('[Channel] about update error:', err.message);
console.error('[Channel] about update error:', err.message);
@@ -724,6 +724,16 @@ async function handleWhipPost(req, res) {
@@ -724,6 +724,16 @@ async function handleWhipPost(req, res) {
if (decoded) {
if (decoded) {
const user = resolveNetworkUser(decoded);
const user = resolveNetworkUser(decoded);
if (user) {
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;
userId = user.id;
// Find live session for this slot owned by this user
// Find live session for this slot owned by this user
const liveSessions = db.getLiveStreamsByUserId(user.id) || [];
const liveSessions = db.getLiveStreamsByUserId(user.id) || [];
@@ -120,4 +120,51 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
@@ -120,4 +120,51 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
ok('both OAuth callback pages escape provider-supplied text');
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`);
console.log(`\n${pass} checks passed`);