mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-15 10:08:30 +00:00
Code scanning alert 106 (stack trace exposure on the release search 503) turned out to be worth a look. Jackett sends its API key as `?apikey=` in the query string, and when Jackett is unreachable the source builds its "unavailable" message from the requests exception, whose text includes the full request URL. `/api/releases` returns that message to whoever ran the search, so any signed-in user could read the Jackett key while Jackett was down. I reproduced it with Jackett pointed at a closed port: the 503 body had `apikey=<the key>` in it. Jackett was added in #1521 and isn't in a release yet, so nothing shipped with this. `SourceUnavailableError` now masks credentials in its message with the same `redact_secrets` the log formatter uses (query parameters like `apikey=`, `user:pass@` in URLs and so on). I did it in the exception rather than at the two Jackett sites that build the text, since its message is written for the user and any other source that ends up with a key in a URL would hit the same thing. The rest of the message stays readable, for example "Max retries exceeded with url: ...?apikey=(removed)&t=book". Prowlarr and slskd send their keys in headers so they didn't leak a key, but their messages do include the internal host and port. I left that alone here. Tests: two Jackett ones (a connection error on the search and on the indexer lookup, with the key in the exception text) and three on the exception itself. The four that check masking fail on current main. The full suite, ruff and basedpyright are clean.
305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""Unit tests for the Jackett release source, client and handler."""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from shelfmark.core.config import config
|
|
from shelfmark.core.models import DownloadTask
|
|
from shelfmark.core.search_plan import ReleaseSearchPlan, ReleaseSearchVariant
|
|
from shelfmark.metadata_providers import BookMetadata
|
|
from shelfmark.release_sources import ReleaseProtocol, SourceUnavailableError
|
|
from shelfmark.release_sources.jackett.api import JackettClient, JackettSearchError
|
|
from shelfmark.release_sources.jackett.handler import JackettHandler
|
|
from shelfmark.release_sources.jackett.source import JackettSource
|
|
from shelfmark.release_sources.prowlarr.cache import cache_release, remove_release
|
|
|
|
TORZNAB_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss version="2.0" xmlns:torznab="http://torznab.com/schemas/2015/feed">
|
|
<channel>
|
|
<item>
|
|
<title>Frank Herbert - Dune.epub</title>
|
|
<guid>https://tracker.example/details/42</guid>
|
|
<jackettindexer id="mytracker">My Tracker</jackettindexer>
|
|
<link>http://jackett:9117/dl/mytracker/?jackett_apikey=k&path=abc</link>
|
|
<size>1048576</size>
|
|
<enclosure url="http://jackett:9117/dl/mytracker/?path=abc" length="1048576" type="application/x-bittorrent"/>
|
|
<category>7020</category>
|
|
<torznab:attr name="seeders" value="12"/>
|
|
<torznab:attr name="peers" value="15"/>
|
|
<torznab:attr name="magneturl" value="magnet:?xt=urn:btih:abc"/>
|
|
<torznab:attr name="infohash" value="abc"/>
|
|
</item>
|
|
</channel>
|
|
</rss>"""
|
|
|
|
INDEXERS_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<indexers>
|
|
<indexer id="a" configured="true"><title>A</title></indexer>
|
|
<indexer id="b" configured="false"><title>B</title></indexer>
|
|
</indexers>"""
|
|
|
|
ERROR_XML = '<error code="100" description="Invalid API Key" />'
|
|
|
|
|
|
def _response(text: str = "", status: int = 200, json_data=None) -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.text = text
|
|
resp.ok = status < 400
|
|
resp.status_code = status
|
|
resp.json.return_value = json_data
|
|
if status >= 400:
|
|
resp.raise_for_status.side_effect = requests.exceptions.HTTPError(response=resp)
|
|
return resp
|
|
|
|
|
|
def _client() -> JackettClient:
|
|
return JackettClient("http://jackett:9117", "key", indexer_timeout=30)
|
|
|
|
|
|
def _book() -> BookMetadata:
|
|
return BookMetadata(
|
|
provider="hardcover", provider_id="1", title="Dune", authors=["Frank Herbert"]
|
|
)
|
|
|
|
|
|
def _plan(**kwargs) -> ReleaseSearchPlan:
|
|
variants = [ReleaseSearchVariant(title="Dune", author="Frank Herbert")]
|
|
return ReleaseSearchPlan(
|
|
languages=None,
|
|
isbn_candidates=[],
|
|
author="Frank Herbert",
|
|
title_variants=variants,
|
|
grouped_title_variants=variants,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def jackett_config(monkeypatch):
|
|
values = {
|
|
"JACKETT_ENABLED": True,
|
|
"JACKETT_URL": "http://jackett:9117",
|
|
"JACKETT_API_KEY": "key",
|
|
}
|
|
monkeypatch.setattr(config, "get", lambda key, default=None: values.get(key, default))
|
|
return values
|
|
|
|
|
|
class TestClient:
|
|
def test_search_parses_jackett_items(self):
|
|
client = _client()
|
|
with patch.object(client._session, "get", return_value=_response(TORZNAB_XML)) as get:
|
|
results = client.torznab_search(query="Dune", categories=[7000])
|
|
|
|
url = get.call_args.args[0]
|
|
assert url == "http://jackett:9117/api/v2.0/indexers/all/results/torznab/api"
|
|
params = get.call_args.kwargs["params"]
|
|
assert params["apikey"] == "key"
|
|
assert params["cat"] == "7000"
|
|
assert results[0]["indexer"] == "My Tracker"
|
|
assert results[0]["indexerKey"] == "mytracker"
|
|
assert results[0]["protocol"] == "torrent"
|
|
|
|
def test_error_document_raises(self):
|
|
client = _client()
|
|
with (
|
|
patch.object(client._session, "get", return_value=_response(ERROR_XML)),
|
|
pytest.raises(JackettSearchError, match="Invalid API Key"),
|
|
):
|
|
client.torznab_search(query="Dune")
|
|
|
|
def test_timeout_raises_search_error(self):
|
|
client = _client()
|
|
with (
|
|
patch.object(client._session, "get", side_effect=requests.exceptions.Timeout()),
|
|
pytest.raises(JackettSearchError, match="did not respond"),
|
|
):
|
|
client.torznab_search(query="Dune")
|
|
|
|
def test_connection_reports_invalid_key(self):
|
|
client = _client()
|
|
with patch.object(client._session, "get", return_value=_response(ERROR_XML)):
|
|
assert client.test_connection() == (False, "Invalid API key")
|
|
|
|
def test_connection_success_counts_indexers(self):
|
|
client = _client()
|
|
responses = [
|
|
_response("<caps/>"),
|
|
_response(INDEXERS_XML),
|
|
]
|
|
with patch.object(client._session, "get", side_effect=responses):
|
|
ok, message = client.test_connection()
|
|
assert ok
|
|
assert "1 indexers" in message
|
|
|
|
def test_get_indexers_uses_torznab_listing_and_skips_unconfigured(self):
|
|
client = _client()
|
|
with patch.object(client._session, "get", return_value=_response(INDEXERS_XML)) as get:
|
|
indexers = client.get_indexers()
|
|
|
|
assert get.call_args.kwargs["params"]["t"] == "indexers"
|
|
assert "/api/v2.0/indexers/all/results/torznab/api" in get.call_args.args[0]
|
|
assert indexers == [{"id": "a", "name": "A"}]
|
|
|
|
def test_get_indexers_raises_on_non_xml_when_requested(self):
|
|
client = _client()
|
|
with (
|
|
patch.object(client._session, "get", return_value=_response("<html>login")),
|
|
pytest.raises(ValueError, match="Unexpected"),
|
|
):
|
|
client.get_indexers(raise_on_error=True)
|
|
|
|
|
|
class TestSource:
|
|
def test_search_builds_namespaced_torrent_release(self, jackett_config):
|
|
client = MagicMock()
|
|
client.indexer_timeout = 30
|
|
client.torznab_search.return_value = _parsed_results()
|
|
with patch.object(JackettSource, "_get_client", return_value=client):
|
|
releases = JackettSource().search(_book(), _plan())
|
|
|
|
assert len(releases) == 1
|
|
release = releases[0]
|
|
assert release.source == "jackett"
|
|
assert release.source_id == "jackett:mytracker:https://tracker.example/details/42"
|
|
assert release.protocol == ReleaseProtocol.TORRENT
|
|
assert release.indexer == "My Tracker"
|
|
assert release.peers == "12 / 3"
|
|
# Searches the aggregate when no indexers are selected.
|
|
assert client.torznab_search.call_args.kwargs["indexer_id"] == "all"
|
|
remove_release(release.source_id)
|
|
|
|
def test_selected_indexers_are_searched_individually(self, jackett_config):
|
|
jackett_config["JACKETT_INDEXERS"] = ["a", "b"]
|
|
client = MagicMock()
|
|
client.indexer_timeout = 30
|
|
client.torznab_search.return_value = []
|
|
with patch.object(JackettSource, "_get_client", return_value=client):
|
|
JackettSource().search(_book(), _plan())
|
|
|
|
searched = [c.kwargs["indexer_id"] for c in client.torznab_search.call_args_list]
|
|
assert searched == ["a", "b"]
|
|
|
|
def test_filter_names_resolve_to_ids(self, jackett_config):
|
|
client = MagicMock()
|
|
client.indexer_timeout = 30
|
|
client.get_indexers.return_value = [{"id": "a", "name": "Alpha"}]
|
|
client.torznab_search.return_value = []
|
|
with patch.object(JackettSource, "_get_client", return_value=client):
|
|
JackettSource().search(_book(), _plan(indexers=["Alpha"]))
|
|
|
|
assert client.torznab_search.call_args.kwargs["indexer_id"] == "a"
|
|
|
|
def test_filter_matching_no_indexers_searches_nothing(self, jackett_config):
|
|
client = MagicMock()
|
|
client.indexer_timeout = 30
|
|
client.get_indexers.return_value = [{"id": "a", "name": "Alpha"}]
|
|
with patch.object(JackettSource, "_get_client", return_value=client):
|
|
results = JackettSource().search(_book(), _plan(indexers=["Gone"]))
|
|
|
|
assert results == []
|
|
client.torznab_search.assert_not_called()
|
|
|
|
def test_all_failures_raise_instead_of_returning_empty(self, jackett_config):
|
|
client = MagicMock()
|
|
client.indexer_timeout = 30
|
|
client.torznab_search.side_effect = JackettSearchError("boom")
|
|
with (
|
|
patch.object(JackettSource, "_get_client", return_value=client),
|
|
pytest.raises(SourceUnavailableError),
|
|
):
|
|
JackettSource().search(_book(), _plan())
|
|
|
|
def test_an_unreachable_jackett_does_not_put_its_api_key_in_the_error(self, jackett_config):
|
|
# requests puts the request URL, apikey included, in a connection error's text.
|
|
client = JackettClient("http://jackett:9117", "SECRETKEY123")
|
|
error = requests.exceptions.ConnectionError(
|
|
"HTTPConnectionPool(host='jackett', port=9117): Max retries exceeded with url: "
|
|
"/api/v2.0/indexers/all/results/torznab/api?apikey=SECRETKEY123&t=book&q=dune"
|
|
)
|
|
with (
|
|
patch.object(JackettSource, "_get_client", return_value=client),
|
|
patch.object(client._session, "get", side_effect=error),
|
|
pytest.raises(SourceUnavailableError) as raised,
|
|
):
|
|
JackettSource().search(_book(), _plan())
|
|
|
|
assert "SECRETKEY123" not in str(raised.value)
|
|
assert "Max retries exceeded" in str(raised.value)
|
|
|
|
def test_the_indexer_lookup_error_does_not_put_the_api_key_in_the_message(self, jackett_config):
|
|
client = JackettClient("http://jackett:9117", "SECRETKEY123")
|
|
error = requests.exceptions.ConnectionError(
|
|
"HTTPConnectionPool(host='jackett', port=9117): Max retries exceeded with url: "
|
|
"/api/v2.0/indexers/all/results/torznab/api?t=indexers&configured=true"
|
|
"&apikey=SECRETKEY123"
|
|
)
|
|
with (
|
|
patch.object(JackettSource, "_get_client", return_value=client),
|
|
patch.object(client._session, "get", side_effect=error),
|
|
pytest.raises(SourceUnavailableError) as raised,
|
|
):
|
|
JackettSource().search(_book(), _plan(indexers=["Alpha"]))
|
|
|
|
assert "SECRETKEY123" not in str(raised.value)
|
|
assert str(raised.value).startswith("could not reach Jackett")
|
|
|
|
def test_auto_expand_retries_without_categories(self, jackett_config):
|
|
jackett_config["JACKETT_AUTO_EXPAND"] = True
|
|
client = MagicMock()
|
|
client.indexer_timeout = 30
|
|
client.torznab_search.return_value = []
|
|
with patch.object(JackettSource, "_get_client", return_value=client):
|
|
JackettSource().search(_book(), _plan())
|
|
|
|
cats = [c.kwargs["categories"] for c in client.torznab_search.call_args_list]
|
|
assert cats == [[7000], None]
|
|
|
|
def test_is_available_requires_enabled_url_and_key(self, jackett_config):
|
|
assert JackettSource().is_available()
|
|
jackett_config["JACKETT_API_KEY"] = ""
|
|
assert not JackettSource().is_available()
|
|
|
|
|
|
def _parsed_results() -> list[dict]:
|
|
from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml
|
|
|
|
return parse_torznab_xml(TORZNAB_XML)
|
|
|
|
|
|
class TestHandler:
|
|
def _task(self) -> DownloadTask:
|
|
return DownloadTask(task_id="jackett:mytracker:1", source="jackett", title="Dune")
|
|
|
|
def test_resolves_magnet_for_torrent(self):
|
|
result = {**_parsed_results()[0], "magnetUrl": "magnet:?xt=urn:btih:abc"}
|
|
cache_release("jackett:mytracker:1", result)
|
|
status = MagicMock()
|
|
with patch(
|
|
"shelfmark.release_sources.jackett.handler.client_prefers_torrent_file",
|
|
return_value=False,
|
|
):
|
|
request = JackettHandler()._resolve_download(self._task(), status)
|
|
remove_release("jackett:mytracker:1")
|
|
|
|
assert request is not None
|
|
assert request.protocol == "torrent"
|
|
assert request.url.startswith("magnet:")
|
|
assert request.expected_hash == "abc"
|
|
|
|
def test_cache_miss_reports_error(self):
|
|
status = MagicMock()
|
|
assert JackettHandler()._resolve_download(self._task(), status) is None
|
|
status.assert_called_once()
|
|
assert status.call_args.args[0] == "error"
|
|
|
|
def test_cache_miss_restores_from_retry_fields(self):
|
|
task = self._task()
|
|
task.retry_download_url = "http://jackett:9117/dl/mytracker/?path=abc"
|
|
task.retry_download_protocol = "torrent"
|
|
request = JackettHandler()._resolve_download(task, MagicMock())
|
|
assert request is not None
|
|
assert request.url == task.retry_download_url
|