openvibers/openvibe.media Commits

trust proxy is loopback only (was true): req.ip is the X-Forwarded-For that the local nginx set when a request came through it, and the socket address for anyone else, so a caller reaching port 4100 directly (HOST defaults to 0.0.0.0) can no longer choose its IP. The view counter read CF-Connecting-IP / X-Forwarded-For itself, from any peer; it now keys on req.ip through server/client-ip.js, like paste limits and the live-frame rate limit. The unused getRequesterIp header reader in public/routes.js is gone. test/trust-proxy.test.js checks the setting on an app (a loopback proxy's forwarded address is believed, a non-loopback peer's X-Forwarded-For and CF-Connecting-IP are not), and boots the real server: forwarded clients get their own live-frame budgets, while a direct caller rotating X-Forwarded-For is rate-limited as one address (it fails with trust proxy true). README: client addresses, and the new jobs and scripts in the layout.

OP OpenVibers committed 24 Sept 2026 · 47b43491a3 · 6 files, +139 −9


Changes

ddiff --git a/README.md b/README.md
iindex 9260c50..b23a24d 100644
---- a/README.md
++++ b/README.md
@@@ -49,12 +49,17 @@ server/
change's transaction; payload queue.jobEvent: ids, state, ISO times,
has_result, never params/result/error text/creator), worker (light/heavy lanes, leases, retries,
cancellation), routes (/api/v2/:app/jobs), types: thumbnail.regenerate,
-- invariant.scan (proposes split/remux), object.split, object.remux
++ invariant.scan (proposes split/remux), object.split, object.remux,
++ vod.finalize (orphans and failed finalizes, with backoff),
++ vod.duration.reconcile (stored vs measured durations, local and B2/R2)
++ client-ip.js trust proxy = loopback; req.ip is the only client address
(openvibe-shared v1.5.1, openvibe-contracts v0.33.0, openvibe-sdk v0.5.0: pinned release tarballs, installed by npm)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/backfill-objects.js / reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
scripts/r2-eviction-drill.js evict one VOD's R2 copy, prove B2 serves it, re-warm R2; JSON artifact (dry run by default)
++scripts/vod-duration-reconcile.js stored VOD durations vs the real files (dry run by default; --apply --backup; --rollback)
++scripts/vods-orphans-report.js read-only report on the B2 vods-orphans/ prefix (matches, sizes, recommendations)
```
## Visitor sign-in
@@@ -300,6 +305,14 @@ backup and starts a second Media from this checkout on 127.0.0.1:14100 with `MED
`test/drill-mode.test.js` boots the real server that way against a copy whose app has a webhook URL
and checks each point.
++## Client addresses
++
++Express `trust proxy` is `loopback` (`server/client-ip.js`): `req.ip` is the `X-Forwarded-For` that the
++local nginx set when the request came through it, and the socket address for anyone else. A caller
++that reaches port 4100 directly cannot choose its IP by sending `X-Forwarded-For` or
++`CF-Connecting-IP` itself. View counting, paste limits and the live-frame rate limit read `req.ip`,
++never the headers (`test/trust-proxy.test.js`).
++
## Health, readiness and metrics
| path | behavior |
ddiff --git a/server/client-ip.js b/server/client-ip.js
nnew file mode 100644
iindex 0000000..c5a3978
---- /dev/null
++++ b/server/client-ip.js
@@@ -0,0 +1,19 @@
++/**
++ * OpenVibe.Media — who the client is.
++ *
++ * Media sits behind nginx on the same host (deploy/nginx/openvibe.media.conf), which sets
++ * X-Forwarded-For / X-Real-IP / CF-Connecting-IP from its own $remote_addr. Only that loopback peer
++ * is trusted (Express 'trust proxy' = loopback): req.ip is the forwarded address when the request
++ * came through the local proxy and the socket address otherwise, so a caller that reaches the port
++ * directly cannot choose its IP by sending the headers itself. Code reads req.ip, never the headers.
++ */
++'use strict';
++
++const TRUST_PROXY = 'loopback';
++
++/** The client's address as Express resolved it under TRUST_PROXY. */
++function clientIp(req) {
++ return String((req && (req.ip || (req.socket && req.socket.remoteAddress))) || 'unknown');
++}
++
++module.exports = { TRUST_PROXY, clientIp };
ddiff --git a/server/index.js b/server/index.js
iindex 8191567..fde849a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -48,7 +48,10 @@ if (!drill.enabled) {
}
const app = express();
--app.set('trust proxy', true);
++// Only the local reverse proxy (nginx on this host) is trusted to say who the client is: req.ip is
++// its X-Forwarded-For for loopback peers and the socket address for anyone else, so a direct caller
++// cannot pick its own IP (views, rate limits). `true` trusted every peer's header.
++app.set('trust proxy', require('./client-ip').TRUST_PROXY);
// Metrics first, so every route below is measured (GET /metrics: loopback callers only).
const release = require('openvibe-shared/release').createRelease({ service: 'media', root: require('path').join(__dirname, '..') });
const observability = require('./observability');
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 6f00d20..db3da86 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -38,10 +38,6 @@ const MIME_TYPES = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
};
--function getRequesterIp(req) {
-- return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.socket?.remoteAddress || 'unknown';
--}
--
const views = require('../views/service');
// Views + unique views live in ../views/service (visit-based with a cooldown, hashed
// visitors, owner/bot/rate-limit exclusions). A playback session counts once: only the
ddiff --git a/server/views/service.js b/server/views/service.js
iindex e332984..32f5ed1 100644
---- a/server/views/service.js
++++ b/server/views/service.js
@@@ -85,9 +85,9 @@ function ensureSchema() {
} catch { /* */ }
}
--function clientIp(req) {
-- return (req.headers['cf-connecting-ip'] || (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.ip || (req.socket && req.socket.remoteAddress) || 'unknown').toString();
--}
++// req.ip under 'trust proxy' = loopback (server/client-ip.js): the forwarding headers count only
++// when the local proxy sent them, so a direct caller cannot pose as many visitors.
++const { clientIp } = require('../client-ip');
function visitorForIp(ip) { return 'ip:' + crypto.createHmac('sha256', secret()).update(String(ip || 'unknown')).digest('hex').slice(0, 24); }
// Per-IP event budget (sliding minute), in memory — protects the DB from a spammer, not a metric.
ddiff --git a/test/trust-proxy.test.js b/test/trust-proxy.test.js
nnew file mode 100644
iindex 0000000..f167144
---- /dev/null
++++ b/test/trust-proxy.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++// 'trust proxy' is loopback only (server/client-ip.js, server/index.js): a request that came through
++// the local nginx is attributed to the X-Forwarded-For address nginx set, and a caller reaching the
++// port directly cannot choose its IP by sending X-Forwarded-For / CF-Connecting-IP itself (views and
++// rate limits key on req.ip). The real server is booted to prove index.js applies it.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const net = require('net');
++const path = require('path');
++const http = require('http');
++const { spawn } = require('child_process');
++const express = require('express');
++
++const { TRUST_PROXY, clientIp } = require('../server/client-ip');
++
++const get = (host, port, p, headers = {}) => new Promise((resolve, reject) => {
++ const req = http.request({ host, port, path: p, headers }, (res) => { let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve({ status: res.statusCode, body: b })); });
++ req.on('error', reject);
++ req.end();
++});
++const externalAddress = () => {
++ for (const list of Object.values(os.networkInterfaces())) for (const a of list || []) if (a.family === 'IPv4' && !a.internal) return a.address;
++ return null;
++};
++
++(async () => {
++ assert.strictEqual(TRUST_PROXY, 'loopback');
++
++ // ── 1. The setting, on an app like index.js's: loopback proxies are believed, nobody else ──
++ const app = express();
++ app.set('trust proxy', TRUST_PROXY);
++ app.get('/ip', (req, res) => res.json({ ip: req.ip, client: clientIp(req) }));
++ const server = await new Promise((r) => { const s = app.listen(0, '0.0.0.0', () => r(s)); });
++ const port = server.address().port;
++ let r = JSON.parse((await get('127.0.0.1', port, '/ip', { 'X-Forwarded-For': '203.0.113.7' })).body);
++ assert.deepStrictEqual([r.ip, r.client], ['203.0.113.7', '203.0.113.7'], 'through the local proxy: the forwarded client');
++ r = JSON.parse((await get('127.0.0.1', port, '/ip')).body);
++ assert.ok(/127\.0\.0\.1$/.test(r.ip), `loopback caller without a proxy header: ${r.ip}`);
++ const ext = externalAddress();
++ if (ext) {
++ r = JSON.parse((await get(ext, port, '/ip', { 'X-Forwarded-For': '198.51.100.9', 'CF-Connecting-IP': '198.51.100.9' })).body);
++ assert.ok(r.ip.endsWith(ext) && r.client.endsWith(ext), `a direct caller cannot choose its IP (got ${r.ip})`);
++ console.log('✅ a non-loopback peer\'s X-Forwarded-For / CF-Connecting-IP are ignored');
++ } else {
++ console.log('⚠️ no non-loopback interface: the direct-caller check is skipped');
++ }
++ server.close();
++ // The view counter keys on req.ip, not on the headers.
++ assert.strictEqual(require('../server/views/service').clientIp({ ip: '192.0.2.4', headers: { 'cf-connecting-ip': '9.9.9.9', 'x-forwarded-for': '8.8.8.8' } }), '192.0.2.4');
++ assert.strictEqual(clientIp({ socket: { remoteAddress: '::1' }, headers: {} }), '::1');
++ console.log('✅ trust proxy loopback: the local proxy\'s forwarded address, else the socket; views key on req.ip');
++
++ // ── 2. index.js applies it (the real server, booted) ──
++ const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
++ assert.ok(!/set\('trust proxy',\s*true\)/.test(src), 'no trust-everyone left');
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-trust-'));
++ const freePort = await new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
++ const env = { ...process.env, DB_PATH: path.join(tmp, 'media.db'), VOD_PATH: path.join(tmp, 'vods'), CLIPS_PATH: path.join(tmp, 'clips'), PASTES_PATH: path.join(tmp, 'pastes'),
++ THUMBNAILS_PATH: path.join(tmp, 'thumbs'), FILES_PATH: path.join(tmp, 'files'), OBJECTS_PATH: path.join(tmp, 'objects'), PORT: String(freePort), HOST: ext ? '0.0.0.0' : '127.0.0.1',
++ NODE_ENV: 'test', MEDIA_JOBS_ENABLED: '0', MEDIA_VERIFY_ENABLED: '0', MEDIA_OWNER_SUBJECT_SYNC: '0', EVENTS_URL: '', MEDIA_APP_KEYS: 'live:live-key-for-trust-test' };
++ const child = spawn(process.execPath, [path.join(__dirname, '../server/index.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] });
++ let log = '';
++ child.stdout.on('data', (d) => { log += d; });
++ child.stderr.on('data', (d) => { log += d; });
++ let up = false;
++ for (let i = 0; i < 100 && !up; i++) {
++ try { up = (await get('127.0.0.1', freePort, '/healthz')).status === 200; } catch { await new Promise((res) => setTimeout(res, 100)); }
++ }
++ assert.ok(up, `the server booted: ${log.slice(-500)}`);
++ // /metrics answers loopback callers only and refuses anything that came through a proxy; with
++ // trust proxy loopback, Express agrees the request came from the local proxy.
++ const direct = await get('127.0.0.1', freePort, '/metrics');
++ const proxied = await get('127.0.0.1', freePort, '/metrics', { 'X-Forwarded-For': '203.0.113.7' });
++ assert.strictEqual(direct.status, 200);
++ assert.strictEqual(proxied.status, 404, 'a proxied caller is not local');
++ // The live-frame endpoint rate-limits per req.ip (burst 30). Through the local proxy every
++ // forwarded client has its own bucket; a direct caller rotating X-Forwarded-For does not.
++ const burst = async (host, n) => {
++ let limited = 0;
++ for (let i = 0; i < n; i++) {
++ const res = await get(host, freePort, '/live/1/frame.jpg?format=json', { 'X-Forwarded-For': `198.51.100.${i + 1}` });
++ if (res.status === 429) limited++;
++ }
++ return limited;
++ };
++ assert.strictEqual(await burst('127.0.0.1', 45), 0, 'forwarded clients each get their own budget');
++ if (ext) {
++ const limited = await burst(ext, 45);
++ assert.ok(limited > 0, 'a direct caller cannot mint new identities with X-Forwarded-For (the real server trusts loopback only)');
++ }
++ child.kill('SIGTERM');
++ await new Promise((res) => child.on('exit', res));
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log('✅ the booted server runs with trust proxy loopback');
++
++ console.log('\n✅ All trust-proxy tests passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });