diff --git a/app/features/conditions/router.py b/app/features/conditions/router.py index d4e462fe..beef2612 100644 --- a/app/features/conditions/router.py +++ b/app/features/conditions/router.py @@ -1,3 +1,4 @@ +import asyncio import logging from collections import OrderedDict from typing import Any @@ -85,7 +86,7 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code) try: - validate_url(url, allow_internal=config.allow_internal_urls) + await asyncio.to_thread(validate_url, url, config.allow_internal_urls) except ValueError as e: return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code) diff --git a/app/features/tasks/router.py b/app/features/tasks/router.py index 9cd23f19..0f15e14d 100644 --- a/app/features/tasks/router.py +++ b/app/features/tasks/router.py @@ -1,3 +1,4 @@ +import asyncio import logging from typing import TYPE_CHECKING, Any @@ -469,7 +470,7 @@ async def task_handler_inspect(request: Request, handler: TaskHandle, encoder: E static_only: bool = data.get("static_only", False) if isinstance(data, dict) else False if not static_only: try: - validate_url(url, allow_internal=config.allow_internal_urls) + await asyncio.to_thread(validate_url, url, config.allow_internal_urls) except ValueError as e: return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code) @@ -710,7 +711,7 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config, continue try: - validate_url(url, allow_internal=config.allow_internal_urls) + await asyncio.to_thread(validate_url, url, config.allow_internal_urls) except ValueError: LOG.warning(f"Invalid thumbnail url '{url}'") continue diff --git a/app/features/ytdlp/router.py b/app/features/ytdlp/router.py index 1054e3fb..d1653b11 100644 --- a/app/features/ytdlp/router.py +++ b/app/features/ytdlp/router.py @@ -1,3 +1,4 @@ +import asyncio import json import logging import time @@ -301,7 +302,7 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response: ) try: - validate_url(url, allow_internal=config.allow_internal_urls) + await asyncio.to_thread(validate_url, url, config.allow_internal_urls) except ValueError as e: return web.json_response( data={"status": False, "message": str(e), "error": str(e)}, @@ -453,7 +454,7 @@ async def get_archive_ids(request: Request, config: Config) -> Response: for i, url in enumerate(data): dct = {"index": i, "url": url} try: - validate_url(url, allow_internal=config.allow_internal_urls) + await asyncio.to_thread(validate_url, url, config.allow_internal_urls) dct.update(get_archive_id(url)) except ValueError as e: dct.update({"id": None, "ie_key": None, "archive_id": None, "error": str(e)}) diff --git a/app/routes/api/images.py b/app/routes/api/images.py index 66dce6ae..164e9479 100644 --- a/app/routes/api/images.py +++ b/app/routes/api/images.py @@ -1,3 +1,4 @@ +import asyncio import logging import random import time @@ -39,7 +40,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response: return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code) try: - validate_url(url, allow_internal=config.allow_internal_urls) + await asyncio.to_thread(validate_url, url, config.allow_internal_urls) except ValueError as e: return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code) @@ -59,6 +60,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response: url=url, follow_redirects=True, headers=request_headers, + timeout=10.0, ) if response.status_code != web.HTTPOk.status_code: diff --git a/app/tests/test_async_url_validation_routes.py b/app/tests/test_async_url_validation_routes.py new file mode 100644 index 00000000..69d8431e --- /dev/null +++ b/app/tests/test_async_url_validation_routes.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import json +from typing import Any, Generator + +import pytest + +from app.features.conditions import router as conditions_router +from app.features.tasks import router as tasks_router +from app.features.ytdlp import router as ytdlp_router +from app.library.config import Config + + +@pytest.fixture(autouse=True) +def reset_config() -> Generator[None, None, None]: + Config._reset_singleton() + yield + Config._reset_singleton() + + +class _JsonRequest: + def __init__(self, payload: Any) -> None: + self._payload = payload + self.body_exists = payload is not None + + async def json(self) -> Any: + return self._payload + + +class _InspectRequest(_JsonRequest): + query: dict[str, str] = {} + match_info: dict[str, str] = {} + + +class _QueryRequest: + def __init__(self, query: dict[str, str]) -> None: + self.query = query + + +def _patch_to_thread(monkeypatch: pytest.MonkeyPatch, module: Any, config: Config, url: str) -> dict[str, bool]: + called = {"to_thread": False, "validate": False} + + def fake_validate_url(next_url: str, allow_internal: bool = False) -> bool: + called["validate"] = True + assert next_url == url + assert allow_internal is config.allow_internal_urls + raise ValueError("Invalid hostname.") + + async def fake_to_thread(func, *args, **kwargs): + called["to_thread"] = True + return func(*args, **kwargs) + + monkeypatch.setattr(module, "validate_url", fake_validate_url) + monkeypatch.setattr(module.asyncio, "to_thread", fake_to_thread) + return called + + +@pytest.mark.asyncio +async def test_inspect_validate_off_thread(monkeypatch: pytest.MonkeyPatch) -> None: + config = Config.get_instance() + request = _InspectRequest({"url": "https://bad.example/task"}) + called = _patch_to_thread(monkeypatch, tasks_router, config, "https://bad.example/task") + + response = await tasks_router.task_handler_inspect(request, handler=None, encoder=None, config=config) + + assert response.status == 400 + assert json.loads(response.body.decode("utf-8")) == {"error": "Invalid hostname."} + assert called == {"to_thread": True, "validate": True} + + +@pytest.mark.asyncio +async def test_conditions_validate_off_thread(monkeypatch: pytest.MonkeyPatch) -> None: + config = Config.get_instance() + request = _JsonRequest({"url": "https://bad.example/cond", "condition": "title ~= 'x'"}) + called = _patch_to_thread(monkeypatch, conditions_router, config, "https://bad.example/cond") + + response = await conditions_router.conditions_test(request, encoder=None, cache=None, config=config) + + assert response.status == 400 + assert json.loads(response.body.decode("utf-8")) == {"error": "Invalid hostname."} + assert called == {"to_thread": True, "validate": True} + + +@pytest.mark.asyncio +async def test_info_validate_off_thread(monkeypatch: pytest.MonkeyPatch) -> None: + config = Config.get_instance() + request = _QueryRequest({"url": "https://bad.example/info"}) + called = _patch_to_thread(monkeypatch, ytdlp_router, config, "https://bad.example/info") + + response = await ytdlp_router.get_info(request, cache=None, config=config) + + assert response.status == 400 + assert json.loads(response.body.decode("utf-8")) == { + "status": False, + "message": "Invalid hostname.", + "error": "Invalid hostname.", + } + assert called == {"to_thread": True, "validate": True} + + +@pytest.mark.asyncio +async def test_archive_ids_validate_off_thread(monkeypatch: pytest.MonkeyPatch) -> None: + config = Config.get_instance() + request = _JsonRequest(["https://bad.example/archive"]) + called = _patch_to_thread(monkeypatch, ytdlp_router, config, "https://bad.example/archive") + + response = await ytdlp_router.get_archive_ids(request, config) + + assert response.status == 200 + assert json.loads(response.body.decode("utf-8")) == [ + { + "index": 0, + "url": "https://bad.example/archive", + "id": None, + "ie_key": None, + "archive_id": None, + "error": "Invalid hostname.", + } + ] + assert called == {"to_thread": True, "validate": True} diff --git a/app/tests/test_images_routes.py b/app/tests/test_images_routes.py new file mode 100644 index 00000000..e7b10efb --- /dev/null +++ b/app/tests/test_images_routes.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import Generator +from unittest.mock import AsyncMock + +import pytest +from aiohttp import web +from aiohttp.test_utils import make_mocked_request + +from app.library.config import Config +from app.routes.api import images + + +@pytest.fixture(autouse=True) +def reset_config() -> Generator[None, None, None]: + Config._reset_singleton() + yield + Config._reset_singleton() + + +class _Response: + def __init__(self, *, status_code: int = 200, content: bytes = b"img", content_type: str = "image/jpeg") -> None: + self.status_code = status_code + self.content = content + self.headers = {"Content-Type": content_type} + + +@pytest.mark.asyncio +async def test_thumbnail_validate_off_thread(monkeypatch: pytest.MonkeyPatch) -> None: + config = Config.get_instance() + req = make_mocked_request("GET", "/api/thumbnail?url=https://example.com/a.jpg") + req._rel_url = req._rel_url.with_query({"url": "https://example.com/a.jpg"}) + + called = {"to_thread": False, "validate": False} + + def fake_validate_url(url: str, allow_internal: bool = False) -> bool: + called["validate"] = True + assert url == "https://example.com/a.jpg" + assert allow_internal is config.allow_internal_urls + return True + + async def fake_to_thread(func, *args, **kwargs): + called["to_thread"] = True + return func(*args, **kwargs) + + client = AsyncMock() + client.request.return_value = _Response() + + monkeypatch.setattr(images, "validate_url", fake_validate_url) + monkeypatch.setattr(images.asyncio, "to_thread", fake_to_thread) + monkeypatch.setattr(images, "get_async_client", lambda **_kwargs: client) + monkeypatch.setattr(images, "resolve_curl_transport", lambda: False) + monkeypatch.setattr(images, "build_request_headers", lambda **_kwargs: {}) + monkeypatch.setattr(images.Globals, "get_random_agent", staticmethod(lambda: "agent")) + monkeypatch.setattr( + images.YTDLPOpts, + "get_instance", + staticmethod( + lambda: type( + "Opts", + (), + { + "preset": lambda self, name: self, + "get_all": lambda self: {}, + }, + )() + ), + ) + + response = await images.get_thumbnail(req, config) + + assert response.status == web.HTTPOk.status_code + assert called["to_thread"] is True + assert called["validate"] is True + client.request.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_thumbnail_validate_rejects(monkeypatch: pytest.MonkeyPatch) -> None: + config = Config.get_instance() + req = make_mocked_request("GET", "/api/thumbnail?url=https://bad.example/a.jpg") + req._rel_url = req._rel_url.with_query({"url": "https://bad.example/a.jpg"}) + + def fake_validate_url(_url: str, allow_internal: bool = False) -> bool: + assert allow_internal is config.allow_internal_urls + raise ValueError("Invalid hostname.") + + async def fake_to_thread(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(images, "validate_url", fake_validate_url) + monkeypatch.setattr(images.asyncio, "to_thread", fake_to_thread) + + response = await images.get_thumbnail(req, config) + + assert response.status == web.HTTPForbidden.status_code + assert response.text == '{"error": "Invalid hostname."}'