diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1e08c50 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/package.json b/package.json index 6bef281..504f49c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "main": "server/index.js", "scripts": { "start": "node server/index.js", - "dev": "node --watch server/index.js" + "dev": "node --watch server/index.js", + "test": "node --test" }, "dependencies": { "@ffprobe-installer/ffprobe": "^2.1.2", @@ -29,4 +30,4 @@ "engines": { "node": ">=18.0.0" } -} \ No newline at end of file +} diff --git a/server/routes/probe.js b/server/routes/probe.js index c344e86..a085871 100644 --- a/server/routes/probe.js +++ b/server/routes/probe.js @@ -33,7 +33,7 @@ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) { const args = [ '-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', - '-http_persistent', '0', + '-multiple_requests', '0', '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_on_http_error', '4xx,5xx', diff --git a/server/routes/remux.js b/server/routes/remux.js index 4f699a1..34b442f 100644 --- a/server/routes/remux.js +++ b/server/routes/remux.js @@ -3,6 +3,52 @@ const router = express.Router(); const { spawn } = require('child_process'); 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) * GET /api/remux?url=... @@ -19,13 +65,18 @@ router.get('/', async (req, res) => { 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'; // Get User-Agent from settings const settings = await db.settings.get(); 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}`); // FFmpeg arguments for pure remux (no encoding) @@ -33,6 +84,10 @@ router.get('/', async (req, res) => { const args = [ '-hide_banner', '-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, // Standard probe size to handle complex containers (MKV) correctly @@ -44,7 +99,7 @@ router.get('/', async (req, res) => { '-err_detect', 'ignore_err', // Limit max demux delay to prevent buffering issues with bad timestamps '-max_delay', '5000000', - '-http_persistent', '0', + '-multiple_requests', '0', // Reconnect settings for network drops '-reconnect', '1', '-reconnect_streamed', '1', @@ -52,7 +107,7 @@ router.get('/', async (req, res) => { '-reconnect_delay_max', '10', // Prevent Range/HEAD requests that some providers reject with 405 '-seekable', '0', - '-i', url, + '-i', safeUrl, // 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) '-map', '0:v', diff --git a/server/routes/transcode.js b/server/routes/transcode.js index 051d679..c5ae8e4 100644 --- a/server/routes/transcode.js +++ b/server/routes/transcode.js @@ -192,7 +192,7 @@ router.get('/', async (req, res) => { '-err_detect', 'ignore_err', // Limit max demux delay to prevent buffering issues '-max_delay', '2000000', - '-http_persistent', '0', + '-multiple_requests', '0', // Reconnect settings for network drops (useful for live streams) '-reconnect', '1', '-reconnect_streamed', '1', diff --git a/server/services/transcodeSession.js b/server/services/transcodeSession.js index f48a816..0b150fd 100644 --- a/server/services/transcodeSession.js +++ b/server/services/transcodeSession.js @@ -198,7 +198,7 @@ class TranscodeSession extends EventEmitter { '-analyzeduration', '5000000', '-fflags', '+genpts+discardcorrupt', '-err_detect', 'ignore_err', - '-http_persistent', '0', + '-multiple_requests', '0', '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_on_http_error', '4xx,5xx', diff --git a/test/remux.test.js b/test/remux.test.js new file mode 100644 index 0000000..62b13b1 --- /dev/null +++ b/test/remux.test.js @@ -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`); + } + } + });