openvibers/openvibe.media Commits

The avatar fetch uses openvibe-shared/egress's address rule (roadmap WS-R task 5; low): its own copy had drifted and took the IPv6 forms that wrap an internal IPv4 address (v4-compatible ::127.0.0.1, 6to4 2002:7f00:1::, Teredo), site-local fec0::/10 and a non-canonical ::1 as public, and handed IPv6 literals ([::1]) to DNS instead of judging them. Literals are now checked as addresses and names that can only be internal (localhost, *.local, *.internal, single labels) are refused before DNS; the pinned connection and the per-hop redirect checks are unchanged. test/security-ssrf.test.js: an inventory of every outbound call in server/ (a new one fails until classified; only the avatar fetch takes a user-chosen address, and only app seeding writes a webhook URL); the address rule over loopback, RFC 1918, metadata, CGNAT, 0/8, multicast and their IPv6 wrappings; 38 URLs (decimal, octal, hex and short IPv4, bracketed IPv6, internal names, other schemes, ports, credentials) refused with no DNS lookup or socket; DNS answers with any internal address and redirects to internal hosts refused without connecting; DNS rebinding (the socket's lookup answers the checked address and nothing is resolved again); POST /internal/avatar-ingest storing nothing; and the RTMP pull allow-list refusing encoded, IPv6-mapped and look-alike spellings. Checked by breaking it: the previous guard fails it.

OP OpenVibers committed 26 Sept 2026 · 1157a5459a · 2 files, +193 −23


Changes

ddiff --git a/server/avatars/ingest.js b/server/avatars/ingest.js
iindex a45d60e..a0602f4 100644
---- a/server/avatars/ingest.js
++++ b/server/avatars/ingest.js
@@@ -27,31 +27,17 @@ const sharp = require('sharp');
const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZE = 512;
--function isPublicAddress(ip) {
-- if (net.isIPv4(ip)) {
-- const [a, b] = ip.split('.').map(Number);
-- if (a === 0 || a === 10 || a === 127 || a >= 224) return false;
-- if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT
-- if (a === 169 && b === 254) return false; // link-local / cloud metadata
-- if (a === 172 && b >= 16 && b <= 31) return false;
-- if (a === 192 && (b === 168 || b === 0)) return false;
-- if (a === 198 && (b === 18 || b === 19)) return false;
-- return true;
-- }
-- if (net.isIPv6(ip)) {
-- const x = ip.toLowerCase();
-- if (x === '::' || x === '::1') return false;
-- if (x.startsWith('::ffff:')) return isPublicAddress(x.slice(7));
-- if (/^f[cd]/.test(x) || /^fe[89ab]/.test(x) || x.startsWith('ff')) return false; // unique-local, link-local, multicast
-- if (x.startsWith('64:ff9b:') || x.startsWith('2001:db8:')) return false;
-- return true;
-- }
-- return false;
--}
++// The address rule is openvibe-shared/egress's (the strictest union the network's copies drifted from):
++// it also refuses the IPv6 forms that wrap a private IPv4 address (v4-compatible, 6to4, Teredo, NAT64),
++// site-local and documentation ranges. IPv6 literals ([::1]) are judged as addresses, not sent to DNS.
++const egress = require('openvibe-shared/egress');
++const isPublicAddress = (ip) => egress.isPublicAddress(ip);
async function resolvePublic(hostname) {
-- if (net.isIP(hostname)) { if (!isPublicAddress(hostname)) throw new Error('That address is not on the public internet'); return { address: hostname, family: net.isIPv6(hostname) ? 6 : 4 }; }
-- const all = await dns.lookup(hostname, { all: true, verbatim: true });
++ const host = egress.normalizeHost(hostname);
++ if (net.isIP(host)) { if (!isPublicAddress(host)) throw new Error('That address is not on the public internet'); return { address: host, family: net.isIPv6(host) ? 6 : 4 }; }
++ if (egress.isInternalName(host)) throw new Error('That address is not on the public internet');
++ const all = await dns.lookup(host, { all: true, verbatim: true });
if (!all.length) throw new Error('That host could not be found');
if (!all.every(a => isPublicAddress(a.address))) throw new Error('That address is not on the public internet');
return all.find(a => a.family === 4) || all[0];
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
nnew file mode 100644
iindex 0000000..252d3e9
---- /dev/null
++++ b/test/security-ssrf.test.js
@@@ -0,0 +1,184 @@
++'use strict';
++// SSRF (roadmap WS-R task 5). Media connects to an address someone else chose in two places:
++// - POST /internal/avatar-ingest fetches the picture URL a person gave (server/avatars/ingest.js), through
++// its guard: https only, openvibe-shared/egress's address rule for every DNS answer and literal, the
++// connection pinned to the checked address, every redirect hop checked again;
++// - POST /api/v1/:app/vods/:id/ingest/rtmp makes ffmpeg pull an app-supplied rtmp_url, which must name an
++// allow-listed host:port (MEDIA_RTMP_PULL_ALLOW; test/rtmp-allowlist.test.js has the URL grammar).
++// Every other outbound request goes to an operator-configured address (Network, Events, Live's internal
++// URL, B2/R2, an app's webhook_url from MEDIA_APPS_SEED). The inventory below fails when a new outbound
++// call appears in server/ until it is classified here, and no route lets a tenant set a webhook URL.
++// No test here needs the network: literals and internal names are refused before DNS, and DNS answers
++// and the socket are stubbed.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const dns = require('dns');
++const https = require('https');
++
++const ROOT = path.join(__dirname, '..');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-ssrf-'));
++process.env.DB_PATH = path.join(tmp, 'media.db');
++process.env.PASTES_PATH = path.join(tmp, 'pastes');
++process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++delete process.env.MEDIA_RTMP_PULL_ALLOW;
++
++// ── Inventory: every outbound call in server/, and whose address it uses ──
++const OUTBOUND = {
++ 'server/avatars/ingest.js': 'a person\'s picture URL: safeFetchImage, tested below',
++ 'server/auth.js': 'Network JWKS (OV_NETWORK_URL)',
++ 'server/user-auth.js': 'Network OAuth and JWKS (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL)',
++ 'server/webhooks.js': 'an app\'s webhook_url, set by the operator (MEDIA_APPS_SEED)',
++ 'server/thumbnails/live-frame-service.js': 'the app\'s internal URL (APP_INTERNAL_URLS), fixed path',
++ 'server/public/dev-data.js': 'the app\'s internal URL (APP_INTERNAL_URLS), ids and encoded names in the path',
++ 'server/drill.js': 'restore drill: only this server itself',
++ 'server/me/client.js': 'browser script, same-origin',
++};
++{
++ const found = new Set();
++ const walk = (d) => {
++ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
++ const p = path.join(d, e.name);
++ if (e.isDirectory()) walk(p);
++ else if (p.endsWith('.js') && /\bfetch\(|\bhttps?\.(get|request)\(|\b(net|tls)\.connect\(|require\(['"](axios|got|node-fetch|undici)['"]\)/.test(fs.readFileSync(p, 'utf8'))) found.add(path.relative(ROOT, p));
++ }
++ };
++ walk(path.join(ROOT, 'server'));
++ for (const f of found) assert.ok(OUTBOUND[f], `${f} makes an outbound request: classify it in test/security-ssrf.test.js (a user-chosen address must go through a guard)`);
++ for (const f of Object.keys(OUTBOUND)) assert.ok(found.has(f), `${f} no longer makes an outbound request: drop it from the inventory`);
++ // Webhook targets are operator configuration: the only SQL that writes an app's webhook_url is
++ // db.upsertApp, and the only caller of that is app seeding (MEDIA_APPS_SEED / MEDIA_APP_KEYS).
++ const sqlWriters = [], upserters = [];
++ const scan = (d) => {
++ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
++ const p = path.join(d, e.name);
++ if (e.isDirectory()) { scan(p); continue; }
++ if (!p.endsWith('.js')) continue;
++ const src = fs.readFileSync(p, 'utf8');
++ if (/(INSERT\s+(OR\s+\w+\s+)?INTO\s+apps|UPDATE\s+apps\s+SET)[^;]*webhook_url/i.test(src)) sqlWriters.push(path.relative(ROOT, p));
++ if (/\bupsertApp\(/.test(src) && !p.endsWith(path.join('db', 'database.js'))) upserters.push(path.relative(ROOT, p));
++ }
++ };
++ scan(path.join(ROOT, 'server'));
++ assert.deepStrictEqual(sqlWriters, ['server/db/database.js'], 'only db.upsertApp writes an app\'s webhook_url');
++ assert.deepStrictEqual(upserters, ['server/auth.js'], 'and only app seeding calls it');
++ assert.ok(/function seedApps[\s\S]*upsertApp\(/.test(fs.readFileSync(path.join(ROOT, 'server/auth.js'), 'utf8')), 'from seedApps');
++ console.log(`✅ outbound inventory: ${found.size} files, one user-chosen address (avatar ingest); webhook URLs are operator-set`);
++}
++
++const ingest = require('../server/avatars/ingest');
++const { checkRtmpUrl } = require('../server/vod/recorder');
++
++const INTERNAL_IPS = ['127.0.0.1', '127.8.9.10', '10.0.0.1', '172.16.0.1', '172.31.255.255', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '255.255.255.255', '224.0.0.1',
++ '::', '::1', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1', '::ffff:7f00:1', '::ffff:10.0.0.1', '::ffff:a9fe:a9fe', '::127.0.0.1', '::7f00:1',
++ 'fc00::1', 'fd12:3456::1', 'fe80::1', 'fe80::1%eth0', 'fec0::1', 'ff02::1', '64:ff9b::7f00:1', '64:ff9b::a9fe:a9fe', '2002:7f00:1::1', '2002:a9fe:a9fe::1',
++ '2001:0:4136:e378:8000:63bf:80ff:fffe', '2001:db8::1'];
++for (const ip of INTERNAL_IPS) assert.strictEqual(ingest.isPublicAddress(ip), false, `not public: ${ip}`);
++for (const ip of ['93.184.216.34', '1.1.1.1', '172.32.0.1', '2606:4700:4700::1111', '::ffff:8.8.8.8']) assert.strictEqual(ingest.isPublicAddress(ip), true, `public: ${ip}`);
++console.log('✅ avatar address rule: loopback, RFC 1918, link-local/metadata, CGNAT, 0/8, multicast and every IPv6 form of them refused');
++
++(async () => {
++ const rejects = async (p, re, msg) => { let e = null; try { await p; } catch (x) { e = x; } assert.ok(e && re.test(e.message), `${msg} (got: ${e ? e.message : 'no error'})`); };
++ const DENIED = /not on the public internet|https address|username or password|standard https port|not a web address/;
++ // DNS is stubbed: names below answer from this table; any other name fails the test (nothing leaves the host).
++ const answers = {};
++ const lookups = [];
++ dns.promises.lookup = async (host, opts) => {
++ lookups.push(host);
++ const a = answers[host];
++ if (!a) throw new Error(`unexpected DNS lookup for ${host}`);
++ return typeof a === 'function' ? a() : a;
++ };
++ let fetched = [];
++ const noFetch = async (u) => { fetched.push(u.hostname); throw new Error('fetcher reached'); };
++
++ // ── Literal and encoded internal addresses, internal names, other schemes: refused before DNS or a socket ──
++ const refusedUrls = [
++ 'https://127.0.0.1/a.png', 'https://127.1/a.png', 'https://2130706433/a.png', 'https://0177.0.0.1/a.png', 'https://0x7f000001/a.png', 'https://0x7f.0.0.1/a.png',
++ 'https://017700000001/a.png', 'https://0.0.0.0/a.png', 'https://0/a.png', 'https://10.1.2.3/a.png', 'https://192.168.0.1/a.png', 'https://169.254.169.254/latest/meta-data/iam/',
++ 'https://2852039166/latest/meta-data/', 'https://[::1]/a.png', 'https://[::]/a.png', 'https://[::ffff:127.0.0.1]/a.png', 'https://[::ffff:7f00:1]/a.png',
++ 'https://[::ffff:169.254.169.254]/a.png', 'https://[0:0:0:0:0:0:0:1]/a.png', 'https://[fc00::1]/a.png', 'https://[fd00::1]/a.png', 'https://[fe80::1]/a.png',
++ 'https://[64:ff9b::7f00:1]/a.png', 'https://[2002:7f00:1::1]/a.png',
++ 'https://localhost/a.png', 'https://LOCALHOST./a.png', 'https://api.localhost/a.png', 'https://printer.local/a.png', 'https://metadata.google.internal/a.png', 'https://intranet/a.png',
++ 'http://93.184.216.34/a.png', 'ftp://93.184.216.34/a.png', 'file:///etc/passwd', 'gopher://93.184.216.34/', 'https://user:[email protected]/a.png', 'https://93.184.216.34:8443/a.png',
++ 'https://93.184.216.34:22/a.png', 'not a url',
++ ];
++ for (const u of refusedUrls) await rejects(ingest.safeFetchImage(u, { fetcher: noFetch }), DENIED, `refused: ${u}`);
++ assert.deepStrictEqual([fetched, lookups], [[], []], 'no connection and no DNS lookup for any of them');
++ console.log(`✅ ${refusedUrls.length} internal, encoded (decimal, octal, hex, short), IPv6-wrapped and internal-name URLs refused before DNS or a socket`);
++
++ // ── DNS answers: every answer must be public (a single private one refuses the name) ──
++ Object.assign(answers, {
++ 'mixed.example': [{ address: '93.184.216.34', family: 4 }, { address: '127.0.0.1', family: 4 }],
++ 'v6.example': [{ address: '::ffff:10.0.0.1', family: 6 }],
++ 'nat64.example': [{ address: '64:ff9b::a9fe:a9fe', family: 6 }],
++ 'v6compat.example': [{ address: '::127.0.0.1', family: 6 }],
++ 'loop6.example': [{ address: '::1', family: 6 }],
++ 'meta.example': [{ address: '169.254.169.254', family: 4 }],
++ });
++ for (const h of Object.keys(answers)) await rejects(ingest.safeFetchImage(`https://${h}/a.png`, { fetcher: noFetch }), /not on the public internet/, `a name answering an internal address: ${h}`);
++ assert.deepStrictEqual(fetched, [], 'no connection to any of them');
++
++ // ── Redirects: each hop is checked again, whatever the first host was ──
++ answers['good.example'] = [{ address: '93.184.216.34', family: 4 }];
++ for (const target of ['https://127.0.0.1/x', 'https://2130706433/x', 'https://[::1]/x', 'https://[::ffff:7f00:1]/x', 'https://169.254.169.254/latest/meta-data/',
++ 'https://localhost/x', 'https://meta.example/x', 'http://good.example/x', 'https://good.example:8443/x', '//10.0.0.1/x']) {
++ fetched = [];
++ const fetcher = async (u) => { fetched.push(u.host); return fetched.length === 1 ? { redirect: target } : { body: Buffer.from('reached') }; };
++ await rejects(ingest.safeFetchImage('https://good.example/a.png', { fetcher }), DENIED, `a redirect to ${target}`);
++ assert.deepStrictEqual(fetched, ['good.example'], `the internal hop is never connected to (${target})`);
++ }
++ console.log('✅ DNS answers with any internal address, and redirects to internal addresses, are refused');
++
++ // ── DNS rebinding: the socket connects to the address that was checked, not a second lookup ──
++ let calls = 0;
++ answers['rebind.example'] = () => (++calls === 1 ? [{ address: '93.184.216.34', family: 4 }] : [{ address: '127.0.0.1', family: 4 }]);
++ const realGet = https.get;
++ let opts = null;
++ https.get = (o) => { opts = o; const req = new (require('events'))(); req.destroy = () => {}; setImmediate(() => req.emit('error', new Error('stubbed socket'))); return req; };
++ try {
++ await rejects(ingest.safeFetchImage('https://rebind.example/a.png'), /stubbed socket/, 'the real fetcher runs up to the socket');
++ } finally { https.get = realGet; }
++ assert.strictEqual(calls, 1, 'the name is resolved once');
++ assert.strictEqual(opts.host, 'rebind.example', 'TLS still names the host');
++ const pinned = await new Promise((resolve) => opts.lookup('rebind.example', {}, (err, address, family) => resolve({ err, address, family })));
++ assert.deepStrictEqual([pinned.err, pinned.address, pinned.family], [null, '93.184.216.34', 4], 'the socket\'s lookup answers the checked address');
++ const pinnedAll = await new Promise((resolve) => opts.lookup('rebind.example', { all: true }, (err, list) => resolve(list)));
++ assert.deepStrictEqual(pinnedAll, [{ address: '93.184.216.34', family: 4 }], 'also when Node asks for every address');
++ assert.strictEqual(calls, 1, 'connecting does not resolve again (a rebinding answer is never used)');
++ console.log('✅ DNS rebinding: the connection is pinned to the checked address');
++
++ // ── Through the route: an internal URL is refused and nothing is stored ──
++ const db = require('../server/db/database');
++ const express = require('express');
++ const app = express();
++ app.use(express.json());
++ app.post('/internal/avatar-ingest', ingest.createIngestHandler({ db, config: require('../server/config'), screenshotsDir: path.join(tmp, 'shots'), generateSlug: () => 'slug1', log: { warn() {} } }));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ for (const url of ['https://169.254.169.254/latest/meta-data/iam/security-credentials/', 'https://[::ffff:7f00:1]/a.png', 'https://2130706433/a.png', 'https://localhost/a.png', 'https://mixed.example/a.png']) {
++ const r = await fetch(`http://127.0.0.1:${server.address().port}/internal/avatar-ingest`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url, user_id: 1 }) });
++ const body = await r.json();
++ assert.deepStrictEqual([r.status, body.ok], [422, false], `${url}: ${JSON.stringify(body)}`);
++ }
++ assert.strictEqual(db.get("SELECT COUNT(*) AS n FROM pastes WHERE app_id = 'network'").n, 0, 'no avatar was stored');
++ assert.ok(!fs.existsSync(path.join(tmp, 'shots')) || fs.readdirSync(path.join(tmp, 'shots')).length === 0, 'no file was written');
++ server.close();
++ console.log('✅ POST /internal/avatar-ingest refuses internal URLs and stores nothing');
++
++ // ── RTMP pull: only the allow-listed host:port, in no other spelling ──
++ for (const u of ['rtmp://2130706433:1935/live/k', 'rtmp://0177.0.0.1:1935/live/k', 'rtmp://0x7f000001:1935/live/k', 'rtmp://127.1:1935/live/k', 'rtmp://0.0.0.0:1935/live/k',
++ 'rtmp://[::ffff:127.0.0.1]:1935/live/k', 'rtmp://[::ffff:7f00:1]:1935/live/k', 'rtmp://[fe80::1]:1935/live/k', 'rtmp://169.254.169.254:1935/live/k', 'rtmp://10.0.0.5:1935/live/k',
++ 'rtmp://localhost.:1935/live/k', 'rtmp://127.0.0.1.nip.io:1935/live/k', 'rtmp://localhost.evil.example:1935/live/k', 'rtmp://127.0.0.1:19350/live/k', 'rtmp://127.0.0.1:[email protected]/live/k',
++ 'http://127.0.0.1:1935/live/k', 'rtmpt://127.0.0.1:1935/live/k', 'file:///etc/passwd']) {
++ assert.strictEqual(checkRtmpUrl(u).ok, false, `RTMP pull refused: ${u}`);
++ }
++ assert.strictEqual(checkRtmpUrl('rtmp://127.0.0.1:1935/live/k').ok, true, 'Live\'s RTMP server on this host (control)');
++ console.log('✅ RTMP pull: encoded, IPv6-mapped and look-alike hosts are not the allow-listed one');
++
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log('security-ssrf: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });