From 53c16a39d689aa96dc8b4dda4934f792c54f6ce2 Mon Sep 17 00:00:00 2001 From: robomello Date: Thu, 9 Jul 2026 15:03:29 -0500 Subject: [PATCH] fix: prevent yt-dlp argument injection via unsanitized URL The url field from POST /api/download, /api/info, and /api/playlist was passed straight into the yt-dlp argv list. Since these endpoints are unauthenticated, a caller could pass a value like "--exec=" instead of a real URL; yt-dlp parses any argv item starting with "-" as an option rather than a positional URL, so this allowed arbitrary command execution on the host after a (fake) download. Fixes: - Add is_safe_url() to require an http(s) scheme + host, rejecting anything that could be interpreted as a CLI flag. - Insert a "--" argv separator before the URL in every yt-dlp invocation so option parsing stops regardless of URL content (defense in depth alongside the scheme check). - Apply the check on all three endpoints that accept a URL. --- app.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 2b5eaa5..e9dbb09 100644 --- a/app.py +++ b/app.py @@ -4,6 +4,7 @@ import json import subprocess import threading +from urllib.parse import urlparse from flask import Flask, request, jsonify, send_file, render_template app = Flask(__name__) @@ -13,6 +14,20 @@ jobs = {} +def is_safe_url(url): + """Reject anything that isn't a plain http(s) URL. + + This also blocks strings starting with ``-``/``--`` which yt-dlp would + otherwise parse as CLI options (e.g. ``--exec``), letting a caller + smuggle arbitrary flags into the subprocess invocation. + """ + try: + parsed = urlparse(url) + except ValueError: + return False + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + + def parse_ytdlp_json(stdout): """Parse yt-dlp JSON output. @@ -42,7 +57,10 @@ def run_download(job_id, url, format_choice, format_id): else: cmd += ["-f", "bestvideo+bestaudio/best", "--merge-output-format", "mp4"] - cmd.append(url) + # "--" stops yt-dlp from treating a URL that begins with "-" as an + # option (e.g. "--exec=..."), which would otherwise allow arbitrary + # command execution. + cmd += ["--", url] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) @@ -100,8 +118,10 @@ def get_info(): url = data.get("url", "").strip() if not url: return jsonify({"error": "No URL provided"}), 400 + if not is_safe_url(url): + return jsonify({"error": "Invalid URL"}), 400 - cmd = ["yt-dlp", "--no-playlist", "-j", url] + cmd = ["yt-dlp", "--no-playlist", "-j", "--", url] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: @@ -146,8 +166,10 @@ def get_playlist_info(): url = data.get("url", "").strip() if not url: return jsonify({"error": "No URL provided"}), 400 + if not is_safe_url(url): + return jsonify({"error": "Invalid URL"}), 400 - cmd = ["yt-dlp", "--flat-playlist", "-J", url] + cmd = ["yt-dlp", "--flat-playlist", "-J", "--", url] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: @@ -173,6 +195,8 @@ def start_download(): if not url: return jsonify({"error": "No URL provided"}), 400 + if not is_safe_url(url): + return jsonify({"error": "Invalid URL"}), 400 job_id = uuid.uuid4().hex[:10] jobs[job_id] = {"status": "downloading", "url": url, "title": title}