From ce897ee00903bf7ded406f0d7852d95dd4164add Mon Sep 17 00:00:00 2001 From: Alex Shnitman Date: Sat, 20 Jun 2026 09:52:21 +0300 Subject: [PATCH] fix open download of cookie files --- app/main.py | 27 +++++++++++++++++++++++++-- app/tests/test_api.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index d2283ca..03a222c 100644 --- a/app/main.py +++ b/app/main.py @@ -16,7 +16,7 @@ import logging import json import pathlib import re -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse +from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse from watchfiles import DefaultFilter, Change, awatch from ytdl import DownloadQueueNotifier, DownloadQueue, Download @@ -286,7 +286,30 @@ class ObjectSerializer(json.JSONEncoder): return json.JSONEncoder.default(self, obj) serializer = ObjectSerializer() -app = web.Application() + +_STATE_DIR_REAL = os.path.realpath(config.STATE_DIR) + + +def _is_within_state_dir(real_target: str) -> bool: + return real_target == _STATE_DIR_REAL or real_target.startswith(_STATE_DIR_REAL + os.sep) + + +@web.middleware +async def state_dir_guard(request, handler): + for prefix, base in ( + (config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR), + (config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR), + ): + if request.path.startswith(prefix): + rel = unquote(request.path[len(prefix):]) + target = os.path.realpath(os.path.join(base, rel)) + if _is_within_state_dir(target): + raise web.HTTPNotFound() + break + return await handler(request) + + +app = web.Application(middlewares=[state_dir_guard]) _cors_origins = [o.strip() for o in config.CORS_ALLOWED_ORIGINS.split(',') if o.strip()] if config.CORS_ALLOWED_ORIGINS else [] sio = socketio.AsyncServer(cors_allowed_origins=_cors_origins if _cors_origins else []) sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io') diff --git a/app/tests/test_api.py b/app/tests/test_api.py index 9776856..6481520 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -3,10 +3,13 @@ from __future__ import annotations import json +import os +from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer import main @@ -306,3 +309,41 @@ async def test_subscribe_rejects_clip_options(mock_dqueue, monkeypatch): with pytest.raises(web.HTTPBadRequest): await main.subscribe(req) main.submgr.add_subscription.assert_not_awaited() + + +def test_is_within_state_dir_blocks_state_subtree(): + state_dir = main._STATE_DIR_REAL + assert main._is_within_state_dir(state_dir) + assert main._is_within_state_dir(os.path.join(state_dir, "cookies.txt")) + assert main._is_within_state_dir(os.path.join(state_dir, "queue", "item.json")) + + +def test_is_within_state_dir_allows_sibling_downloads(): + download_dir = os.path.realpath(main.config.DOWNLOAD_DIR) + assert not main._is_within_state_dir(os.path.join(download_dir, "video.mp4")) + assert not main._is_within_state_dir("/tmp/unrelated/video.mp4") + + +@pytest.mark.asyncio +async def test_download_blocks_state_dir_files(monkeypatch): + download_dir = Path(main.config.DOWNLOAD_DIR) + state_dir = download_dir / ".metube" + state_dir.mkdir(parents=True, exist_ok=True) + (state_dir / "cookies.txt").write_text("# Netscape HTTP Cookie File\n", encoding="utf-8") + (download_dir / "video.mp4").write_bytes(b"video") + + monkeypatch.setattr(main.config, "STATE_DIR", str(state_dir)) + monkeypatch.setattr(main, "_STATE_DIR_REAL", os.path.realpath(str(state_dir))) + + try: + async with TestClient(TestServer(main.app)) as client: + blocked = await client.get("/download/.metube/cookies.txt") + assert blocked.status == 404 + + allowed = await client.get("/download/video.mp4") + assert allowed.status == 200 + assert await allowed.read() == b"video" + finally: + (state_dir / "cookies.txt").unlink(missing_ok=True) + (download_dir / "video.mp4").unlink(missing_ok=True) + state_dir.rmdir()