diff --git a/pyproject.toml b/pyproject.toml index 60702ffb..77fe1c16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ # HTTP/2 client for RFC 8484 DoH: quad9 rejects HTTP/1.1 outright (505), which # requests cannot speak. See shelfmark/download/doh_wireformat.py. "httpx[http2]>=0.28.1", + "publicsuffixlist>=1.0.2.20260925", ] [project.optional-dependencies] diff --git a/shelfmark/download/clients/sabnzbd.py b/shelfmark/download/clients/sabnzbd.py index 73f02923..5d9b6ebc 100644 --- a/shelfmark/download/clients/sabnzbd.py +++ b/shelfmark/download/clients/sabnzbd.py @@ -3,10 +3,12 @@ Uses SABnzbd's REST API directly via requests (no external dependency). """ +import ipaddress from typing import Any from urllib.parse import urlparse import requests +from publicsuffixlist import PublicSuffixList from shelfmark.core.config import config from shelfmark.core.logger import setup_logger @@ -31,6 +33,7 @@ _SABNZBD_CLIENT_ERRORS = ( ValueError, ) _SabnzbdRequestParam = str | int | float | bool +_PUBLIC_SUFFIXES = PublicSuffixList() def _url_origin(value: str) -> tuple[str, str, int] | None: @@ -51,6 +54,36 @@ def _url_origin(value: str) -> tuple[str, str, int] | None: return scheme, hostname, port +def _origin_is_trusted( + target: tuple[str, str, int], + trusted: tuple[str, str, int] | None, +) -> bool: + """Match the trusted origin exactly, or any host in its registrable domain on the same scheme/port. + + Indexers commonly serve NZB downloads from a different host in the same domain + as their API (e.g. dl.indexer.example for api.indexer.example). The Public Suffix + List keeps this from widening to shared suffixes such as co.uk or duckdns.org. + """ + if trusted is None: + return False + if target == trusted: + return True + + target_scheme, target_host, target_port = target + trusted_scheme, trusted_host, trusted_port = trusted + if (target_scheme, target_port) != (trusted_scheme, trusted_port): + return False + + try: + ipaddress.ip_address(trusted_host) + except ValueError: + trusted_domain = _PUBLIC_SUFFIXES.privatesuffix(trusted_host) + return trusted_domain is not None and ( + _PUBLIC_SUFFIXES.privatesuffix(target_host) == trusted_domain + ) + return False + + def _parse_eta(eta_str: str) -> int | None: """Parse SABnzbd ETA string (format: 'H:MM:SS') to seconds.""" if not eta_str or eta_str == "0:00:00": @@ -245,7 +278,7 @@ class SABnzbdClient(DownloadClient): for key in ("PROWLARR_URL", "NEWZNAB_URL"): trusted_url = normalize_http_config_url(config.get(key, "")) - if trusted_url and _url_origin(trusted_url) == target_origin: + if trusted_url and _origin_is_trusted(target_origin, _url_origin(trusted_url)): return True named_indexers = config.get("NEWZNAB_INDEXERS", []) @@ -254,7 +287,7 @@ class SABnzbdClient(DownloadClient): if not isinstance(row, dict): continue trusted_url = normalize_http_config_url(row.get("url")) - if trusted_url and _url_origin(trusted_url) == target_origin: + if trusted_url and _origin_is_trusted(target_origin, _url_origin(trusted_url)): return True return False diff --git a/tests/prowlarr/test_sabnzbd_client.py b/tests/prowlarr/test_sabnzbd_client.py index 7f6c1e22..c087679f 100644 --- a/tests/prowlarr/test_sabnzbd_client.py +++ b/tests/prowlarr/test_sabnzbd_client.py @@ -688,6 +688,47 @@ class TestSABnzbdClientAddDownload: assert result == "SABnzbd_nzo_named" fetch.assert_called_once_with("https://geek.example/download.nzb?apikey=secret") + @pytest.mark.parametrize( + ("indexer_url", "nzb_url", "expected"), + [ + ("https://indexer.example.com", "https://indexer.example.com/get.nzb", True), + ("https://indexer.example.com", "https://file.indexer.example.com/get.nzb", True), + ("https://indexer.example.com", "https://a.b.indexer.example.com/get.nzb", True), + ("https://indexer.example.com/api", "https://FILE.Indexer.Example.com/x", True), + # Siblings and the parent share the registrable domain. + ("https://api.example.com", "https://file.example.com/get.nzb", True), + ("https://indexer.example.com", "https://example.com/get.nzb", True), + ("https://api.indexer.co.uk", "https://dl.indexer.co.uk/get.nzb", True), + # Other registrable domains stay untrusted, including under shared suffixes. + ("https://indexer.example.com", "https://indexer.example.com.evil.test/x", False), + ("https://indexer.example.com", "https://example.net/get.nzb", False), + ("https://indexer.co.uk", "https://evil.co.uk/get.nzb", False), + ("https://mine.duckdns.org", "https://evil.duckdns.org/get.nzb", False), + ("http://prowlarr:9696", "http://other:9696/get.nzb", False), + # Scheme and port must still match. + ("https://indexer.example.com", "http://file.indexer.example.com/get.nzb", False), + ("https://indexer.example.com", "https://file.indexer.example.com:8443/x", False), + # IP literals never extend to "subdomains". + ("http://10.0.0.5:9696", "http://10.0.0.5:9696/get.nzb", True), + ("http://10.0.0.5:9696", "http://x.10.0.0.5:9696/get.nzb", False), + ], + ) + def test_can_prefetch_nzb_url_same_domain(self, monkeypatch, indexer_url, nzb_url, expected): + """NZB downloads served from the configured indexer's domain are prefetched.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "NEWZNAB_INDEXERS": [{"name": "Indexer", "url": indexer_url, "api_key": "k"}], + } + monkeypatch.setattr( + "shelfmark.download.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + from shelfmark.download.clients.sabnzbd import SABnzbdClient + + assert SABnzbdClient()._can_prefetch_nzb_url(nzb_url) is expected + class TestSABnzbdClientRemove: """Tests for SABnzbdClient.remove().""" diff --git a/uv.lock b/uv.lock index 83ba3c06..190c8c3e 100644 --- a/uv.lock +++ b/uv.lock @@ -965,6 +965,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "publicsuffixlist" +version = "1.0.2.20260925" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/7f/3ed941493eb0be191cfafecd4fd1d8a1206b055b88308d50893d60ea460e/publicsuffixlist-1.0.2.20260925.tar.gz", hash = "sha256:2bacd61c8b361ff3faa11f984a3554f2a3047327909467cb9cbd3a129d8065f8", size = 109293, upload-time = "2026-09-25T08:44:22.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/87/9e56f9cef465ad5a014acbe6b3f2a48b30b2a84f62b10dcfdbdb419bc3cc/publicsuffixlist-1.0.2.20260925-py2.py3-none-any.whl", hash = "sha256:f99c707b3f2fdc386b64e01fc4eca3a12631a677c43b62283aa0255fa7ea0412", size = 108980, upload-time = "2026-09-25T08:44:21.681Z" }, +] + [[package]] name = "pyautogui" version = "0.9.54" @@ -1517,6 +1526,7 @@ dependencies = [ { name = "gunicorn" }, { name = "httpx", extra = ["http2"] }, { name = "psutil" }, + { name = "publicsuffixlist" }, { name = "python-socketio" }, { name = "qbittorrent-api" }, { name = "rarfile" }, @@ -1560,6 +1570,7 @@ requires-dist = [ { name = "gunicorn" }, { name = "httpx", extras = ["http2"], specifier = ">=0.28.1" }, { name = "psutil" }, + { name = "publicsuffixlist", specifier = ">=1.0.2.20260925" }, { name = "pyautogui", marker = "extra == 'browser'" }, { name = "python-socketio" }, { name = "python-xlib", marker = "extra == 'browser'" },