openvibers/openvibe.media Commits

RTMP pull SSRF: POST /vods/:id/ingest/rtmp makes ffmpeg connect to the URL it is given, and any rtmp:// or rtmps:// URL was accepted, so an app key (or a leaked one) could point Media's ffmpeg at any host and port it can reach. The recorder now pulls only from allow-listed RTMP servers: MEDIA_RTMP_PULL_ALLOW, a host:port list defaulting to Live's RTMP server on this host (127.0.0.1:1935, localhost:1935, [::1]:1935; a URL without a port means 1935, or 443 for rtmps). The URL must be a plain rtmp[s]://host[:port]/path[?query]: no user info, backslash, whitespace or control characters, nothing a URL parser and ffmpeg could read differently, and alternative spellings of loopback (0x7f.0.0.1, 127.1) are not on the list. The scheme is handed to ffmpeg in lower case. A refused URL answers 400 (was 409) and starts no ffmpeg. test/rtmp-allowlist.test.js covers allowed and refused URLs (metadata address, other local ports, parser differentials, injected arguments) and the route.

OP OpenVibers committed 24 Sept 2026 · cb17f297b0 · 5 files, +125 −7


Changes

ddiff --git a/README.md b/README.md
iindex b23a24d..75e18ae 100644
---- a/README.md
++++ b/README.md
@@@ -152,7 +152,7 @@ MEDIA_APP_KEYS="live:key1,games:key2"
| method | path | notes |
|---|---|---|
| POST | `/vods` | `{ title?, stream_id?, stream_key?, managed_stream_id?, user_id?, meta?, visibility?, clips_only? }` → `{ id }` |
--| POST | `/vods/:id/ingest/rtmp` | `{ rtmp_url }` → **202**; ffmpeg pulls the URL, lossless stream-copy → fragmented `.mp4` |
++| POST | `/vods/:id/ingest/rtmp` | `{ rtmp_url }` → **202**; ffmpeg pulls the URL, lossless stream-copy → fragmented `.mp4`. Only allow-listed RTMP servers (`MEDIA_RTMP_PULL_ALLOW`, `host:port` list, default `127.0.0.1:1935,localhost:1935,[::1]:1935`: Live's RTMP server on this host) and a plain `rtmp[s]://host[:port]/path` shape (no user info, whitespace or backslash); anything else is **400** and starts nothing |
| POST | `/vods/:id/ingest/rtp/start` | `{ video: {payloadType, codec, clockRate, ssrc?, parameters?}, audio?: {…} }` → `{ videoPort, audioPort }` from UDP 12000-12199; point PlainRtpTransports at `127.0.0.1` (RTCP = port+1) |
| POST | `/vods/:id/ingest/rtp/stop` | finalizes the recording |
| POST | `/vods/:id/chunks` | multipart `chunk` (+`segmentId`), user JWT ok — browser MediaRecorder append flow |
ddiff --git a/server/config.js b/server/config.js
iindex c3403f0..a2586ab 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -96,6 +96,13 @@ const config = {
intervalMin: intEnv('MEDIA_OWNER_SUBJECT_INTERVAL_MIN', 10),
},
++ // Where POST /vods/:id/ingest/rtmp may make ffmpeg connect (an SSRF guard): host:port entries,
++ // by default Live's RTMP server on this host. A URL without a port means 1935 (rtmp) / 443 (rtmps).
++ rtmpPull: {
++ allow: String(process.env.MEDIA_RTMP_PULL_ALLOW || '127.0.0.1:1935,localhost:1935,[::1]:1935')
++ .split(',').map(s => s.trim().toLowerCase()).filter(Boolean),
++ },
++
rtp: {
portMin: intEnv('RTP_PORT_MIN', 12000),
portMax: intEnv('RTP_PORT_MAX', 12199),
ddiff --git a/server/vod/recorder.js b/server/vod/recorder.js
iindex 89c9c7c..08c6586 100644
---- a/server/vod/recorder.js
++++ b/server/vod/recorder.js
@@@ -4,7 +4,8 @@
* Adapted from the predecessor's ffmpeg recorder, keyed by vodId (tenant apps
* own their stream state and point ingest at a VOD they created):
*
-- * RTMP: POST ingest/rtmp { rtmp_url } → ffmpeg pulls the URL, lossless
++ * RTMP: POST ingest/rtmp { rtmp_url } → ffmpeg pulls the URL (allow-listed RTMP servers
++ * only: MEDIA_RTMP_PULL_ALLOW, default Live's on this host), lossless
* stream-copy into a fragmented MP4 (H.264/AAC passthrough — near-zero
* CPU, live-seekable, kill-tolerant, and IS its own lossless master).
* RTP: POST ingest/rtp/start { video, audio } → allocates a UDP port pair
@@@ -34,6 +35,26 @@ const tools = require('./media-tools');
// be cut short (that truncates the VOD). Cleared the instant ffmpeg exits.
const STOP_GRACE_MS = parseInt(process.env.VOD_STOP_GRACE_MS, 10) || 60000;
++/**
++ * Is this an RTMP URL ffmpeg may pull? Only rtmp:// or rtmps:// with a plain host (a name, IPv4 or
++ * [IPv6]), an optional port and a plain path/query: no user info, no backslash, no whitespace or
++ * control characters, nothing ffmpeg and a URL parser could read differently. The host:port (default
++ * 1935 / 443) must be in `allow` (config.rtmpPull.allow). Returns { ok, host, port, url } or { ok: false, error }.
++ */
++function checkRtmpUrl(url, allow = config.rtmpPull.allow) {
++ const u = String(url == null ? '' : url);
++ if (!u || u.length > 2048) return { ok: false, error: 'rtmp_url must be an rtmp:// or rtmps:// URL' };
++ const m = /^(rtmps?):\/\/([A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::(\d{1,5}))?(\/[A-Za-z0-9._~\/=+%-]*)?(\?[A-Za-z0-9._~\/=+%&-]*)?$/i.exec(u);
++ if (!m) return { ok: false, error: 'rtmp_url must be an rtmp:// or rtmps:// URL with a plain host, port and path' };
++ const host = m[2].toLowerCase();
++ const port = m[3] ? Number(m[3]) : (m[1].toLowerCase() === 'rtmps' ? 443 : 1935);
++ if (!(port > 0 && port < 65536)) return { ok: false, error: 'rtmp_url has an invalid port' };
++ const allowed = (allow || []).map(a => String(a).toLowerCase());
++ if (!allowed.includes(`${host}:${port}`)) return { ok: false, error: `rtmp_url host ${host}:${port} is not an allowed RTMP ingest (MEDIA_RTMP_PULL_ALLOW)` };
++ // ffmpeg matches protocol names case-sensitively: hand it the scheme in lower case.
++ return { ok: true, host, port, url: `${m[1].toLowerCase()}${u.slice(m[1].length)}` };
++}
++
// Disk guardian thresholds (free bytes on the VOD volume).
const DISK_WARN_BYTES = (parseFloat(process.env.VOD_DISK_WARN_GB) || 15) * 1024 * 1024 * 1024;
const DISK_CRIT_BYTES = (parseFloat(process.env.VOD_DISK_CRIT_GB) || 5) * 1024 * 1024 * 1024;
@@@ -308,9 +329,9 @@ class StreamRecorder {
startRtmp(vod, rtmpUrl) {
const guard = this._guardCanRecord(vod);
if (!guard.ok) return guard;
-- if (!/^rtmps?:\/\//i.test(String(rtmpUrl || ''))) {
-- return { ok: false, error: 'rtmp_url must be an rtmp:// or rtmps:// URL' };
-- }
++ // SSRF guard: ffmpeg connects only to an allow-listed RTMP server (config.rtmpPull.allow).
++ const target = checkRtmpUrl(rtmpUrl);
++ if (!target.ok) return { ok: false, error: target.error, status: 400 };
const filename = `vod-${vod.app_id}-${vod.id}-${Date.now()}.mp4`;
const filePath = path.resolve(config.vod.path, filename);
@@@ -318,7 +339,7 @@ class StreamRecorder {
const ffmpegArgs = [
'-y',
'-rw_timeout', '15000000',
-- '-i', rtmpUrl,
++ '-i', target.url,
// ── Lossless stream-copy → fragmented MP4 ──
// No re-encode (H.264/AAC pass straight through from the RTMP feed), so
// this ALWAYS keeps real-time regardless of source resolution/bitrate.
@@@ -574,3 +595,4 @@ class StreamRecorder {
module.exports = new StreamRecorder();
module.exports.buildRtpSdp = buildRtpSdp;
++module.exports.checkRtmpUrl = checkRtmpUrl;
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
iindex cb09d13..e2560b6 100644
---- a/server/vod/routes.js
++++ b/server/vod/routes.js
@@@ -121,7 +121,7 @@ router.post('/:id/ingest/rtmp', tenantAuth(), (req, res) => {
const vod = _getVodScoped(req, res);
if (!vod) return;
const result = recorder.startRtmp(vod, req.body?.rtmp_url);
-- if (!result.ok) return res.status(409).json({ error: result.error });
++ if (!result.ok) return res.status(result.status || 409).json({ error: result.error });
res.status(202).json({ id: vod.id, status: 'recording' });
} catch (err) {
console.error('[VOD] RTMP ingest error:', err.message);
ddiff --git a/test/rtmp-allowlist.test.js b/test/rtmp-allowlist.test.js
nnew file mode 100644
iindex 0000000..cb91a5e
---- /dev/null
++++ b/test/rtmp-allowlist.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++// POST /api/v1/:app/vods/:id/ingest/rtmp makes ffmpeg connect to the URL it is given, so it is an SSRF
++// vector. The recorder only pulls from allow-listed RTMP servers (MEDIA_RTMP_PULL_ALLOW, default
++// Live's on this host: 127.0.0.1 / localhost / [::1] port 1935), with a strict URL shape that a URL
++// parser and ffmpeg cannot read differently. Refused URLs answer 400 and start nothing.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-rtmp-'));
++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++Object.assign(process.env, {
++ DB_PATH: path.join(tmp, 'media.db'), VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),
++ THUMBNAILS_PATH: dir('thumbnails'), PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'),
++});
++delete process.env.MEDIA_RTMP_PULL_ALLOW;
++
++const config = require('../server/config');
++const recorder = require('../server/vod/recorder');
++const { checkRtmpUrl } = recorder;
++
++assert.deepStrictEqual(config.rtmpPull.allow, ['127.0.0.1:1935', 'localhost:1935', '[::1]:1935'], 'default: Live\'s RTMP server on this host');
++
++// ── 1. The allow-list and the URL shape ──
++for (const ok of ['rtmp://127.0.0.1:1935/live/abc123', 'rtmp://localhost:1935/live/Key_9-x', 'rtmp://127.0.0.1/live/k', 'rtmp://[::1]:1935/live/k',
++ 'RTMP://127.0.0.1:1935/live/k', 'rtmp://127.0.0.1:1935/live/k?token=a%2Fb&x=1']) {
++ assert.ok(checkRtmpUrl(ok).ok, `allowed: ${ok}`);
++}
++assert.deepStrictEqual([checkRtmpUrl('rtmp://127.0.0.1/live/k').host, checkRtmpUrl('rtmp://127.0.0.1/live/k').port], ['127.0.0.1', 1935], 'no port = 1935');
++assert.strictEqual(checkRtmpUrl('RTMP://127.0.0.1:1935/live/K').url, 'rtmp://127.0.0.1:1935/live/K', 'ffmpeg gets a lower-case scheme');
++const refused = [
++ 'rtmp://10.0.0.5:1935/live/k', // another host on the network
++ 'rtmp://169.254.169.254:80/latest/meta-data', // cloud metadata
++ 'rtmp://127.0.0.1:22/x', // a local port that is not the RTMP server
++ 'rtmp://127.0.0.1:4100/x', // Media itself
++ 'rtmp://evil.example/live',
++ 'rtmp://user:[email protected]:1935/x', // user info
++ 'rtmp://127.0.0.1:[email protected]/x', // parser differential
++ 'rtmp://127.0.0.1:1935\\@evil.example/x',
++ 'rtmp://127.0.0.1:1935/live/k -i file:///etc/passwd',
++ 'rtmp://127.0.0.1:1935/x\r\nfoo',
++ 'rtmp://0x7f.0.0.1:1935/x', // alternative spellings of loopback are not on the list
++ 'rtmp://127.1:1935/x',
++ 'rtmps://127.0.0.1/x', // 443 is not the RTMP server
++ 'rtmpt://127.0.0.1:1935/x', 'http://127.0.0.1:1935/', 'file:///etc/passwd', 'concat:rtmp://127.0.0.1:1935/x', '', null, 42,
++ `rtmp://127.0.0.1:1935/${'a'.repeat(3000)}`,
++];
++for (const bad of refused) assert.strictEqual(checkRtmpUrl(bad).ok, false, `refused: ${JSON.stringify(bad)}`);
++assert.ok(/not an allowed RTMP ingest/.test(checkRtmpUrl('rtmp://10.0.0.5:1935/live/k').error));
++assert.ok(checkRtmpUrl('rtmp://ingest.openre.stream:1936/live/k', ['ingest.openre.stream:1936']).ok, 'the list is configuration (OpenRe on 1936, say)');
++console.log('✅ only allow-listed host:port pairs with a plain URL shape pass');
++
++(async () => {
++ // ── 2. The recorder and the route refuse before anything starts ──
++ const db = require('../server/db/database');
++ db.upsertApp({ app_id: 'live', api_key: 'live-key-rtmp-test' });
++ const vodId = Number(db.createVod({ app_id: 'live', title: 'x' }).lastInsertRowid);
++ const r = recorder.startRtmp(db.getVodById(vodId), 'rtmp://10.0.0.5:1935/live/k');
++ assert.deepStrictEqual([r.ok, r.status], [false, 400]);
++ assert.strictEqual(recorder.activeCount(), 0, 'no ffmpeg was started');
++ assert.strictEqual(db.getVodById(vodId).is_recording, 0);
++
++ const app = express();
++ app.use(express.json());
++ app.use('/api/v1/:app/vods', require('../server/vod/routes'));
++ const server = await new Promise((res) => { const s = app.listen(0, '127.0.0.1', () => res(s)); });
++ const post = (p, body) => new Promise((resolve, reject) => {
++ const data = JSON.stringify(body);
++ const req = http.request({ host: '127.0.0.1', port: server.address().port, path: p, method: 'POST',
++ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Authorization: 'Bearer live-key-rtmp-test' } },
++ (res) => { let b = ''; res.on('data', (c) => { b += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b || '{}') })); });
++ req.on('error', reject);
++ req.end(data);
++ });
++ for (const url of ['rtmp://169.254.169.254:80/x', 'rtmp://127.0.0.1:[email protected]/x', 'file:///etc/passwd']) {
++ const res = await post(`/api/v1/live/vods/${vodId}/ingest/rtmp`, { rtmp_url: url });
++ assert.strictEqual(res.status, 400, `${url} → ${res.status}`);
++ }
++ assert.strictEqual(recorder.activeCount(), 0);
++ server.close();
++ db.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log('✅ POST …/ingest/rtmp answers 400 for a URL off the list, and starts no ffmpeg');
++ console.log('\n✅ All RTMP allow-list tests passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });