mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
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:
co-authored by
Claude Opus 5.5
parent
8392b43dd3
commit
95d9f1da57
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()."""
|
||||
|
||||
@@ -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'" },
|
||||
|
||||
Reference in New Issue
Block a user