From 95d9f1da5751c3f1c562e582936a8762070d33b4 Mon Sep 17 00:00:00 2001 From: HeihoffJ <31669146+HeihoffJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:25:36 +0200 Subject: [PATCH] fix(sabnzbd): prefetch NZBs served from the indexer's own domain (#1416) Indexers often serve NZB downloads from a different host than their API (e.g. file.indexer.example for indexer.example/api). The exact-origin check added in #967 rejected those, so SABnzbd silently fell back to addurl. Trust any host in the configured indexer's registrable domain, using the Public Suffix List so shared suffixes like co.uk or duckdns.org never widen trust. Scheme and port must still match, IP literals and single-label hosts still require an exact match, and the Prowlarr API key header is unchanged. This closes this issue: https://github.com/calibrain/shelfmark/issues/1411 Disclaimer: Implemented by Claude and Co-Authored by me. Tested and verified by me alone (Why is it always this way around and not the other) Co-authored-by: Claude Opus 5.5 --- pyproject.toml | 1 + shelfmark/download/clients/sabnzbd.py | 37 ++++++++++++++++++++++-- tests/prowlarr/test_sabnzbd_client.py | 41 +++++++++++++++++++++++++++ uv.lock | 11 +++++++ 4 files changed, 88 insertions(+), 2 deletions(-) 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'" },