fix(remux): preserve encrypted HLS and test protocol restrictions
Allow nested crypto for encrypted HTTP HLS while rejecting top-level protocol wrappers and local-file access. Replace the HLS-only http_persistent option with HTTP multiple_requests across probe/remux/transcode paths, fixing the raw HTTP regression introduced by #124. Add real FFmpeg regression tests and PR CI.
This commit is contained in:
24
.github/workflows/test.yml
vendored
Normal file
24
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
name: Regression Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
remux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- name: Install FFmpeg
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y ffmpeg
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm test
|
||||||
@ -6,7 +6,8 @@
|
|||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"dev": "node --watch server/index.js"
|
"dev": "node --watch server/index.js",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||||
|
|||||||
@ -33,7 +33,7 @@ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
|||||||
const args = [
|
const args = [
|
||||||
'-v', 'error',
|
'-v', 'error',
|
||||||
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
'-user_agent', userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||||
'-http_persistent', '0',
|
'-multiple_requests', '0',
|
||||||
'-reconnect', '1',
|
'-reconnect', '1',
|
||||||
'-reconnect_streamed', '1',
|
'-reconnect_streamed', '1',
|
||||||
'-reconnect_on_http_error', '4xx,5xx',
|
'-reconnect_on_http_error', '4xx,5xx',
|
||||||
|
|||||||
@ -9,9 +9,8 @@ const db = require('../db');
|
|||||||
* FFmpeg accepts a wide variety of protocol handlers (file://, concat:,
|
* FFmpeg accepts a wide variety of protocol handlers (file://, concat:,
|
||||||
* subfile:, data:, pipe:, crypto:, async:, cache:, ...). Several of them
|
* subfile:, data:, pipe:, crypto:, async:, cache:, ...). Several of them
|
||||||
* read from the local filesystem or compose existing protocols, so they
|
* read from the local filesystem or compose existing protocols, so they
|
||||||
* must NOT be reachable from a request parameter — otherwise an attacker
|
* must not be reachable from a request parameter. Remux inputs should only
|
||||||
* who can call /api/remux can read arbitrary local files via the streamed
|
* reference network streams, never local files or protocol wrappers.
|
||||||
* response.
|
|
||||||
*
|
*
|
||||||
* Only plain http/https stream URLs are valid inputs here.
|
* Only plain http/https stream URLs are valid inputs here.
|
||||||
*/
|
*/
|
||||||
@ -23,11 +22,13 @@ const ALLOWED_URL_SCHEMES = new Set(['http:', 'https:']);
|
|||||||
* if a future code path forwarded a different URL, ffmpeg itself would
|
* if a future code path forwarded a different URL, ffmpeg itself would
|
||||||
* refuse to touch local files / arbitrary demuxer-level protocols.
|
* refuse to touch local files / arbitrary demuxer-level protocols.
|
||||||
*
|
*
|
||||||
* - file/concat/subfile/pipe/data/crypto/async/cache are intentionally absent.
|
* - file/concat/subfile/pipe/data/async/cache are intentionally absent.
|
||||||
* - tls/tcp are needed for https; https/http are obvious.
|
* - tls/tcp are needed for https; https/http are obvious.
|
||||||
* - hls is needed so the demuxer can follow HLS playlists.
|
* - hls is needed so the demuxer can follow HLS playlists.
|
||||||
|
* - crypto is needed for AES-encrypted HLS segments. Nested file access
|
||||||
|
* remains blocked, and top-level crypto URLs are rejected by the validator.
|
||||||
*/
|
*/
|
||||||
const FFMPEG_PROTOCOL_WHITELIST = 'http,https,tls,tcp,hls';
|
const FFMPEG_PROTOCOL_WHITELIST = 'http,https,tls,tcp,hls,crypto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a user-supplied stream URL.
|
* Validate a user-supplied stream URL.
|
||||||
@ -98,7 +99,7 @@ router.get('/', async (req, res) => {
|
|||||||
'-err_detect', 'ignore_err',
|
'-err_detect', 'ignore_err',
|
||||||
// Limit max demux delay to prevent buffering issues with bad timestamps
|
// Limit max demux delay to prevent buffering issues with bad timestamps
|
||||||
'-max_delay', '5000000',
|
'-max_delay', '5000000',
|
||||||
'-http_persistent', '0',
|
'-multiple_requests', '0',
|
||||||
// Reconnect settings for network drops
|
// Reconnect settings for network drops
|
||||||
'-reconnect', '1',
|
'-reconnect', '1',
|
||||||
'-reconnect_streamed', '1',
|
'-reconnect_streamed', '1',
|
||||||
|
|||||||
@ -192,7 +192,7 @@ router.get('/', async (req, res) => {
|
|||||||
'-err_detect', 'ignore_err',
|
'-err_detect', 'ignore_err',
|
||||||
// Limit max demux delay to prevent buffering issues
|
// Limit max demux delay to prevent buffering issues
|
||||||
'-max_delay', '2000000',
|
'-max_delay', '2000000',
|
||||||
'-http_persistent', '0',
|
'-multiple_requests', '0',
|
||||||
// Reconnect settings for network drops (useful for live streams)
|
// Reconnect settings for network drops (useful for live streams)
|
||||||
'-reconnect', '1',
|
'-reconnect', '1',
|
||||||
'-reconnect_streamed', '1',
|
'-reconnect_streamed', '1',
|
||||||
|
|||||||
@ -198,7 +198,7 @@ class TranscodeSession extends EventEmitter {
|
|||||||
'-analyzeduration', '5000000',
|
'-analyzeduration', '5000000',
|
||||||
'-fflags', '+genpts+discardcorrupt',
|
'-fflags', '+genpts+discardcorrupt',
|
||||||
'-err_detect', 'ignore_err',
|
'-err_detect', 'ignore_err',
|
||||||
'-http_persistent', '0',
|
'-multiple_requests', '0',
|
||||||
'-reconnect', '1',
|
'-reconnect', '1',
|
||||||
'-reconnect_streamed', '1',
|
'-reconnect_streamed', '1',
|
||||||
'-reconnect_on_http_error', '4xx,5xx',
|
'-reconnect_on_http_error', '4xx,5xx',
|
||||||
|
|||||||
190
test/remux.test.js
Normal file
190
test/remux.test.js
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const http = require('node:http');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const { EventEmitter } = require('node:events');
|
||||||
|
const { spawn, spawnSync } = require('node:child_process');
|
||||||
|
const { createRequire } = require('node:module');
|
||||||
|
const { pathToFileURL } = require('node:url');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
function loadRemuxRouter() {
|
||||||
|
const filename = path.join(__dirname, '..', 'server', 'routes', 'remux.js');
|
||||||
|
const realRequire = createRequire(filename);
|
||||||
|
const calls = [];
|
||||||
|
const module = { exports: {} };
|
||||||
|
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), {
|
||||||
|
module,
|
||||||
|
URL,
|
||||||
|
console: { log() {}, error() {} },
|
||||||
|
require(id) {
|
||||||
|
if (id === '../db') {
|
||||||
|
return { settings: { get: async () => ({}) }, getUserAgent: () => 'test-agent' };
|
||||||
|
}
|
||||||
|
if (id === 'child_process') {
|
||||||
|
return { spawn(binary, args) {
|
||||||
|
calls.push({ binary, args });
|
||||||
|
const child = new EventEmitter();
|
||||||
|
child.stdout = { pipe(res) { res.end('test-media'); } };
|
||||||
|
child.stderr = new EventEmitter();
|
||||||
|
child.kill = () => {};
|
||||||
|
return child;
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
return realRequire(id);
|
||||||
|
}
|
||||||
|
}, { filename });
|
||||||
|
return { router: module.exports, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listen(server) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
return `http://127.0.0.1:${server.address().port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(url, res => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', chunk => chunks.push(chunk));
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }));
|
||||||
|
res.on('error', reject);
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function routeFixture(t) {
|
||||||
|
const fixture = loadRemuxRouter();
|
||||||
|
const app = express();
|
||||||
|
app.use('/api/remux', fixture.router);
|
||||||
|
const server = http.createServer(app);
|
||||||
|
t.after(() => new Promise(resolve => server.close(resolve)));
|
||||||
|
fixture.base = await listen(server);
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('remux rejects unsafe protocols, controls, and structured query values before spawning', async t => {
|
||||||
|
const { base, calls } = await routeFixture(t);
|
||||||
|
const invalid = [
|
||||||
|
'file:///etc/passwd', 'FILE:///etc/passwd', 'concat:one|two',
|
||||||
|
'subfile,,start,0,end,4096,,:/etc/passwd', 'data:text/plain,hello',
|
||||||
|
'pipe:0', 'crypto:http://provider.test/media.ts', 'async:http://provider.test/media.ts',
|
||||||
|
'cache:http://provider.test/media.ts', 'ftp://provider.test/media.ts', '-i',
|
||||||
|
'provider.test/media.ts', 'http://provider.test/\r\nfile:///etc/passwd',
|
||||||
|
'http://provider.test/\0media.ts', 'http://provider.test/\tmedia.ts',
|
||||||
|
'http://provider.test/\x7fmedia.ts'
|
||||||
|
];
|
||||||
|
const queries = invalid.map(url => `url=${encodeURIComponent(url)}`);
|
||||||
|
queries.push('', 'url=', 'url=http%3A%2F%2Fprovider.test%2Fa&url=http%3A%2F%2Fprovider.test%2Fb',
|
||||||
|
'url[value]=http%3A%2F%2Fprovider.test%2Fa');
|
||||||
|
for (const query of queries) {
|
||||||
|
const result = await request(`${base}/api/remux?${query}`);
|
||||||
|
assert.equal(result.status, 400, query);
|
||||||
|
assert.ok(JSON.parse(result.body).error);
|
||||||
|
}
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remux accepts HTTP(S), preserves query credentials, and restricts nested protocols', async t => {
|
||||||
|
const { base, calls } = await routeFixture(t);
|
||||||
|
for (const url of ['http://provider.test/media.ts', 'https://provider.test/media.m3u8',
|
||||||
|
'HTTP://provider.test:8080/media.ts?password=abc/']) {
|
||||||
|
assert.equal((await request(`${base}/api/remux?url=${encodeURIComponent(url)}`)).status, 200);
|
||||||
|
const args = calls.at(-1).args;
|
||||||
|
const inputIndex = args.indexOf('-i');
|
||||||
|
const whitelistIndex = args.indexOf('-protocol_whitelist');
|
||||||
|
assert.ok(whitelistIndex >= 0 && whitelistIndex < inputIndex);
|
||||||
|
assert.equal(args[inputIndex + 1], new URL(url).href);
|
||||||
|
const whitelist = args[whitelistIndex + 1].split(',');
|
||||||
|
for (const protocol of ['http', 'https', 'tcp', 'tls', 'crypto']) {
|
||||||
|
assert.ok(whitelist.includes(protocol), protocol);
|
||||||
|
}
|
||||||
|
for (const protocol of ['file', 'concat', 'subfile', 'pipe', 'data', 'async', 'cache']) {
|
||||||
|
assert.ok(!whitelist.includes(protocol), protocol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function findFFmpeg() {
|
||||||
|
try {
|
||||||
|
const binary = require('ffmpeg-static');
|
||||||
|
if (binary && fs.existsSync(binary)) return binary;
|
||||||
|
} catch { /* Optional dependency may be absent. */ }
|
||||||
|
return spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0 ? 'ffmpeg' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFFmpeg(binary, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
const stdout = [], stderr = [];
|
||||||
|
let timedOut = false;
|
||||||
|
const timer = setTimeout(() => { timedOut = true; child.kill(); }, 15000);
|
||||||
|
child.stdout.on('data', data => stdout.push(data));
|
||||||
|
child.stderr.on('data', data => stderr.push(data));
|
||||||
|
child.on('error', error => { clearTimeout(timer); reject(error); });
|
||||||
|
child.on('close', code => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ code, timedOut, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr).toString() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffmpeg = findFFmpeg();
|
||||||
|
test('real FFmpeg remuxes encrypted HLS over HTTP and blocks local HLS segments',
|
||||||
|
{ skip: ffmpeg ? false : 'FFmpeg unavailable' }, async t => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-remux-test-'));
|
||||||
|
t.after(() => {
|
||||||
|
assert.equal(path.dirname(path.resolve(dir)), path.resolve(os.tmpdir()));
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
const sample = path.join(dir, 'media.ts');
|
||||||
|
const generated = await runFFmpeg(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y',
|
||||||
|
'-f', 'lavfi', '-i', 'testsrc2=size=64x64:rate=10',
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000', '-t', '12',
|
||||||
|
'-c:v', 'libx264', '-threads', '1', '-preset', 'ultrafast', '-tune', 'zerolatency',
|
||||||
|
'-c:a', 'libmp3lame', '-f', 'mpegts', sample]);
|
||||||
|
assert.equal(generated.code, 0, generated.stderr);
|
||||||
|
const media = fs.readFileSync(sample), key = Buffer.alloc(16, 0x5a), iv = Buffer.alloc(16);
|
||||||
|
const cipher = crypto.createCipheriv('aes-128-cbc', key, iv);
|
||||||
|
const header = '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:12\n#EXT-X-MEDIA-SEQUENCE:0\n';
|
||||||
|
const resources = {
|
||||||
|
'/media.ts': media,
|
||||||
|
'/key.bin': key,
|
||||||
|
'/encrypted.ts': Buffer.concat([cipher.update(media), cipher.final()]),
|
||||||
|
'/plain.m3u8': Buffer.from(`${header}#EXTINF:12,\nmedia.ts\n#EXT-X-ENDLIST\n`),
|
||||||
|
'/encrypted.m3u8': Buffer.from(`${header}#EXT-X-KEY:METHOD=AES-128,URI="key.bin",IV=0x${iv.toString('hex')}\n#EXTINF:12,\nencrypted.ts\n#EXT-X-ENDLIST\n`),
|
||||||
|
'/local.m3u8': Buffer.from(`${header}#EXTINF:12,\n${pathToFileURL(sample).href}\n#EXT-X-ENDLIST\n`)
|
||||||
|
};
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const body = resources[req.url];
|
||||||
|
if (!body) { res.writeHead(404); res.end(); return; }
|
||||||
|
res.writeHead(200, { 'Content-Length': body.length,
|
||||||
|
'Content-Type': req.url.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'application/octet-stream' });
|
||||||
|
res.end(body);
|
||||||
|
});
|
||||||
|
t.after(() => new Promise(resolve => server.close(resolve)));
|
||||||
|
const base = await listen(server);
|
||||||
|
const { base: routeBase, calls } = await routeFixture(t);
|
||||||
|
for (const file of ['media.ts', 'plain.m3u8', 'encrypted.m3u8', 'local.m3u8']) {
|
||||||
|
const url = `${base}/${file}`;
|
||||||
|
assert.equal((await request(`${routeBase}/api/remux?url=${encodeURIComponent(url)}`)).status, 200);
|
||||||
|
const result = await runFFmpeg(ffmpeg, calls.at(-1).args);
|
||||||
|
assert.equal(result.timedOut, false, file);
|
||||||
|
if (file === 'local.m3u8') {
|
||||||
|
assert.notEqual(result.code, 0);
|
||||||
|
assert.match(result.stderr, /Protocol 'file' not on whitelist/);
|
||||||
|
assert.equal(result.stdout.length, 0);
|
||||||
|
} else {
|
||||||
|
assert.equal(result.code, 0, `${file}: ${result.stderr}`);
|
||||||
|
assert.ok(result.stdout.includes(Buffer.from('ftyp')), `${file}: no MP4 output`);
|
||||||
|
assert.ok(result.stdout.includes(Buffer.from('mdat')), `${file}: no media packets`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user