diff --git a/README.md b/README.md index bfa709a..bb1845a 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Certain values can be set via environment variables, using the `-e` parameter on * __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected. * __YTDL_OPTIONS_PRESETS__: A JSON object mapping preset names to yt-dlp option objects. These preset names are exposed in the web UI's Advanced Options panel so users can pick per-download overrides without changing the global `YTDL_OPTIONS`. * __YTDL_OPTIONS_PRESETS_FILE__: A path to a JSON file containing `YTDL_OPTIONS_PRESETS`. If both are specified, values from `YTDL_OPTIONS_PRESETS_FILE` are merged into `YTDL_OPTIONS_PRESETS`. +* __ALLOW_YTDL_OPTIONS_OVERRIDES__: Whether to show the web UI field for manual per-download `ytdl_options_overrides`. Defaults to `false`. Enabling this allows arbitrary yt-dlp API options to be supplied by UI users, which may enable arbitrary command execution inside the container depending on the options used. Enable only if you understand and accept that risk. ### 🌐 Web Server & URLs diff --git a/app/main.py b/app/main.py index 4ffe2cb..2a01d77 100644 --- a/app/main.py +++ b/app/main.py @@ -59,6 +59,7 @@ class Config: 'YTDL_OPTIONS_FILE': '', 'YTDL_OPTIONS_PRESETS': '{}', 'YTDL_OPTIONS_PRESETS_FILE': '', + 'ALLOW_YTDL_OPTIONS_OVERRIDES': 'false', 'ROBOTS_TXT': '', 'HOST': '0.0.0.0', 'PORT': '8081', @@ -72,7 +73,7 @@ class Config: 'ENABLE_ACCESSLOG': 'false', } - _BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG') + _BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'ALLOW_YTDL_OPTIONS_OVERRIDES') def __init__(self): for k, v in self._DEFAULTS.items(): @@ -126,6 +127,7 @@ class Config: 'PUBLIC_HOST_AUDIO_URL', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT', 'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL', + 'ALLOW_YTDL_OPTIONS_OVERRIDES', ) def frontend_safe(self) -> dict: @@ -232,36 +234,7 @@ VALID_VIDEO_CODECS = {'auto', 'h264', 'h265', 'av1', 'vp9'} VALID_VIDEO_FORMATS = {'any', 'mp4', 'ios'} VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'} VALID_THUMBNAIL_FORMATS = {'jpg'} -BLOCKED_YTDL_OVERRIDE_KEYS = frozenset({ - 'exec', - 'exec_before_dl', - 'exec_cmd', - 'external_downloader', - 'external_downloader_args', - 'format', - 'ignore_no_formats_error', - 'no_color', - 'outtmpl', - 'paths', - 'postprocessor_hooks', - 'progress_hooks', - 'quiet', - 'socket_timeout', - 'verbose', -}) - - -def _iter_nested_keys(value): - if isinstance(value, dict): - for key, nested in value.items(): - yield str(key) - yield from _iter_nested_keys(nested) - elif isinstance(value, list): - for item in value: - yield from _iter_nested_keys(item) - - -def _parse_ytdl_options_overrides(value) -> dict: +def _parse_ytdl_options_overrides(value, *, enabled: bool) -> dict: if value is None or value == '': return {} @@ -274,9 +247,8 @@ def _parse_ytdl_options_overrides(value) -> dict: if not isinstance(value, dict): raise web.HTTPBadRequest(reason='ytdl_options_overrides must be a JSON object') - blocked_keys = sorted({key for key in _iter_nested_keys(value) if key in BLOCKED_YTDL_OVERRIDE_KEYS}) - if blocked_keys: - raise web.HTTPBadRequest(reason=f'ytdl_options_overrides contains disallowed keys: {blocked_keys}') + if value and not enabled: + raise web.HTTPBadRequest(reason='ytdl_options_overrides are disabled') return value @@ -497,7 +469,10 @@ def parse_download_options(post: dict) -> dict: subtitle_language = str(subtitle_language).strip() subtitle_mode = str(subtitle_mode).strip() ytdl_options_preset = str(ytdl_options_preset).strip() - ytdl_options_overrides = _parse_ytdl_options_overrides(ytdl_options_overrides) + ytdl_options_overrides = _parse_ytdl_options_overrides( + ytdl_options_overrides, + enabled=config.ALLOW_YTDL_OPTIONS_OVERRIDES, + ) if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')): raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator') diff --git a/app/tests/test_api.py b/app/tests/test_api.py index 5af4e25..b8d1f2d 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -64,6 +64,7 @@ async def test_add_ok(mock_dqueue): @pytest.mark.asyncio async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch): monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset A": {"writesubtitles": True}}) + monkeypatch.setattr(main.config, "ALLOW_YTDL_OPTIONS_OVERRIDES", True) req = _json_request( _valid_video_add_body( ytdl_options_preset="Preset A", @@ -151,12 +152,23 @@ async def test_add_invalid_ytdl_options_override_json(mock_dqueue): @pytest.mark.asyncio -async def test_add_blocked_ytdl_options_override_key(mock_dqueue): +async def test_add_rejects_ytdl_options_overrides_when_disabled(mock_dqueue): req = _json_request(_valid_video_add_body(ytdl_options_overrides='{"exec": "rm -rf /"}')) with pytest.raises(web.HTTPBadRequest): await main.add(req) +@pytest.mark.asyncio +async def test_add_allows_any_ytdl_options_override_key_when_enabled(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.config, "ALLOW_YTDL_OPTIONS_OVERRIDES", True) + req = _json_request(_valid_video_add_body(ytdl_options_overrides='{"exec": "echo hi"}')) + resp = await main.add(req) + assert resp.status == 200 + call = mock_dqueue.add.await_args + assert call is not None + assert call.args[14] == {"exec": "echo hi"} + + @pytest.mark.asyncio async def test_add_unknown_ytdl_preset(mock_dqueue): req = _json_request(_valid_video_add_body(ytdl_options_preset="Missing")) diff --git a/app/tests/test_config.py b/app/tests/test_config.py index 558f998..d97c1a9 100644 --- a/app/tests/test_config.py +++ b/app/tests/test_config.py @@ -59,6 +59,12 @@ class ConfigTests(unittest.TestCase): safe = c.frontend_safe() self.assertNotIn("YTDL_OPTIONS", safe) self.assertNotIn("HOST", safe) + self.assertEqual(safe["ALLOW_YTDL_OPTIONS_OVERRIDES"], False) + + def test_allow_ytdl_options_overrides_boolean_loaded(self): + with patch.dict(os.environ, _base_env(ALLOW_YTDL_OPTIONS_OVERRIDES="true"), clear=False): + c = Config() + self.assertTrue(c.ALLOW_YTDL_OPTIONS_OVERRIDES) def test_runtime_override_roundtrip(self): with patch.dict(os.environ, _base_env(), clear=False): diff --git a/app/tests/test_main_helpers.py b/app/tests/test_main_helpers.py index e6cfcfd..37dd6fd 100644 --- a/app/tests/test_main_helpers.py +++ b/app/tests/test_main_helpers.py @@ -99,25 +99,34 @@ class FrontendSafeTests(unittest.TestCase): self.assertIn(key, safe) self.assertNotIn("YTDL_OPTIONS", safe) self.assertNotIn("DOWNLOAD_DIR", safe) + self.assertIn("ALLOW_YTDL_OPTIONS_OVERRIDES", safe) class ParseYtdlOverridesTests(unittest.TestCase): def test_empty_override_string_returns_empty_dict(self): - self.assertEqual(main._parse_ytdl_options_overrides(""), {}) + self.assertEqual(main._parse_ytdl_options_overrides("", enabled=False), {}) def test_rejects_non_object_json(self): with self.assertRaises(main.web.HTTPBadRequest): - main._parse_ytdl_options_overrides('["bad"]') + main._parse_ytdl_options_overrides('["bad"]', enabled=True) - def test_rejects_blocked_keys(self): + def test_rejects_non_empty_overrides_when_disabled(self): with self.assertRaises(main.web.HTTPBadRequest): - main._parse_ytdl_options_overrides('{"exec": "rm -rf /"}') + main._parse_ytdl_options_overrides('{"exec": "rm -rf /"}', enabled=False) + + def test_allows_any_keys_when_enabled(self): + self.assertEqual( + main._parse_ytdl_options_overrides('{"exec": "rm -rf /"}', enabled=True), + {"exec": "rm -rf /"}, + ) class ParseDownloadOptionsTests(unittest.TestCase): def test_accepts_known_preset_and_overrides(self): previous = dict(main.config.YTDL_OPTIONS_PRESETS) + previous_allow = main.config.ALLOW_YTDL_OPTIONS_OVERRIDES main.config.YTDL_OPTIONS_PRESETS = {"With subtitles": {"writesubtitles": True}} + main.config.ALLOW_YTDL_OPTIONS_OVERRIDES = True try: parsed = main.parse_download_options({ "url": "https://example.com/v", @@ -130,6 +139,7 @@ class ParseDownloadOptionsTests(unittest.TestCase): }) finally: main.config.YTDL_OPTIONS_PRESETS = previous + main.config.ALLOW_YTDL_OPTIONS_OVERRIDES = previous_allow self.assertEqual(parsed["ytdl_options_preset"], "With subtitles") self.assertEqual(parsed["ytdl_options_overrides"], {"writesubtitles": True}) diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 73d4f77..453d4d9 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -447,7 +447,7 @@ ngbTooltip="How often to poll subscriptions for new videos"> -