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).
Changes
@@@ -40,11 +40,9 @@ RTP_PORT_MAX=12199# RS256 verification of user JWTs on browser endpoints.OV_NETWORK_URL=https://openvibe.networkOV_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@@@ -15,9 +15,3 @@ description = "Throwaway app keys the local smoke test provisions for its own tecondition = "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$''']@@@ -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, B2and 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@@@ -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: {@@@ -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');@@@ -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 };@@@ -24,7 +24,12 @@ const BOT_UA = /bot|crawl|spider|slurp|facebookexternalhit|preview|discordbot|twlet _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 {@@@ -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 runtimelet 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 runtimeassert.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();@@@ -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',@@@ -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);@@@ -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');
@@ -40,11 +40,9 @@ RTP_PORT_MAX=12199
@@ -40,11 +40,9 @@ RTP_PORT_MAX=12199
# RS256 verification of user JWTs on browser endpoints.
# RS256 verification of user JWTs on browser endpoints.
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
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
# 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
# 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=0 stops the reconcile job (before rolling a backfill back).
MEDIA_OWNER_SUBJECT_SYNC=
MEDIA_OWNER_SUBJECT_SYNC=
MEDIA_OWNER_SUBJECT_INTERVAL_MIN=10
MEDIA_OWNER_SUBJECT_INTERVAL_MIN=10
@@ -15,9 +15,3 @@ description = "Throwaway app keys the local smoke test provisions for its own te
@@ -15,9 +15,3 @@ description = "Throwaway app keys the local smoke test provisions for its own te
condition = "AND"
condition = "AND"
paths = ['''^scripts/smoke-test\.sh$''']
paths = ['''^scripts/smoke-test\.sh$''']
regexes = ['''^(smoke-[a-z]+-key-[0-9]+|wrong-key)$''']
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$''']
@@ -632,8 +632,9 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -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
ingestion fetches through the SSRF guard; other calls go to the configured Network, Live, Events, B2
and R2 endpoints.
and R2 endpoints.
- **Secrets.** `OV_OAUTH_CLIENT_SECRET`, `MEDIA_SIGNING_SECRET`, `MEDIA_SECRET`, `MEDIA_APPS_SEED` /
- **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
## Deploy
@@ -130,7 +130,6 @@ const config = {
@@ -130,7 +130,6 @@ const config = {
network: {
network: {
url: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
url: (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
internalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
internalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
- internalApiKey: process.env.INTERNAL_API_KEY || '',
},
},
apps: {
apps: {
@@ -106,9 +106,9 @@ const ready = (async () => {
@@ -106,9 +106,9 @@ const ready = (async () => {
{
{
const pastes = require('./pastes/routes');
const pastes = require('./pastes/routes');
// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);
// 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 }));
require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config, screenshotsDir: pastes.SCREENSHOTS_DIR, generateSlug: pastes.generateSlug }));
}
}
const userAuth = require('./user-auth');
const userAuth = require('./user-auth');
@@ -5,17 +5,11 @@
@@ -5,17 +5,11 @@
* guard(capability) protects an internal route: loopback only (nothing that came through nginx) and a
* 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
* 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
* 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 { capabilities, http } = require('openvibe-contracts');
const auth = require('./auth');
const auth = require('./auth');
-const config = require('./config');
// Loopback only: the local reverse proxy marks what it forwarded with one of these headers.
// 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']);
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) {
@@ -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 };
@@ -24,7 +24,12 @@ const BOT_UA = /bot|crawl|spider|slurp|facebookexternalhit|preview|discordbot|tw
@@ -24,7 +24,12 @@ const BOT_UA = /bot|crawl|spider|slurp|facebookexternalhit|preview|discordbot|tw
let _secret = null;
let _secret = null;
async function secret() {
async function secret() {
if (_secret) return _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) {
if (!_secret) {
// Persist a random secret so visitor hashes stay stable across restarts.
// Persist a random secret so visitor hashes stay stable across restarts.
try {
try {
@@ -1,21 +1,22 @@
@@ -1,21 +1,22 @@
'use strict';
'use strict';
// POST /internal/avatar-ingest authorization (plan T2, X-Internal-Key retirement): a Network service
// 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
// 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 assert = require('assert');
const http = require('http');
const http = require('http');
const crypto = require('crypto');
const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
process.env.OV_NETWORK_URL = 'https://openvibe.network';
process.env.OV_NETWORK_URL = 'https://openvibe.network';
process.env.MEDIA_PUBLIC_URL = 'https://media.test';
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 () => {
(async () => {
const express = require('express');
const express = require('express');
const auth = require('../server/auth');
const auth = require('../server/auth');
- const { guardOrKey } = require('../server/service-guard');
+ const { guard } = require('../server/service-guard');
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
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
@@ -34,13 +35,15 @@ process.env.INTERNAL_API_KEY = 'k'.repeat(40); // a fake key, built at runtime
let reached = 0;
let reached = 0;
const app = express();
const app = express();
app.use(express.json());
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);
const server = http.createServer(app);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
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 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 {
try {
let r = await post({ authorization: `Bearer ${tok()}` });
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');
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
@@ -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');
assert.strictEqual(r.status, 401, 'a forged token is 401');
r = await post({ authorization: `Bearer ${forged}`, 'x-internal-key': KEY });
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({});
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' });
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 });
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');
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 });
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');
console.log('avatar-ingest auth: all checks passed');
} finally {
} finally {
server.close();
server.close();
@@ -122,7 +122,7 @@ const { spawnSync, execFileSync } = require('child_process');
@@ -122,7 +122,7 @@ const { spawnSync, execFileSync } = require('child_process');
Object.assign(process.env, {
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',
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.
// 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_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_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',
MEDIA_R2_ENDPOINT: 'https://r2.example.invalid', MEDIA_R2_BUCKET: 'r', MEDIA_R2_ACCESS_KEY_ID: 'id', MEDIA_R2_SECRET_ACCESS_KEY: 'key',
@@ -18,7 +18,7 @@ const { ids } = require('openvibe-contracts');
@@ -18,7 +18,7 @@ const { ids } = require('openvibe-contracts');
VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),
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'),
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',
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] = '';
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);
Object.assign(process.env, env);
@@ -28,7 +28,6 @@ const crypto = require('crypto');
@@ -28,7 +28,6 @@ const crypto = require('crypto');
const LIVE_KEY = S('live-app-key');
const LIVE_KEY = S('live-app-key');
const GAMES_KEY = S('games-app-key');
const GAMES_KEY = S('games-app-key');
const SECRET_ENV = {
const SECRET_ENV = {
- INTERNAL_API_KEY: S('internal-api-key'),
MEDIA_SIGNING_SECRET: S('media-signing-secret'),
MEDIA_SIGNING_SECRET: S('media-signing-secret'),
MEDIA_SECRET: S('media-secret'),
MEDIA_SECRET: S('media-secret'),
VIEW_HASH_SECRET: S('view-hash-secret'),
VIEW_HASH_SECRET: S('view-hash-secret'),
@@ -247,8 +246,8 @@ const crypto = require('crypto');
@@ -247,8 +246,8 @@ const crypto = require('crypto');
await call('GET', `${signed}x`);
await call('GET', `${signed}x`);
await call('GET', signed);
await call('GET', signed);
await call('GET', `/api/v2/live/objects/${privObj}/download?format=json`, { headers: IDS.app });
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('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/callback?code=forged&state=forged');
await call('GET', '/auth/login?next=/me');
await call('GET', '/auth/login?next=/me');