openvibers/openvibe.live Commits

Internal-key routes share one trust rule (server/net/internal-key.js): loopback only and INTERNAL_API_KEY compared in constant time. POST /api/cosmetics/internal-unlock checked only the key, so anyone holding the key shared by Network, Live, Media and Tools could unlock paid cosmetics through the public edge; it now refuses anything with X-Forwarded-For, X-Real-IP or CF-Connecting-IP like /internal/* and the service-token guard (which now imports the same viaProxy). /internal/* and /internal/analytics-summary compared the key with !==; all three use internalKeyMatches (both sides hashed, so neither content nor length leaks through timing). test/cosmetics-internal.test.js checks the edge headers, wrong keys of equal and longer length, the /internal routes, and that no === / !== key compare is left in server/

OP OpenVibers committed 24 Sept 2026 · 912b87c57b · 6 files, +97 −28


Changes

ddiff --git a/server/index.js b/server/index.js
iindex 370a5faf..4ae36be2 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -582,8 +582,7 @@ app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
// Summary numbers for the Network's navigation service (ordering sites by real use).
// Internal key only; returns totals, never rows.
app.get('/internal/analytics-summary', (req, res) => {
-- const key = req.headers['x-internal-key'];
-- if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || !config.internalApiKey || !key || key !== config.internalApiKey) return res.status(401).json({ ok: false });
++ if (!require('./net/internal-key').internalKeyOk(req)) return res.status(401).json({ ok: false });
try {
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
const st = analytics.getStats({ days }) || {};
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 900fcd93..a774c113 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -5,11 +5,12 @@ const router = express.Router();
const config = require('../config');
const db = require('../db/database');
++const { internalKeyOk } = require('../net/internal-key');
++
++// Service-to-service only: loopback (nothing that came through nginx) and INTERNAL_API_KEY,
++// compared in constant time (server/net/internal-key.js).
function requireInternalKey(req, res, next) {
-- const key = req.headers['x-internal-key'];
-- // Service-to-service only: nginx adds X-Forwarded-For to everything from outside, loopback callers never do.
-- const viaProxy = req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip'];
-- if (viaProxy || !key || !config.internalApiKey || key !== config.internalApiKey) {
++ if (!internalKeyOk(req)) {
return res.status(403).json({ error: 'Invalid or missing internal key' });
}
next();
ddiff --git a/server/monetization/cosmetics-routes.js b/server/monetization/cosmetics-routes.js
iindex 0a705c92..419296a4 100644
---- a/server/monetization/cosmetics-routes.js
++++ b/server/monetization/cosmetics-routes.js
@@@ -80,16 +80,12 @@ 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.
--// 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'] || '');
-- return !!key && given.length === key.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(key));
--}
++// Only the configured internal API key, from loopback: the legacy X-Internal-Secret is in git
++// history, and the quest game that sent it from this host is gone. The key is shared by several
++// services, so it is refused through the public edge (nginx/Cloudflare) like every internal route
++// (server/net/internal-key.js); otherwise anyone holding it could unlock paid cosmetics from outside.
router.post('/internal-unlock', (req, res) => {
-- if (!internalCallerOk(req)) {
++ if (!require('../net/internal-key').internalKeyOk(req)) {
return res.status(403).json({ error: 'Forbidden' });
}
const { userId, itemId } = req.body;
ddiff --git a/server/net/internal-key.js b/server/net/internal-key.js
nnew file mode 100644
iindex 00000000..cb35dff3
---- /dev/null
++++ b/server/net/internal-key.js
@@@ -0,0 +1,35 @@
++'use strict';
++/**
++ * The trust rule for Live's internal (server-to-server) routes, in one place.
++ *
++ * viaProxy(req) the request came through nginx or Cloudflare: nginx adds X-Forwarded-For /
++ * X-Real-IP to everything from outside (Cloudflare adds CF-Connecting-IP), and
++ * loopback callers never send them. Internal routes refuse such a request.
++ * internalKeyMatches(k) timing-safe comparison with INTERNAL_API_KEY (both sides hashed first, so
++ * neither the content nor the length leaks through timing).
++ * internalKeyOk(req) both: loopback and the right X-Internal-Key. Every X-Internal-Key route uses
++ * this (server/internal/routes.js, /internal/analytics-summary,
++ * POST /api/cosmetics/internal-unlock); service-token routes use
++ * server/net/service-guard.js, which applies the same viaProxy rule.
++ */
++const crypto = require('crypto');
++
++function viaProxy(req) {
++ const h = (req && req.headers) || {};
++ return !!(h['x-forwarded-for'] || h['x-real-ip'] || h['cf-connecting-ip']);
++}
++
++const digest = (v) => crypto.createHash('sha256').update(String(v)).digest();
++
++function internalKeyMatches(given) {
++ const key = String(require('../config').internalApiKey || '');
++ const presented = String(given || '');
++ if (!key || !presented) return false;
++ return crypto.timingSafeEqual(digest(presented), digest(key));
++}
++
++function internalKeyOk(req) {
++ return !viaProxy(req) && internalKeyMatches(req && req.headers && req.headers['x-internal-key']);
++}
++
++module.exports = { viaProxy, internalKeyMatches, internalKeyOk };
ddiff --git a/server/net/service-guard.js b/server/net/service-guard.js
iindex cb2b4307..8fb85e81 100644
---- a/server/net/service-guard.js
++++ b/server/net/service-guard.js
@@@ -14,13 +14,10 @@
*/
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { getNetworkPublicKey, getNetworkIssuer } = require('../auth/auth');
++const { viaProxy } = require('./internal-key');
const AUDIENCE = 'openvibe.live';
--function viaProxy(req) {
-- return !!(req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']);
--}
--
function guard(capability) {
const registered = !!capabilities.get(capability);
return function liveServiceGuard(req, res, next) {
ddiff --git a/test/cosmetics-internal.test.js b/test/cosmetics-internal.test.js
iindex 9d95965b..4488ceff 100644
---- a/test/cosmetics-internal.test.js
++++ b/test/cosmetics-internal.test.js
@@@ -1,8 +1,12 @@
'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.
++// opens it, and only from loopback. The legacy X-Internal-Secret is in the repository's history:
++// from loopback, without proxy headers, it used to be enough on its own. The key is shared by
++// Network, Live, Media and Tools, so a request that came through the public edge (nginx adds
++// X-Forwarded-For / X-Real-IP, Cloudflare CF-Connecting-IP) is refused even with the right key.
++// The same rule and a constant-time compare cover the other X-Internal-Key routes
++// (server/net/internal-key.js): /internal/* and /internal/analytics-summary.
const assert = require('assert');
const fs = require('fs');
@@@ -12,24 +16,61 @@ 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);
++const KEY = 'k'.repeat(40);
++process.env.INTERNAL_API_KEY = KEY;
console.log = () => {};
console.warn = () => {};
console.error = () => {};
(async () => {
++ require('../server/db/database').initDb();
const app = express();
app.use(express.json());
app.use('/api/cosmetics', require('../server/monetization/cosmetics-routes'));
++ app.use('/internal', require('../server/internal/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({}) });
++ const origin = `http://127.0.0.1:${server.address().port}`;
++ const post = (p, headers, body = {}) => fetch(origin + p, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) });
++ const unlock = (headers) => post('/api/cosmetics/internal-unlock', headers);
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)');
++ // ── internal-unlock ──────────────────────────────────────────────────────────
++ assert.strictEqual((await unlock({ 'x-internal-secret': 'openvibe-internal-2026' })).status, 403, 'the legacy secret from loopback no longer opens it');
++ assert.strictEqual((await unlock({})).status, 403);
++ assert.strictEqual((await unlock({ 'x-internal-key': 'wrong' })).status, 403);
++ assert.strictEqual((await unlock({ 'x-internal-key': 'j'.repeat(40) })).status, 403, 'a wrong key of the right length');
++ assert.strictEqual((await unlock({ 'x-internal-key': KEY + 'x' })).status, 403, 'a longer key that starts right');
++ assert.strictEqual((await unlock({ 'x-internal-key': KEY })).status, 400, 'the internal key from loopback gets past the gate (then needs userId/itemId)');
++ for (const h of ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip']) {
++ assert.strictEqual((await unlock({ 'x-internal-key': KEY, [h]: '203.0.113.9' })).status, 403, `the right key through the public edge (${h}) is refused`);
++ }
++
++ // ── the other X-Internal-Key routes: same rule ──────────────────────────────
++ const avatar = (headers) => post('/internal/user-avatar', headers, { username: 'nobody-here' });
++ assert.strictEqual((await avatar({ 'x-internal-key': KEY })).status, 404, 'the right key from loopback reaches /internal/*');
++ assert.strictEqual((await avatar({ 'x-internal-key': 'j'.repeat(40) })).status, 403);
++ assert.strictEqual((await avatar({ 'x-internal-key': KEY, 'x-forwarded-for': '203.0.113.9' })).status, 403);
++ assert.strictEqual((await avatar({ 'x-internal-key': KEY, 'cf-connecting-ip': '203.0.113.9' })).status, 403);
++
++ const ik = require('../server/net/internal-key');
++ assert.strictEqual(ik.internalKeyMatches(KEY), true);
++ assert.strictEqual(ik.internalKeyMatches(''), false);
++ assert.strictEqual(ik.internalKeyMatches(KEY.slice(0, 39)), false);
++
++ // No internal-key compare with === / !== is left anywhere in the server.
++ const offenders = [];
++ const walk = (dir) => {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) walk(p);
++ else if (e.name.endsWith('.js')) {
++ const src = fs.readFileSync(p, 'utf8');
++ if (/[!=]==\s*(config\.internalApiKey|INTERNAL_API_KEY)\b|\b(config\.internalApiKey|INTERNAL_API_KEY)\s*[!=]==/.test(src)) offenders.push(path.relative(path.join(__dirname, '..'), p));
++ }
++ }
++ };
++ walk(path.join(__dirname, '..', 'server'));
++ assert.deepStrictEqual(offenders, [], 'internal keys are compared with internalKeyMatches (constant time)');
process.stdout.write('cosmetics internal-unlock: all checks passed\n');
} finally {
server.close();