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/
Changes
@@@ -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 }) || {};@@@ -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();@@@ -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;nnew file mode 100644@@@ -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 };@@@ -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) {@@@ -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();
@@ -582,8 +582,7 @@ app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
@@ -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).
// Summary numbers for the Network's navigation service (ordering sites by real use).
// Internal key only; returns totals, never rows.
// Internal key only; returns totals, never rows.
app.get('/internal/analytics-summary', (req, res) => {
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 {
try {
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
const st = analytics.getStats({ days }) || {};
const st = analytics.getStats({ days }) || {};
@@ -5,11 +5,12 @@ const router = express.Router();
@@ -5,11 +5,12 @@ const router = express.Router();
const config = require('../config');
const config = require('../config');
const db = require('../db/database');
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) {
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' });
return res.status(403).json({ error: 'Invalid or missing internal key' });
}
}
next();
next();
@@ -80,16 +80,12 @@ router.post('/deactivate', requireAuth, async (req, res) => {
@@ -80,16 +80,12 @@ router.post('/deactivate', requireAuth, async (req, res) => {
// ── Internal: auto-unlock cosmetic from openvibe-quest game ──────
// ── Internal: auto-unlock cosmetic from openvibe-quest game ──────
// Called server-to-server when openvibe-quest game awards a hat/cosmetic item.
// 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) => {
router.post('/internal-unlock', (req, res) => {
- if (!internalCallerOk(req)) {
+ if (!require('../net/internal-key').internalKeyOk(req)) {
return res.status(403).json({ error: 'Forbidden' });
return res.status(403).json({ error: 'Forbidden' });
}
}
const { userId, itemId } = req.body;
const { userId, itemId } = req.body;
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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 };
@@ -14,13 +14,10 @@
@@ -14,13 +14,10 @@
*/
*/
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { getNetworkPublicKey, getNetworkIssuer } = require('../auth/auth');
const { getNetworkPublicKey, getNetworkIssuer } = require('../auth/auth');
+const { viaProxy } = require('./internal-key');
const AUDIENCE = 'openvibe.live';
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) {
function guard(capability) {
const registered = !!capabilities.get(capability);
const registered = !!capabilities.get(capability);
return function liveServiceGuard(req, res, next) {
return function liveServiceGuard(req, res, next) {
@@ -1,8 +1,12 @@
@@ -1,8 +1,12 @@
'use strict';
'use strict';
// POST /api/cosmetics/internal-unlock grants paid cosmetics, so only the configured INTERNAL_API_KEY
// 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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -12,24 +16,61 @@ const express = require('express');
@@ -12,24 +16,61 @@ const express = require('express');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-cosmetics-internal-'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-cosmetics-internal-'));
process.env.DB_PATH = path.join(tmp, 'live.db');
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.log = () => {};
console.warn = () => {};
console.warn = () => {};
console.error = () => {};
console.error = () => {};
(async () => {
(async () => {
+ require('../server/db/database').initDb();
const app = express();
const app = express();
app.use(express.json());
app.use(express.json());
app.use('/api/cosmetics', require('../server/monetization/cosmetics-routes'));
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');
const server = app.listen(0, '127.0.0.1');
await new Promise((r) => server.once('listening', r));
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 {
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');
process.stdout.write('cosmetics internal-unlock: all checks passed\n');
} finally {
} finally {
server.close();
server.close();