Gate manual yt-dlp overrides behind flag

Agent-Logs-Url: https://github.com/alexta69/metube/sessions/31b4274d-cf48-4260-b73b-633cbcd2bb09

Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-03 09:05:19 +00:00
committed by GitHub
parent 565a715037
commit 6e9b2dd7b3
8 changed files with 201 additions and 42 deletions
+1
View File
@@ -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_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__: 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`. * __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 ### 🌐 Web Server & URLs
+10 -35
View File
@@ -59,6 +59,7 @@ class Config:
'YTDL_OPTIONS_FILE': '', 'YTDL_OPTIONS_FILE': '',
'YTDL_OPTIONS_PRESETS': '{}', 'YTDL_OPTIONS_PRESETS': '{}',
'YTDL_OPTIONS_PRESETS_FILE': '', 'YTDL_OPTIONS_PRESETS_FILE': '',
'ALLOW_YTDL_OPTIONS_OVERRIDES': 'false',
'ROBOTS_TXT': '', 'ROBOTS_TXT': '',
'HOST': '0.0.0.0', 'HOST': '0.0.0.0',
'PORT': '8081', 'PORT': '8081',
@@ -72,7 +73,7 @@ class Config:
'ENABLE_ACCESSLOG': 'false', '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): def __init__(self):
for k, v in self._DEFAULTS.items(): for k, v in self._DEFAULTS.items():
@@ -126,6 +127,7 @@ class Config:
'PUBLIC_HOST_AUDIO_URL', 'PUBLIC_HOST_AUDIO_URL',
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL', 'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL',
'ALLOW_YTDL_OPTIONS_OVERRIDES',
) )
def frontend_safe(self) -> dict: def frontend_safe(self) -> dict:
@@ -232,36 +234,7 @@ VALID_VIDEO_CODECS = {'auto', 'h264', 'h265', 'av1', 'vp9'}
VALID_VIDEO_FORMATS = {'any', 'mp4', 'ios'} VALID_VIDEO_FORMATS = {'any', 'mp4', 'ios'}
VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'} VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'}
VALID_THUMBNAIL_FORMATS = {'jpg'} VALID_THUMBNAIL_FORMATS = {'jpg'}
BLOCKED_YTDL_OVERRIDE_KEYS = frozenset({ def _parse_ytdl_options_overrides(value, *, enabled: bool) -> dict:
'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:
if value is None or value == '': if value is None or value == '':
return {} return {}
@@ -274,9 +247,8 @@ def _parse_ytdl_options_overrides(value) -> dict:
if not isinstance(value, dict): if not isinstance(value, dict):
raise web.HTTPBadRequest(reason='ytdl_options_overrides must be a JSON object') 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 value and not enabled:
if blocked_keys: raise web.HTTPBadRequest(reason='ytdl_options_overrides are disabled')
raise web.HTTPBadRequest(reason=f'ytdl_options_overrides contains disallowed keys: {blocked_keys}')
return value return value
@@ -497,7 +469,10 @@ def parse_download_options(post: dict) -> dict:
subtitle_language = str(subtitle_language).strip() subtitle_language = str(subtitle_language).strip()
subtitle_mode = str(subtitle_mode).strip() subtitle_mode = str(subtitle_mode).strip()
ytdl_options_preset = str(ytdl_options_preset).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('\\')): 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') raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator')
+13 -1
View File
@@ -64,6 +64,7 @@ async def test_add_ok(mock_dqueue):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch): 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, "YTDL_OPTIONS_PRESETS", {"Preset A": {"writesubtitles": True}})
monkeypatch.setattr(main.config, "ALLOW_YTDL_OPTIONS_OVERRIDES", True)
req = _json_request( req = _json_request(
_valid_video_add_body( _valid_video_add_body(
ytdl_options_preset="Preset A", ytdl_options_preset="Preset A",
@@ -151,12 +152,23 @@ async def test_add_invalid_ytdl_options_override_json(mock_dqueue):
@pytest.mark.asyncio @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 /"}')) req = _json_request(_valid_video_add_body(ytdl_options_overrides='{"exec": "rm -rf /"}'))
with pytest.raises(web.HTTPBadRequest): with pytest.raises(web.HTTPBadRequest):
await main.add(req) 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 @pytest.mark.asyncio
async def test_add_unknown_ytdl_preset(mock_dqueue): async def test_add_unknown_ytdl_preset(mock_dqueue):
req = _json_request(_valid_video_add_body(ytdl_options_preset="Missing")) req = _json_request(_valid_video_add_body(ytdl_options_preset="Missing"))
+6
View File
@@ -59,6 +59,12 @@ class ConfigTests(unittest.TestCase):
safe = c.frontend_safe() safe = c.frontend_safe()
self.assertNotIn("YTDL_OPTIONS", safe) self.assertNotIn("YTDL_OPTIONS", safe)
self.assertNotIn("HOST", 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): def test_runtime_override_roundtrip(self):
with patch.dict(os.environ, _base_env(), clear=False): with patch.dict(os.environ, _base_env(), clear=False):
+14 -4
View File
@@ -99,25 +99,34 @@ class FrontendSafeTests(unittest.TestCase):
self.assertIn(key, safe) self.assertIn(key, safe)
self.assertNotIn("YTDL_OPTIONS", safe) self.assertNotIn("YTDL_OPTIONS", safe)
self.assertNotIn("DOWNLOAD_DIR", safe) self.assertNotIn("DOWNLOAD_DIR", safe)
self.assertIn("ALLOW_YTDL_OPTIONS_OVERRIDES", safe)
class ParseYtdlOverridesTests(unittest.TestCase): class ParseYtdlOverridesTests(unittest.TestCase):
def test_empty_override_string_returns_empty_dict(self): 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): def test_rejects_non_object_json(self):
with self.assertRaises(main.web.HTTPBadRequest): 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): 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): class ParseDownloadOptionsTests(unittest.TestCase):
def test_accepts_known_preset_and_overrides(self): def test_accepts_known_preset_and_overrides(self):
previous = dict(main.config.YTDL_OPTIONS_PRESETS) 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.YTDL_OPTIONS_PRESETS = {"With subtitles": {"writesubtitles": True}}
main.config.ALLOW_YTDL_OPTIONS_OVERRIDES = True
try: try:
parsed = main.parse_download_options({ parsed = main.parse_download_options({
"url": "https://example.com/v", "url": "https://example.com/v",
@@ -130,6 +139,7 @@ class ParseDownloadOptionsTests(unittest.TestCase):
}) })
finally: finally:
main.config.YTDL_OPTIONS_PRESETS = previous 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_preset"], "With subtitles")
self.assertEqual(parsed["ytdl_options_overrides"], {"writesubtitles": True}) self.assertEqual(parsed["ytdl_options_overrides"], {"writesubtitles": True})
+3 -1
View File
@@ -447,7 +447,7 @@
ngbTooltip="How often to poll subscriptions for new videos"> ngbTooltip="How often to poll subscriptions for new videos">
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-12" [class.col-md-6]="allowYtdlOptionsOverrides()">
<div class="input-group"> <div class="input-group">
<span class="input-group-text">Option Preset</span> <span class="input-group-text">Option Preset</span>
<select class="form-select" <select class="form-select"
@@ -463,6 +463,7 @@
</select> </select>
</div> </div>
</div> </div>
@if (allowYtdlOptionsOverrides()) {
<div class="col-md-6"> <div class="col-md-6">
<div class="input-group"> <div class="input-group">
<span class="input-group-text">Custom yt-dlp Options</span> <span class="input-group-text">Custom yt-dlp Options</span>
@@ -476,6 +477,7 @@
ngbTooltip="Optional per-download yt-dlp overrides as a JSON object"> ngbTooltip="Optional per-download yt-dlp overrides as a JSON object">
</div> </div>
</div> </div>
}
<div class="col-12"> <div class="col-12">
<div class="row g-2 align-items-center"> <div class="row g-2 align-items-center">
<div class="col-auto"> <div class="col-auto">
+143
View File
@@ -1,7 +1,101 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { Subject, of } from 'rxjs';
import { App } from './app'; import { App } from './app';
import { DownloadsService } from './services/downloads.service';
import { SubscriptionsService } from './services/subscriptions.service';
import { CookieService } from 'ngx-cookie-service';
class DownloadsServiceStub {
loading = false;
queue = new Map();
done = new Map();
configuration: Record<string, unknown> = { CUSTOM_DIRS: true, CREATE_CUSTOM_DIRS: true, ALLOW_YTDL_OPTIONS_OVERRIDES: false };
customDirs = { download_dir: [], audio_download_dir: [] };
queueChanged = new Subject<void>();
doneChanged = new Subject<void>();
configurationChanged = new Subject<Record<string, unknown>>();
customDirsChanged = new Subject<Record<string, string[]>>();
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
updated = new Subject<void>();
getCookieStatus() {
return of({ status: 'ok', has_cookies: false });
}
getPresets() {
return of({ presets: ['Preset A'] });
}
add() {
return of({ status: 'ok' as const });
}
cancelAdd() {
return of({ status: 'ok' as const });
}
startById() {
return of({});
}
delById() {
return of({});
}
delByFilter() {
return of({});
}
startByFilter() {
return of({});
}
uploadCookies() {
return of({ status: 'ok' });
}
deleteCookies() {
return of({ status: 'ok' });
}
}
class SubscriptionsServiceStub {
subscriptions = new Map();
subscriptionsChanged = new Subject<void>();
subscribe() {
return of({ status: 'ok' as const });
}
delete() {
return of({});
}
refreshList() {
return of([]);
}
}
class CookieServiceStub {
private cookies = new Map<string, string>();
get(name: string) {
return this.cookies.get(name) ?? '';
}
set(name: string, value: string) {
this.cookies.set(name, value);
}
check(name: string) {
return this.cookies.has(name);
}
}
describe('App', () => { describe('App', () => {
let downloads: DownloadsServiceStub;
beforeEach(async () => { beforeEach(async () => {
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,
@@ -15,8 +109,20 @@ describe('App', () => {
dispatchEvent: vi.fn(), dispatchEvent: vi.fn(),
})), })),
}); });
downloads = new DownloadsServiceStub();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [
{ provide: DownloadsService, useValue: downloads },
{ provide: SubscriptionsService, useClass: SubscriptionsServiceStub },
{ provide: CookieService, useClass: CookieServiceStub },
{
provide: HttpClient,
useValue: {
get: vi.fn().mockReturnValue(of({ 'yt-dlp': 'test', version: 'test' })),
},
},
],
}).compileComponents(); }).compileComponents();
}); });
@@ -25,4 +131,41 @@ describe('App', () => {
const app = fixture.componentInstance; const app = fixture.componentInstance;
expect(app).toBeTruthy(); expect(app).toBeTruthy();
}); });
it('hides manual override input when disabled', () => {
const fixture = TestBed.createComponent(App);
fixture.componentInstance.isAdvancedOpen = true;
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('input[name="ytdlOptionsOverrides"]')).toBeNull();
const presetWrapper = root.querySelector('select[name="ytdlOptionsPreset"]')?.closest('.col-12');
expect(presetWrapper?.classList.contains('col-md-6')).toBe(false);
});
it('shows manual override input when enabled', () => {
downloads.configuration.ALLOW_YTDL_OPTIONS_OVERRIDES = true;
const fixture = TestBed.createComponent(App);
fixture.componentInstance.isAdvancedOpen = true;
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('input[name="ytdlOptionsOverrides"]')).not.toBeNull();
const presetWrapper = root.querySelector('select[name="ytdlOptionsPreset"]')?.closest('.col-12');
expect(presetWrapper?.classList.contains('col-md-6')).toBe(true);
});
it('does not submit manual overrides when disabled', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
app.ytdlOptionsOverrides = '{"exec":"echo hi"}';
const payload = app['buildAddPayload']();
expect(payload.ytdlOptionsOverrides).toBe('');
});
}); });
+11 -1
View File
@@ -356,6 +356,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
return this.downloads.configuration['CUSTOM_DIRS']; return this.downloads.configuration['CUSTOM_DIRS'];
} }
allowYtdlOptionsOverrides() {
return this.downloads.configuration['ALLOW_YTDL_OPTIONS_OVERRIDES'] === true;
}
allowCustomDir(tag: string) { allowCustomDir(tag: string) {
if (this.downloads.configuration['CREATE_CUSTOM_DIRS']) { if (this.downloads.configuration['CREATE_CUSTOM_DIRS']) {
return tag; return tag;
@@ -437,6 +441,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
} }
private validateYtdlOptionsOverrides(value: string): boolean { private validateYtdlOptionsOverrides(value: string): boolean {
if (!this.allowYtdlOptionsOverrides()) {
return true;
}
const trimmed = value?.trim() || ''; const trimmed = value?.trim() || '';
if (!trimmed) { if (!trimmed) {
return true; return true;
@@ -930,6 +937,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
} }
private buildAddPayload(overrides: Partial<AddDownloadPayload> = {}): AddDownloadPayload { private buildAddPayload(overrides: Partial<AddDownloadPayload> = {}): AddDownloadPayload {
const allowYtdlOptionsOverrides = this.allowYtdlOptionsOverrides();
return { return {
url: overrides.url ?? this.addUrl, url: overrides.url ?? this.addUrl,
downloadType: overrides.downloadType ?? this.downloadType, downloadType: overrides.downloadType ?? this.downloadType,
@@ -945,7 +953,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage, subtitleLanguage: overrides.subtitleLanguage ?? this.subtitleLanguage,
subtitleMode: overrides.subtitleMode ?? this.subtitleMode, subtitleMode: overrides.subtitleMode ?? this.subtitleMode,
ytdlOptionsPreset: overrides.ytdlOptionsPreset ?? this.ytdlOptionsPreset, ytdlOptionsPreset: overrides.ytdlOptionsPreset ?? this.ytdlOptionsPreset,
ytdlOptionsOverrides: overrides.ytdlOptionsOverrides ?? this.ytdlOptionsOverrides, ytdlOptionsOverrides: allowYtdlOptionsOverrides
? (overrides.ytdlOptionsOverrides ?? this.ytdlOptionsOverrides)
: '',
}; };
} }