Merge pull request #154 from technomancer702/codex/remux-input-hardening

Restrict remux input protocols while preserving encrypted HLS
This commit is contained in:
Technomancer702
2026-10-12 15:45:38 -07:00
committed by GitHub
7 changed files with 280 additions and 8 deletions

24
.github/workflows/test.yml vendored Normal file
View 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

View File

@ -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",

View File

@ -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',

View File

@ -3,6 +3,52 @@ const router = express.Router();
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const db = require('../db'); const db = require('../db');
/**
* Allowed input URL schemes for the remux endpoint.
*
* FFmpeg accepts a wide variety of protocol handlers (file://, concat:,
* subfile:, data:, pipe:, crypto:, async:, cache:, ...). Several of them
* read from the local filesystem or compose existing protocols, so they
* must not be reachable from a request parameter. Remux inputs should only
* reference network streams, never local files or protocol wrappers.
*
* Only plain http/https stream URLs are valid inputs here.
*/
const ALLOWED_URL_SCHEMES = new Set(['http:', 'https:']);
/**
* The set of ffmpeg protocols we explicitly permit via -protocol_whitelist.
* This is a defence-in-depth layer applied on top of the scheme check: even
* if a future code path forwarded a different URL, ffmpeg itself would
* refuse to touch local files / arbitrary demuxer-level protocols.
*
* - file/concat/subfile/pipe/data/async/cache are intentionally absent.
* - tls/tcp are needed for https; https/http are obvious.
* - 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,crypto';
/**
* Validate a user-supplied stream URL.
* Returns the normalised URL string on success, or null on rejection.
*/
function validateStreamUrl(raw) {
if (typeof raw !== 'string' || raw.length === 0) return null;
// Reject control characters (incl. CR/LF/NUL) before WHATWG URL parsing,
// which would otherwise silently strip some of them.
if (/[\x00-\x1F\x7F]/.test(raw)) return null;
let parsed;
try {
parsed = new URL(raw);
} catch (_) {
return null;
}
if (!ALLOWED_URL_SCHEMES.has(parsed.protocol)) return null;
return parsed.toString();
}
/** /**
* Remux stream (container conversion only) * Remux stream (container conversion only)
* GET /api/remux?url=... * GET /api/remux?url=...
@ -19,13 +65,18 @@ router.get('/', async (req, res) => {
return res.status(400).json({ error: 'URL parameter is required' }); return res.status(400).json({ error: 'URL parameter is required' });
} }
const safeUrl = validateStreamUrl(url);
if (!safeUrl) {
return res.status(400).json({ error: 'URL must be an http:// or https:// stream URL' });
}
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg'; const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
// Get User-Agent from settings // Get User-Agent from settings
const settings = await db.settings.get(); const settings = await db.settings.get();
const userAgent = db.getUserAgent(settings); const userAgent = db.getUserAgent(settings);
console.log(`[Remux] Starting remux for: ${url}`); console.log(`[Remux] Starting remux for: ${safeUrl}`);
console.log(`[Remux] Using User-Agent: ${settings.userAgentPreset}`); console.log(`[Remux] Using User-Agent: ${settings.userAgentPreset}`);
// FFmpeg arguments for pure remux (no encoding) // FFmpeg arguments for pure remux (no encoding)
@ -33,6 +84,10 @@ router.get('/', async (req, res) => {
const args = [ const args = [
'-hide_banner', '-hide_banner',
'-loglevel', 'warning', '-loglevel', 'warning',
// Restrict the protocols ffmpeg is allowed to open for input.
// Defence in depth: blocks file://, concat:, subfile:, data:, pipe:, etc.
// even if a different URL slipped past validateStreamUrl().
'-protocol_whitelist', FFMPEG_PROTOCOL_WHITELIST,
'-user_agent', userAgent, '-user_agent', userAgent,
'-user_agent', userAgent, '-user_agent', userAgent,
// Standard probe size to handle complex containers (MKV) correctly // Standard probe size to handle complex containers (MKV) correctly
@ -44,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',
@ -52,7 +107,7 @@ router.get('/', async (req, res) => {
'-reconnect_delay_max', '10', '-reconnect_delay_max', '10',
// Prevent Range/HEAD requests that some providers reject with 405 // Prevent Range/HEAD requests that some providers reject with 405
'-seekable', '0', '-seekable', '0',
'-i', url, '-i', safeUrl,
// STRICT MAPPING: Only map video and audio, ignore subtitles/data/attachments // STRICT MAPPING: Only map video and audio, ignore subtitles/data/attachments
// This prevents remux failure when source container has incompatible subtitle tracks (e.g. MKV -> MP4) // This prevents remux failure when source container has incompatible subtitle tracks (e.g. MKV -> MP4)
'-map', '0:v', '-map', '0:v',

View File

@ -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',

View File

@ -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',

192
test/remux.test.js Normal file
View File

@ -0,0 +1,192 @@
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() {
// Match server/index.js: prefer system FFmpeg, then the optional binary.
if (spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0) return 'ffmpeg';
try {
const binary = require('ffmpeg-static');
if (binary && fs.existsSync(binary)) return binary;
} catch { /* Optional dependency may be absent. */ }
return 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, signal) => {
clearTimeout(timer);
resolve({ code, signal, 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} (${ffmpeg}, signal=${result.signal}): ${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`);
}
}
});