CVE-2024-38519: Insufficient Information (NVD-CWE-noinfo)
value: `yt-dlp` and `youtube-dl` are command-line audio/video downloaders. Prior to the fixed versions, `yt-dlp` and `youtube-dl` do not limit the extensions of downloaded files, which could lead to arbitrary filenames being created in the download folder (and path traversal on Windows). Since `yt-dlp` and `youtube-dl` also read config from the working directory (and on Windows executables will be executed from the `yt-dlp` or `youtube-dl` directory), this could lead to arbitrary code being executed.




`yt-dlp` version 2024.07.01 fixes this issue by whitelisting the allowed extensions. `youtube-dl` fixes this issue in commit `d42a222` on the `master` branch and in nightly builds tagged 2024-07-03 or later. This might mean some very uncommon extensions might not get downloaded, however it will also limit the possible exploitation surface. In addition to upgrading, have `.%(ext)s` at the end of the output template and make sure the user trusts the websites that they are downloading from. Also, make sure to never download to a directory within PATH or other sensitive locations like one's user directory, `system32`, or other binaries locations. For users who are not able to upgrade, keep the default output template (`-o "%(title)s [%(id)s].%(ext)s`); make sure the extension of the media to download is a common video/audio/sub/... one; try to avoid the generic extractor; and/or use `--ignore-config --config-location ...` to not load config from common locations.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-07-02T14:15Z
Repository: https://github.com/yt-dlp/yt-dlp
Language: Python
Files changed: README.md, devscripts/changelog_override.json, test/test_utils.py, yt_dlp/YoutubeDL.py, yt_dlp/__init__.py, yt_dlp/options.py, yt_dlp/utils/_utils.py
Commit date: 2024-07-02 00:58:40 +0200
Commit message: [core] Disallow unsafe extensions (CVE-2024-38519)

Ref: https://github.com/yt-dlp/yt-dlp/security/advisories/GHSA-79w7-vh3h-8g4j

Authored by: Grub4K

Diff: diff --git a/README.md b/README.md
index f265c8b558..d1fd6e4f06 100644
--- a/README.md
+++ b/README.md
@@ -2227,10 +2227,18 @@ ### Differences in default behavior
 * `--compat-options youtube-dlc`: Same as `--compat-options all,-no-live-chat,-no-youtube-channel-redirect,-playlist-match-filter,-manifest-filesize-approx`
 * `--compat-options 2021`: Same as `--compat-options 2022,no-certifi,filename-sanitization,no-youtube-prefer-utc-upload-date`
 * `--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
 
 #### Almost redundant options
diff --git a/devscripts/changelog_override.json b/devscripts/changelog_override.json
index f7209f3bda..ced38a0ddf 100644
--- a/devscripts/changelog_override.json
+++ b/devscripts/changelog_override.json
@@ -173,7 +173,12 @@
     {
         "action": "change",
         "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"
     }
 ]
diff --git a/test/test_utils.py b/test/test_utils.py
index 251739686e..3ff1f8b556 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -128,10 +128,11 @@
     xpath_attr,
     xpath_element,
     xpath_text,
     xpath_with_ns,
 )
+from yt_dlp.utils._utils import _UnsafeExtensionError
 from yt_dlp.utils.networking import (
     HTTPHeaderDict,
     escape_rfc3986,
     normalize_url,
     remove_dot_segments,
@@ -279,26 +280,56 @@ def env(var):
                 expand_path('~/{}'.format(env('yt_dlp_EXPATH_PATH'))),
                 '{}/expanded'.format(os.getenv('HOME')))
         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')
         self.assertEqual(prepend_extension('abc.unexpected_ext', 'temp', 'ext'), 'abc.unexpected_ext.temp')
         self.assertEqual(prepend_extension('abc', 'temp'), 'abc.temp')
         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')
         self.assertEqual(replace_extension('abc.unexpected_ext', 'temp', 'ext'), 'abc.unexpected_ext.temp')
         self.assertEqual(replace_extension('abc', 'temp'), 'abc.temp')
         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')
         self.assertEqual(subtitles_filename('abc.unexpected_ext', 'en', 'vtt', 'ext'), 'abc.unexpected_ext.en.vtt')
 
diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py
index a8d0342d53..e56c3ed3c9 100644
--- a/yt_dlp/YoutubeDL.py
+++ b/yt_dlp/YoutubeDL.py
@@ -157,11 +157,11 @@
     version_tuple,
     windows_enable_vt_mode,
     write_json_file,
     write_string,
 )
-from .utils._utils import _YDLLogger
+from .utils._utils import _UnsafeExtensionError, _YDLLogger
 from .utils.networking import (
     HTTPHeaderDict,
     clean_headers,
     clean_proxies,
     std_headers,
@@ -170,10 +170,24 @@
 
 if compat_os_name == 'nt':
     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.
 
     YoutubeDL objects are the ones responsible of downloading the
     actual video file and writing it to disk if the user has requested
@@ -452,12 +466,13 @@ class YoutubeDL:
                        external downloader to use for it. The allowed protocols
                        are default|http|ftp|m3u8|dash|rtsp|rtmp|mms.
                        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',
                        'download-title' (console title) and 'postprocess-title'.
                        The template is mapped on a dictionary with keys 'progress' and 'info'
@@ -1398,10 +1413,11 @@ def create_key(outer_mobj):
 
     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:
             outtmpl = self.params['outtmpl'].get(tmpl_type or 'default', self.params['outtmpl']['default'])
         try:
@@ -3190,10 +3206,11 @@ def existing_file(self, filepaths, *, default_overwrite=True):
         for file in existing_files:
             self.report_file_delete(file)
             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)"""
 
         assert info_dict.get('_type', 'video') == 'video'
         original_infodict = info_dict
diff --git a/yt_dlp/__init__.py b/yt_dlp/__init__.py
index c18af75891..f88f15d70c 100644
--- a/yt_dlp/__init__.py
+++ b/yt_dlp/__init__.py
@@ -62,10 +62,11 @@
     traverse_obj,
     variadic,
     write_string,
 )
 from .utils.networking import std_headers
+from .utils._utils import _UnsafeExtensionError
 from .YoutubeDL import YoutubeDL
 
 _IN_CLI = False
 
 
@@ -591,10 +592,17 @@ def report_deprecation(val, old, new=None):
     if opts.username is not None and opts.password is None:
         opts.password = getpass.getpass('Type account password and press [Return]: ')
     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
 
 
 def get_postprocessors(opts):
     yield from opts.add_postprocessors
diff --git a/yt_dlp/options.py b/yt_dlp/options.py
index b97c516ceb..1b18575c18 100644
--- a/yt_dlp/options.py
+++ b/yt_dlp/options.py
@@ -472,11 +472,11 @@ def _alias_callback(option, opt_str, value, parser, opts, nargs):
                 'filename', 'filename-sanitization', 'format-sort', 'abort-on-error', 'format-spec', 'no-playlist-metafiles',
                 'multistreams', 'no-live-chat', 'playlist-index', 'list-formats', 'no-direct-merge', 'playlist-match-filter',
                 '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'],
                 '2021': ['2022', 'no-certifi', 'filename-sanitization'],
                 '2022': ['2023', 'no-external-downloader-progress', 'playlist-match-filter', 'prefer-legacy-http-handler', 'manifest-filesize-approx'],
diff --git a/yt_dlp/utils/_utils.py b/yt_dlp/utils/_utils.py
index 664675a099..b5e1e2950c 100644
--- a/yt_dlp/utils/_utils.py
+++ b/yt_dlp/utils/_utils.py
@@ -2083,21 +2083,24 @@ def parse_duration(s):
         ms = ms.replace(':', '.')
     return sum(float(part or 0) * mult for part, mult in (
         (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=[]):
     """ Checks if the given binary is installed somewhere in PATH, and returns its name.
     args can be a list of arguments for a short output (like -version) """
@@ -5033,10 +5036,105 @@ def items_(self):
 MEDIA_EXTENSIONS.audio += MEDIA_EXTENSIONS.common_audio
 
 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(...):
             try:
                 ...

Vulnerable code: from .utils._utils import _YDLLogger
                       filename, abort-on-error, multistreams, no-live-chat, format-sort
                       no-clean-infojson, no-playlist-metafiles, no-keep-subs, no-attach-info-json.
                'prefer-legacy-http-handler', 'manifest-filesize-approx',
def prepend_extension(filename, ext, expected_real_ext=None):
    return (
        f'{name}.{ext}{real_ext}'
        if not expected_real_ext or real_ext[1:] == expected_real_ext
        else f'{filename}.{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}'
Fixed code: 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!**

    },
    {
        "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"
from yt_dlp.utils._utils import _UnsafeExtensionError
    _uncommon_extensions = [
        ('exe', 'abc.exe.ext'),
        ('de', 'abc.de.ext'),
        ('../.mp4', None),
        ('..\\.mp4', None),
    ]

        # 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')

        # 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')

from .utils._utils import _UnsafeExtensionError, _YDLLogger
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


                       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.
    @_catch_unsafe_extension_error
    @_catch_unsafe_extension_error
from .utils._utils import _UnsafeExtensionError
    # 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

                'prefer-legacy-http-handler', 'manifest-filesize-approx', 'allow-unsafe-ext',
def _change_extension(prepend, filename, ext, expected_real_ext=None):
    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)}'

prepend_extension = functools.partial(_change_extension, True)
replace_extension = functools.partial(_change_extension, False)
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