fix(remux): validate stream URL scheme and restrict ffmpeg protocols (CWE-78)

The /api/remux endpoint passed the user-supplied 'url' query parameter
straight to 'ffmpeg -i <url>'. spawn() with an argv array prevented shell
injection, but ffmpeg itself accepts a wide set of protocol handlers
(file://, concat:, subfile:, data:, pipe:, crypto:, async:, cache:, ...)
that read from the local filesystem or compose existing protocols.
Because the resulting ffmpeg stdout is piped directly back to the
response, an attacker who could call /api/remux could read arbitrary
local files (e.g. ?url=file:///etc/passwd, concat:file://...).

Apply two layers of defence at the entry point only:

1. validateStreamUrl(): reject anything that is not a syntactically valid
   http:// or https:// URL, and reject any string containing control
   characters before parsing (so CR/LF/NUL cannot be silently stripped).

2. Pass -protocol_whitelist http,https,tls,tcp,hls to ffmpeg so that even
   if a future code path forwarded a different URL, ffmpeg would refuse
   to open file/concat/subfile/pipe/data/crypto/async/cache inputs.

Legitimate http/https stream URLs (including ports, userinfo, queries,
and HLS playlists) continue to work; only non-network schemes and
malformed inputs are rejected with HTTP 400.
This commit is contained in:
Sebastion
2026-06-30 17:18:29 +01:00
committed by Technomancer702
parent 98d2651e7b
commit 138efd6007

View File

@ -3,6 +3,51 @@ 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 — otherwise an attacker
* who can call /api/remux can read arbitrary local files via the streamed
* response.
*
* 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/crypto/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.
*/
const FFMPEG_PROTOCOL_WHITELIST = 'http,https,tls,tcp,hls';
/**
* 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 +64,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 +83,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
@ -52,7 +106,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',