From d55e42fbc68494aa5791297c8f0e2da60c0cb896 Mon Sep 17 00:00:00 2001 From: CaliBrain Date: Wed, 17 Jun 2026 13:46:51 -0400 Subject: [PATCH] Add torrent fix for untrusted URL (#1071) --- shelfmark/download/clients/torrent_utils.py | 21 ++++--- tests/prowlarr/test_torrent_utils.py | 68 ++++++++++++++++++--- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/shelfmark/download/clients/torrent_utils.py b/shelfmark/download/clients/torrent_utils.py index 3478556..fe3431e 100644 --- a/shelfmark/download/clients/torrent_utils.py +++ b/shelfmark/download/clients/torrent_utils.py @@ -95,15 +95,22 @@ def extract_torrent_info( # Not a magnet - try to fetch and parse the .torrent file if not fetch_torrent: return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False) - if not _is_trusted_torrent_fetch_url(url): - logger.debug("Skipping torrent prefetch for untrusted URL: %s...", url[:80]) - return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False) + + # A release source can legitimately hand us a download URL on a different + # origin than the configured Prowlarr/Newznab endpoint (e.g. a direct + # tracker link, or Prowlarr reached through a separate proxy). We still need + # to fetch the .torrent to recover the info_hash when the source did not + # provide one, so the prefetch runs regardless of origin. The Prowlarr API + # key, however, is only ever sent to a trusted origin so it can never leak + # to an arbitrary indexer/tracker host. + trusted_origin = _is_trusted_torrent_fetch_url(url) headers: dict[str, str] = {"Accept": "application/x-bittorrent"} - # TODO(shelfmark): Move this source-specific Prowlarr auth handling into a source hook. - api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip() - if api_key: - headers["X-Api-Key"] = api_key + if trusted_origin: + # TODO(shelfmark): Move this source-specific Prowlarr auth handling into a source hook. + api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip() + if api_key: + headers["X-Api-Key"] = api_key def resolve_url(current: str, location: str) -> str: if not location: diff --git a/tests/prowlarr/test_torrent_utils.py b/tests/prowlarr/test_torrent_utils.py index 42dea44..5a9ba99 100644 --- a/tests/prowlarr/test_torrent_utils.py +++ b/tests/prowlarr/test_torrent_utils.py @@ -361,26 +361,76 @@ class TestExtractInfoHash: class TestExtractTorrentInfo: """Tests for extracting torrent info from user-supplied URLs.""" - def test_does_not_fetch_untrusted_http_torrent_url(self, monkeypatch): - """Arbitrary HTTP torrent URLs are passed through without backend prefetch.""" - expected_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" + def test_fetches_untrusted_torrent_url_to_recover_missing_hash(self, monkeypatch): + """Regression for #1012. + + A download URL on a non-Prowlarr origin (e.g. a direct tracker link, or + Prowlarr reached through a separate proxy) must still be prefetched so + the info_hash can be recovered when the feed did not provide one. + Otherwise qBittorrent fails with "Could not determine torrent hash from + URL". + """ + info_dict = { + b"name": b"book.txt", + b"length": 100, + b"piece length": 16384, + b"pieces": b"\x00" * 20, + } + torrent_data = bencode_encode({b"info": info_dict}) + expected_hash = hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower() + + config_values = {"PROWLARR_URL": "https://prowlarr.example"} monkeypatch.setattr( "shelfmark.download.clients.torrent_utils.config.get", - lambda key, default="": "", + lambda key, default="": config_values.get(key, default), ) - mock_get = MagicMock() + response = MagicMock(status_code=200, content=torrent_data) + response.raise_for_status = MagicMock() + mock_get = MagicMock(return_value=response) monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get) + # No expected_hash supplied: the hash can only come from the prefetch. result = extract_torrent_info( - "https://attacker.example/book.torrent", + "https://tracker.example/download/book.torrent", fetch_torrent=True, - expected_hash=expected_hash, ) assert result.info_hash == expected_hash - assert result.torrent_data is None + assert result.torrent_data == torrent_data assert result.is_magnet is False - mock_get.assert_not_called() + mock_get.assert_called_once() + + def test_does_not_send_api_key_to_untrusted_torrent_url(self, monkeypatch): + """The Prowlarr API key is never sent to a download URL on an untrusted origin.""" + info_dict = { + b"name": b"book.txt", + b"length": 100, + b"piece length": 16384, + b"pieces": b"\x00" * 20, + } + torrent_data = bencode_encode({b"info": info_dict}) + + config_values = { + "PROWLARR_URL": "https://prowlarr.example", + "PROWLARR_API_KEY": "secret", + } + monkeypatch.setattr( + "shelfmark.download.clients.torrent_utils.config.get", + lambda key, default="": config_values.get(key, default), + ) + response = MagicMock(status_code=200, content=torrent_data) + response.raise_for_status = MagicMock() + mock_get = MagicMock(return_value=response) + monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get) + + extract_torrent_info( + "https://attacker.example/book.torrent", + fetch_torrent=True, + ) + + mock_get.assert_called_once() + sent_headers = mock_get.call_args.kwargs.get("headers", {}) + assert "X-Api-Key" not in sent_headers def test_fetches_configured_prowlarr_torrent_url(self, monkeypatch): """Configured Prowlarr download URLs can still be prefetched and parsed."""