openvibers/openvibe.live Commits

Remove the hard-coded legacy quest secret: /api/cosmetics/internal-unlock takes only INTERNAL_API_KEY, and the quest game-item bridge is off unless LEGACY_QUEST_API_URL is set (the game no longer runs); drop the unused admin route that wrote the frozen pastes table (Wave 22 register C-59, C-73)

OP OpenVibers committed 23 Sept 2026 · e505868b33 · 5 files, +49 −21


Changes

ddiff --git a/.env.example b/.env.example
iindex 5bbf2c99..22de26b5 100644
---- a/.env.example
++++ b/.env.example
@@@ -47,6 +47,8 @@ OV_OAUTH_CLIENT_ID=live
OV_OAUTH_CLIENT_SECRET=
# Per-service key for Network's /internal/* endpoints (header X-Internal-Key)
INTERNAL_API_KEY=change-me-to-a-random-secret
++# Legacy openvibe-quest game-item bridge (cosmetics ⇄ game inventory). Off unless set; authenticated with INTERNAL_API_KEY.
++# LEGACY_QUEST_API_URL=http://127.0.0.1:3200
# OpenVibe.Events (durable events): stream lifecycle is published through Live's outbox with the
# OAuth client above (grant events.event.publish). Unset = off. EVENTS_PUBLISH=off also disables it.
#EVENTS_URL=http://127.0.0.1:4300
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 0e37a9d7..b85b0dd4 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -423,17 +423,6 @@ router.put('/streams/:id/nsfw', (req, res) => {
}
});
--// ── Force NSFW on a Paste ────────────────────────────────────
--router.put('/pastes/:id/nsfw', (req, res) => {
-- try {
-- const { is_nsfw } = req.body;
-- db.run('UPDATE pastes SET is_nsfw = ? WHERE id = ?', [is_nsfw ? 1 : 0, req.params.id]);
-- res.json({ message: is_nsfw ? 'Paste marked as NSFW' : 'NSFW removed from paste' });
-- } catch (err) {
-- res.status(500).json({ error: 'Failed to update paste NSFW' });
-- }
--});
--
// ── List Bans ────────────────────────────────────────────────
router.get('/bans', (req, res) => {
try {
ddiff --git a/server/monetization/cosmetics-routes.js b/server/monetization/cosmetics-routes.js
iindex dfc857a6..0a705c92 100644
---- a/server/monetization/cosmetics-routes.js
++++ b/server/monetization/cosmetics-routes.js
@@@ -80,17 +80,13 @@ router.post('/deactivate', requireAuth, async (req, res) => {
// ── Internal: auto-unlock cosmetic from openvibe-quest game ──────
// Called server-to-server when openvibe-quest game awards a hat/cosmetic item.
--// The shared secret in the X-Internal-Secret header is in git history, so on its own it proves
--// nothing. Accept either the configured internal API key, or the legacy header from loopback only
--// (the game ran on this host). Anyone on the internet could otherwise unlock paid cosmetics.
++// Only the configured internal API key: the legacy X-Internal-Secret is in git history, and the
++// quest game that sent it from this host is gone. Anyone else could unlock paid cosmetics.
function internalCallerOk(req) {
const crypto = require('crypto');
const key = String(require('../config').internalApiKey || '');
const given = String(req.headers['x-internal-key'] || '');
-- if (key && given.length === key.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(key))) return true;
-- const loopback = /^(127\.|::1$|::ffff:127\.)/.test(String(req.socket && req.socket.remoteAddress || ''));
-- const forwarded = !!(req.headers['x-forwarded-for'] || req.headers['cf-connecting-ip']);
-- return loopback && !forwarded && req.headers['x-internal-secret'] === 'openvibe-internal-2026';
++ return !!key && given.length === key.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(key));
}
router.post('/internal-unlock', (req, res) => {
if (!internalCallerOk(req)) {
ddiff --git a/server/monetization/cosmetics.js b/server/monetization/cosmetics.js
iindex 82f53dcd..927eb178 100644
---- a/server/monetization/cosmetics.js
++++ b/server/monetization/cosmetics.js
@@@ -7,8 +7,10 @@
const db = require('../db/database');
const http = require('http');
--// openvibe-quest internal API base (server-to-server on localhost)
--const LEGACY_QUEST_API = 'http://127.0.0.1:3200';
++// openvibe-quest internal API base (server-to-server). The quest game no longer runs on this host
++// (2026-09-23), so the game-item bridge is off unless LEGACY_QUEST_API_URL names one; it
++// authenticates with INTERNAL_API_KEY, never a secret written into the code.
++const LEGACY_QUEST_API = String(process.env.LEGACY_QUEST_API_URL || '').replace(/\/+$/, '');
// ── Cosmetic Catalog (defines all cosmetics and their CSS/rendering data) ───
const COSMETICS = {
@@@ -220,13 +222,14 @@ function unequipSlot(userId, slot) {
// ── Helper: call openvibe-quest internal API ─────────────────────
function questApi(method, path, body) {
++ if (!LEGACY_QUEST_API) return Promise.reject(new Error('the quest game bridge is off (LEGACY_QUEST_API_URL is not set)'));
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const req = http.request(`${LEGACY_QUEST_API}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
-- 'X-Internal-Secret': 'openvibe-internal-2026',
++ 'X-Internal-Key': String(require('../config').internalApiKey || ''),
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
},
timeout: 5000,
ddiff --git a/test/cosmetics-internal.test.js b/test/cosmetics-internal.test.js
nnew file mode 100644
iindex 00000000..9d95965b
---- /dev/null
++++ b/test/cosmetics-internal.test.js
@@@ -0,0 +1,38 @@
++'use strict';
++
++// POST /api/cosmetics/internal-unlock grants paid cosmetics, so only the configured INTERNAL_API_KEY
++// opens it. The legacy X-Internal-Secret is in git history: from loopback, without proxy headers,
++// it used to be enough on its own.
++
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const express = require('express');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-cosmetics-internal-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.INTERNAL_API_KEY = 'k'.repeat(40);
++console.log = () => {};
++console.warn = () => {};
++console.error = () => {};
++
++(async () => {
++ const app = express();
++ app.use(express.json());
++ app.use('/api/cosmetics', require('../server/monetization/cosmetics-routes'));
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/cosmetics/internal-unlock`;
++ const post = (headers) => fetch(base, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify({}) });
++ try {
++ assert.strictEqual((await post({ 'x-internal-secret': 'openvibe-internal-2026' })).status, 403, 'the legacy secret from loopback no longer opens it');
++ assert.strictEqual((await post({})).status, 403);
++ assert.strictEqual((await post({ 'x-internal-key': 'wrong' })).status, 403);
++ assert.strictEqual((await post({ 'x-internal-key': 'k'.repeat(40) })).status, 400, 'the internal key gets past the gate (then needs userId/itemId)');
++ process.stdout.write('cosmetics internal-unlock: all checks passed\n');
++ } finally {
++ server.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });