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 <noreply@anthropic.com>
This commit is contained in:
HeihoffJ
2026-10-02 22:25:36 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8392b43dd3
commit 95d9f1da57
4 changed files with 88 additions and 2 deletions
+1
View File
@@ -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]
+35 -2
View File
@@ -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
+41
View File
@@ -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()."""
Generated
+11
View File
@@ -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'" },