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.
Changes
@@@ -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 |@@@ -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),@@@ -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;@@@ -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);nnew file mode 100644@@@ -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); });
@@ -152,7 +152,7 @@ MEDIA_APP_KEYS="live:key1,games:key2"
@@ -152,7 +152,7 @@ MEDIA_APP_KEYS="live:key1,games:key2"
| method | path | notes |
| method | path | notes |
|---|---|---|
|---|---|---|
| POST | `/vods` | `{ title?, stream_id?, stream_key?, managed_stream_id?, user_id?, meta?, visibility?, clips_only? }` → `{ id }` |
| 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/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/ingest/rtp/stop` | finalizes the recording |
| POST | `/vods/:id/chunks` | multipart `chunk` (+`segmentId`), user JWT ok — browser MediaRecorder append flow |
| POST | `/vods/:id/chunks` | multipart `chunk` (+`segmentId`), user JWT ok — browser MediaRecorder append flow |
@@ -96,6 +96,13 @@ const config = {
@@ -96,6 +96,13 @@ const config = {
intervalMin: intEnv('MEDIA_OWNER_SUBJECT_INTERVAL_MIN', 10),
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: {
rtp: {
portMin: intEnv('RTP_PORT_MIN', 12000),
portMin: intEnv('RTP_PORT_MIN', 12000),
portMax: intEnv('RTP_PORT_MAX', 12199),
portMax: intEnv('RTP_PORT_MAX', 12199),
@@ -4,7 +4,8 @@
@@ -4,7 +4,8 @@
* Adapted from the predecessor's ffmpeg recorder, keyed by vodId (tenant apps
* Adapted from the predecessor's ffmpeg recorder, keyed by vodId (tenant apps
* own their stream state and point ingest at a VOD they created):
* 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
* stream-copy into a fragmented MP4 (H.264/AAC passthrough — near-zero
* CPU, live-seekable, kill-tolerant, and IS its own lossless master).
* CPU, live-seekable, kill-tolerant, and IS its own lossless master).
* RTP: POST ingest/rtp/start { video, audio } → allocates a UDP port pair
* RTP: POST ingest/rtp/start { video, audio } → allocates a UDP port pair
@@ -34,6 +35,26 @@ const tools = require('./media-tools');
@@ -34,6 +35,26 @@ const tools = require('./media-tools');
// be cut short (that truncates the VOD). Cleared the instant ffmpeg exits.
// 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;
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).
// 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_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;
const DISK_CRIT_BYTES = (parseFloat(process.env.VOD_DISK_CRIT_GB) || 5) * 1024 * 1024 * 1024;
@@ -308,9 +329,9 @@ class StreamRecorder {
@@ -308,9 +329,9 @@ class StreamRecorder {
startRtmp(vod, rtmpUrl) {
startRtmp(vod, rtmpUrl) {
const guard = this._guardCanRecord(vod);
const guard = this._guardCanRecord(vod);
if (!guard.ok) return guard;
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 filename = `vod-${vod.app_id}-${vod.id}-${Date.now()}.mp4`;
const filePath = path.resolve(config.vod.path, filename);
const filePath = path.resolve(config.vod.path, filename);
@@ -318,7 +339,7 @@ class StreamRecorder {
@@ -318,7 +339,7 @@ class StreamRecorder {
const ffmpegArgs = [
const ffmpegArgs = [
'-y',
'-y',
'-rw_timeout', '15000000',
'-rw_timeout', '15000000',
- '-i', rtmpUrl,
+ '-i', target.url,
// ── Lossless stream-copy → fragmented MP4 ──
// ── Lossless stream-copy → fragmented MP4 ──
// No re-encode (H.264/AAC pass straight through from the RTMP feed), so
// No re-encode (H.264/AAC pass straight through from the RTMP feed), so
// this ALWAYS keeps real-time regardless of source resolution/bitrate.
// this ALWAYS keeps real-time regardless of source resolution/bitrate.
@@ -574,3 +595,4 @@ class StreamRecorder {
@@ -574,3 +595,4 @@ class StreamRecorder {
module.exports = new StreamRecorder();
module.exports = new StreamRecorder();
module.exports.buildRtpSdp = buildRtpSdp;
module.exports.buildRtpSdp = buildRtpSdp;
+module.exports.checkRtmpUrl = checkRtmpUrl;
@@ -121,7 +121,7 @@ router.post('/:id/ingest/rtmp', tenantAuth(), (req, res) => {
@@ -121,7 +121,7 @@ router.post('/:id/ingest/rtmp', tenantAuth(), (req, res) => {
const vod = _getVodScoped(req, res);
const vod = _getVodScoped(req, res);
if (!vod) return;
if (!vod) return;
const result = recorder.startRtmp(vod, req.body?.rtmp_url);
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' });
res.status(202).json({ id: vod.id, status: 'recording' });
} catch (err) {
} catch (err) {
console.error('[VOD] RTMP ingest error:', err.message);
console.error('[VOD] RTMP ingest error:', err.message);
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -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); });