openvibers/openvibe.media Commits

X-Internal-Key is gone from Media (plan T2, the last step): POST /internal/avatar-ingest takes only a Network service token holding media.avatar.ingest, loopback only; guardOrKey, config.network.internalApiKey and INTERNAL_API_KEY are deleted (production no longer sets it), and the visitor-hash secret is VIEW_HASH_SECRET or MEDIA_SECRET with a named warning when neither is set in production. Tests: token with the capability passes, without it 403, wrong audience and sandbox 401, no credential or a forged Bearer refused. npm test and test:pg 75/76, 1 skipped (verified).

OP OpenVibers committed 29 Sept 2026 · 809b06bb41 · 11 files, +51 −60


Changes

ddiff --git a/.env.example b/.env.example
iindex 3288f2d..84f646a 100644
---- a/.env.example
++++ b/.env.example
@@@ -40,11 +40,9 @@ RTP_PORT_MAX=12199
# RS256 verification of user JWTs on browser endpoints.
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
--# Per-service key for Network's /internal/* endpoints (header X-Internal-Key).
--INTERNAL_API_KEY=
# Owner subjects (docs/object-model.md#owner-subjects): objects that name only an app-local owner
# get the owner's Network subject from POST /internal/identity/resolve-batch, with a service token
--# (OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET, capability identity.subject.resolve) or else the key.
++# (OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET, capability identity.subject.resolve).
# MEDIA_OWNER_SUBJECT_SYNC=0 stops the reconcile job (before rolling a backfill back).
MEDIA_OWNER_SUBJECT_SYNC=
MEDIA_OWNER_SUBJECT_INTERVAL_MIN=10
ddiff --git a/.gitleaks.toml b/.gitleaks.toml
iindex b852c11..d3a9a94 100644
---- a/.gitleaks.toml
++++ b/.gitleaks.toml
@@@ -15,9 +15,3 @@ description = "Throwaway app keys the local smoke test provisions for its own te
condition = "AND"
paths = ['''^scripts/smoke-test\.sh$''']
regexes = ['''^(smoke-[a-z]+-key-[0-9]+|wrong-key)$''']
--
--[[allowlists]]
--description = "Made-up INTERNAL_API_KEY in the owner-subject test's environment (test/owner-subject.test.js); it opens nothing"
--condition = "AND"
--paths = ['''^test/owner-subject\.test\.js$''']
--regexes = ['''^internal-key-0123456789abcdef$''']
ddiff --git a/README.md b/README.md
iindex bcdf970..7cccddf 100644
---- a/README.md
++++ b/README.md
@@@ -632,8 +632,9 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
ingestion fetches through the SSRF guard; other calls go to the configured Network, Live, Events, B2
and R2 endpoints.
- **Secrets.** `OV_OAUTH_CLIENT_SECRET`, `MEDIA_SIGNING_SECRET`, `MEDIA_SECRET`, `MEDIA_APPS_SEED` /
-- `MEDIA_APP_KEYS`, `MEDIA_B2_*`, `MEDIA_R2_*`, `MEDIA_INBOUND_EVENTS_SECRET`, `VIEW_HASH_SECRET` and the
-- fallback `INTERNAL_API_KEY` live in `/etc/openvibe/media.env` (0600), by name only.
++ `MEDIA_APP_KEYS`, `MEDIA_B2_*`, `MEDIA_R2_*`, `MEDIA_INBOUND_EVENTS_SECRET` and `VIEW_HASH_SECRET` (the
++ secret visitor hashes are derived from; it falls back to `MEDIA_SECRET`) live in
++ `/etc/openvibe/media.env` (0600), by name only.
## Deploy
ddiff --git a/server/config.js b/server/config.js
iindex 31ce4b2..c34a69b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -130,7 +130,6 @@ const config = {
network: {
url: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
internalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
-- internalApiKey: process.env.INTERNAL_API_KEY || '',
},
apps: {
ddiff --git a/server/index.js b/server/index.js
iindex fa31811..cecaa41 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -106,9 +106,9 @@ const ready = (async () => {
{
const pastes = require('./pastes/routes');
// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);
-- // while Network moves to tokens, a request with no Bearer still passes with INTERNAL_API_KEY
-- // (server/service-guard.js). A Bearer is judged on the token alone (401 bad, 403 no capability).
-- app.post('/internal/avatar-ingest', require('./service-guard').guardOrKey('media.avatar.ingest'),
++ // a Bearer is judged on the token alone (401 bad, 403 no capability) and nothing else opens the
++ // route (server/service-guard.js).
++ app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),
require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config, screenshotsDir: pastes.SCREENSHOTS_DIR, generateSlug: pastes.generateSlug }));
}
const userAuth = require('./user-auth');
ddiff --git a/server/service-guard.js b/server/service-guard.js
iindex efad908..91e9e47 100644
---- a/server/service-guard.js
++++ b/server/service-guard.js
@@@ -5,17 +5,11 @@
* guard(capability) protects an internal route: loopback only (nothing that came through nginx) and a
* Network service token holding `capability` for audience openvibe.media, verified with the same key
* Media verifies user JWTs with (server/auth.js verifyServiceTokenResult; sandbox refused). The token
-- * is judged on its own: a Bearer is never downgraded to another credential.
-- *
-- * guardOrKey(capability) is the migration form used today by POST /internal/avatar-ingest: a request
-- * that presents a Bearer is judged on the token alone; one without a Bearer still passes with the
-- * INTERNAL_API_KEY exactly as before. Once Network sends tokens, the route becomes guard(capability)
-- * and the key path goes.
++ * is judged on its own: a Bearer is never downgraded to another credential, and nothing else opens the
++ * route (the X-Internal-Key path is retired).
*/
--const crypto = require('crypto');
const { capabilities, http } = require('openvibe-contracts');
const auth = require('./auth');
--const config = require('./config');
// Loopback only: the local reverse proxy marks what it forwarded with one of these headers.
const viaProxy = (req) => !!(req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']);
@@@ -35,20 +29,4 @@ function guard(capability) {
};
}
--/**
-- * While X-Internal-Key is retired: a request that presents a Bearer is judged on the token alone (never
-- * downgraded to the key); one without a Bearer still passes with the key from loopback, exactly as the
-- * old guard did (404 otherwise, so the route is not enumerated).
-- */
--function guardOrKey(capability) {
-- const tokenGuard = guard(capability);
-- return function mediaServiceGuardOrKey(req, res, next) {
-- if (viaProxy(req) || !isLocal(req)) return res.status(404).json({ error: 'Not found' });
-- if (String(req.headers.authorization || '').startsWith('Bearer ')) return tokenGuard(req, res, next);
-- const want = String(config.network.internalApiKey || ''), got = String(req.headers['x-internal-key'] || '');
-- if (want.length < 16 || got.length !== want.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) return res.status(404).json({ error: 'Not found' });
-- next();
-- };
--}
--
--module.exports = { guard, guardOrKey };
++module.exports = { guard };
ddiff --git a/server/views/service.js b/server/views/service.js
iindex 45516aa..748b49f 100644
---- a/server/views/service.js
++++ b/server/views/service.js
@@@ -24,7 +24,12 @@ const BOT_UA = /bot|crawl|spider|slurp|facebookexternalhit|preview|discordbot|tw
let _secret = null;
async function secret() {
if (_secret) return _secret;
-- _secret = process.env.VIEW_HASH_SECRET || process.env.MEDIA_SECRET || process.env.INTERNAL_API_KEY || '';
++ _secret = process.env.VIEW_HASH_SECRET || process.env.MEDIA_SECRET || '';
++ if (!_secret && process.env.NODE_ENV === 'production') {
++ // Hashes already on disk were derived from the old secret; without it they can never match again.
++ // Never the value, only the name.
++ console.warn('[views] VIEW_HASH_SECRET and MEDIA_SECRET are unset in production; visitor hashes fall back to the persisted per-process secret and past hashes stop matching.');
++ }
if (!_secret) {
// Persist a random secret so visitor hashes stay stable across restarts.
try {
ddiff --git a/test/avatar-ingest-auth.test.js b/test/avatar-ingest-auth.test.js
iindex 7bfa339..fed05b2 100644
---- a/test/avatar-ingest-auth.test.js
++++ b/test/avatar-ingest-auth.test.js
@@@ -1,21 +1,22 @@
'use strict';
// POST /internal/avatar-ingest authorization (plan T2, X-Internal-Key retirement): a Network service
// token holding media.avatar.ingest reaches the handler; a token without it is 403; another audience
--// or a sandbox token is 401; a Bearer is judged on the token alone and never downgraded to
--// INTERNAL_API_KEY; the key alone still works; and anything that came through the proxy is refused
--// whatever it carries (server/service-guard.js).
++// or a sandbox token is 401; the key is gone, so the header alone opens nothing and a bad Bearer is
++// never downgraded to it; and anything that came through the proxy is refused whatever it carries
++// (server/service-guard.js).
const assert = require('assert');
const http = require('http');
const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
process.env.OV_NETWORK_URL = 'https://openvibe.network';
process.env.MEDIA_PUBLIC_URL = 'https://media.test';
--process.env.INTERNAL_API_KEY = 'k'.repeat(40); // a fake key, built at runtime so no literal looks like a secret
(async () => {
const express = require('express');
const auth = require('../server/auth');
-- const { guardOrKey } = require('../server/service-guard');
++ const { guard } = require('../server/service-guard');
const { serviceAuth } = require('openvibe-contracts');
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
@@@ -34,13 +35,15 @@ process.env.INTERNAL_API_KEY = 'k'.repeat(40); // a fake key, built at runtime
let reached = 0;
const app = express();
app.use(express.json());
-- app.post('/internal/avatar-ingest', guardOrKey('media.avatar.ingest'), (req, res) => { reached++; res.json({ ok: true, principal: req.principal || null }); });
++ app.post('/internal/avatar-ingest', guard('media.avatar.ingest'), (req, res) => { reached++; res.json({ ok: true, principal: req.principal || null }); });
const server = http.createServer(app);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
const post = (headers = {}) => fetch(`${base}/internal/avatar-ingest`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: '{}' }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
-- const KEY = process.env.INTERNAL_API_KEY;
++ // The retired credential, built at runtime so no literal in this file looks like a secret. It is now
++ // only a header value: whatever it is, it opens nothing.
++ const KEY = 'k'.repeat(40);
try {
let r = await post({ authorization: `Bearer ${tok()}` });
assert.deepStrictEqual([r.status, r.body.ok], [200, true], 'a token with media.avatar.ingest reaches the handler');
@@@ -59,24 +62,38 @@ process.env.INTERNAL_API_KEY = 'k'.repeat(40); // a fake key, built at runtime
assert.strictEqual(r.status, 401, 'a forged token is 401');
r = await post({ authorization: `Bearer ${forged}`, 'x-internal-key': KEY });
-- assert.strictEqual(r.status, 401, 'a Bearer is never downgraded to the key');
++ assert.strictEqual(r.status, 401, 'a Bearer beside the key is judged on the token alone');
r = await post({});
-- assert.strictEqual(r.status, 404, 'no credential looks like a route that is not there');
++ assert.deepStrictEqual([r.status, r.body.code], [401, 'token.missing'], 'no credential is 401 (a service token is required)');
r = await post({ 'x-internal-key': 'wrong-key-0000000000000000' });
-- assert.strictEqual(r.status, 404, 'a wrong key is refused');
++ assert.strictEqual(r.status, 401, 'a wrong key is refused');
r = await post({ 'x-internal-key': KEY });
-- assert.deepStrictEqual([r.status, r.body.ok], [200, true], 'the key alone still reaches the handler');
++ assert.strictEqual(r.status, 401, 'the key alone is refused: X-Internal-Key is retired');
-- r = await post({ 'x-forwarded-for': '203.0.113.7', authorization: `Bearer ${tok()}`, 'x-internal-key': KEY });
++ r = await post({ 'x-forwarded-for': '203.0.113.7', authorization: `Bearer ${tok()}` });
assert.strictEqual(r.status, 404, 'a request that came through the proxy is refused whatever it carries');
r = await post({ 'x-real-ip': '203.0.113.7', 'x-internal-key': KEY });
-- assert.strictEqual(r.status, 404, 'a proxy header with the key only is refused');
++ assert.strictEqual(r.status, 404, 'a proxy header with the key is refused');
++
++ assert.strictEqual(reached, 1, 'only the authorized request reached the handler');
++
++ // The retirement itself: no file under server/ reads the retired key, by any of its names.
++ const RETIRED = /\b(INTERNAL_API_KEY|OV_INTERNAL_KEY|internalApiKey)\b|x-internal-key/i;
++ const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
++ const p = path.join(dir, e.name);
++ return e.isDirectory() ? walk(p) : (e.name.endsWith('.js') ? [p] : []);
++ });
++ const offenders = [];
++ for (const f of walk(path.join(__dirname, '..', 'server'))) {
++ const code = fs.readFileSync(f, 'utf8').split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n'); // comments do not read anything
++ if (RETIRED.test(code)) offenders.push(path.relative(path.join(__dirname, '..'), f));
++ }
++ assert.deepStrictEqual(offenders, [], `X-Internal-Key is retired everywhere under server/ (still read in: ${offenders.join(', ') || '—'})`);
-- assert.strictEqual(reached, 2, 'only the two authorized requests reached the handler');
console.log('avatar-ingest auth: all checks passed');
} finally {
server.close();
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex dfc7204..b488be1 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -122,7 +122,7 @@ const { spawnSync, execFileSync } = require('child_process');
Object.assign(process.env, {
MEDIA_DRILL: '1', DATABASE_URL: DRILL_URL, DATABASE_DIRECT_URL: '', HOST: '127.0.0.1', PORT: String(port), NODE_ENV: 'production',
// What the production env file sets; a normal boot would act on all of it.
-- EVENTS_URL: 'http://127.0.0.1:9', OV_OAUTH_CLIENT_SECRET: 's'.repeat(40), INTERNAL_API_KEY: 'k'.repeat(40),
++ EVENTS_URL: 'http://127.0.0.1:9', OV_OAUTH_CLIENT_SECRET: 's'.repeat(40),
MEDIA_APP_KEYS: `live:${'n'.repeat(40)},games:${'g'.repeat(40)}`,
MEDIA_B2_ENDPOINT: 'https://s3.example.invalid', MEDIA_B2_BUCKET: 'b', MEDIA_B2_KEY_ID: 'id', MEDIA_B2_APP_KEY: 'key',
MEDIA_R2_ENDPOINT: 'https://r2.example.invalid', MEDIA_R2_BUCKET: 'r', MEDIA_R2_ACCESS_KEY_ID: 'id', MEDIA_R2_SECRET_ACCESS_KEY: 'key',
ddiff --git a/test/owner-subject.test.js b/test/owner-subject.test.js
iindex b5710f8..99bf2b7 100644
---- a/test/owner-subject.test.js
++++ b/test/owner-subject.test.js
@@@ -18,7 +18,7 @@ const { ids } = require('openvibe-contracts');
VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),
THUMBNAILS_PATH: dir('thumbnails'), PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'), ASSETS_PATH: dir('assets'),
MEDIA_PUBLIC_URL: 'https://media.test', OV_NETWORK_URL: 'http://127.0.0.1:9', MEDIA_SIGNING_SECRET: 'test-signing-secret',
-- INTERNAL_API_KEY: 'internal-key-0123456789abcdef', OV_OAUTH_CLIENT_ID: 'media', OV_OAUTH_CLIENT_SECRET: 'media-secret',
++ OV_OAUTH_CLIENT_ID: 'media', OV_OAUTH_CLIENT_SECRET: 'media-secret',
};
for (const k of ['MEDIA_B2_ENDPOINT', 'MEDIA_B2_BUCKET', 'MEDIA_R2_ENDPOINT', 'MEDIA_R2_BUCKET', 'EVENTS_URL', 'MEDIA_OWNER_SUBJECT_SYNC']) process.env[k] = '';
Object.assign(process.env, env);
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex 170f96e..0422ab7 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -28,7 +28,6 @@ const crypto = require('crypto');
const LIVE_KEY = S('live-app-key');
const GAMES_KEY = S('games-app-key');
const SECRET_ENV = {
-- INTERNAL_API_KEY: S('internal-api-key'),
MEDIA_SIGNING_SECRET: S('media-signing-secret'),
MEDIA_SECRET: S('media-secret'),
VIEW_HASH_SECRET: S('view-hash-secret'),
@@@ -247,8 +246,8 @@ const crypto = require('crypto');
await call('GET', `${signed}x`);
await call('GET', signed);
await call('GET', `/api/v2/live/objects/${privObj}/download?format=json`, { headers: IDS.app });
-- await call('POST', '/internal/avatar-ingest', { headers: { 'x-internal-key': 'wrong' }, json: { url: 'https://127.0.0.1/a.png', user_id: 1 } });
-- await call('POST', '/internal/avatar-ingest', { headers: { 'x-internal-key': SECRET_ENV.INTERNAL_API_KEY }, json: { url: 'https://127.0.0.1/a.png', user_id: 1 } });
++ await call('POST', '/internal/avatar-ingest', { json: { url: 'https://127.0.0.1/a.png', user_id: 1 } }); // no service token: refused, and it says so
++ await call('POST', '/internal/avatar-ingest', { headers: { authorization: 'Bearer forged.token.value' }, json: { url: 'https://127.0.0.1/a.png', user_id: 1 } });
await call('POST', '/internal/events', { headers: { 'content-type': 'application/json', 'x-openvibe-signature': 'v2=forged' }, body: '{}' });
await call('GET', '/auth/callback?code=forged&state=forged');
await call('GET', '/auth/login?next=/me');