From 7b5313e97e0ed48fa54e89aa08df28ba5c6d4ff4 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Thu, 2 Jul 2026 11:43:08 +0200 Subject: [PATCH] Update detection --- .env.example | 9 + app/Http/Middleware/BlockAbusiveServices.php | 68 ++++ .../Security/IndexerProxyDetector.php | 268 ++++++++++++++ app/Services/Security/ProxyRequestContext.php | 64 ++++ app/Services/Security/ProxyVerdict.php | 30 ++ config/nntmux.php | 10 + .../Middleware/BlockAbusiveServicesTest.php | 2 +- tests/Unit/BlockAbusiveServicesTest.php | 103 +++++- tests/Unit/IndexerProxyDetectorTest.php | 347 ++++++++++++++++++ 9 files changed, 896 insertions(+), 5 deletions(-) create mode 100644 app/Services/Security/IndexerProxyDetector.php create mode 100644 app/Services/Security/ProxyRequestContext.php create mode 100644 app/Services/Security/ProxyVerdict.php create mode 100644 tests/Unit/IndexerProxyDetectorTest.php diff --git a/.env.example b/.env.example index bd61f8d25..5231d0343 100644 --- a/.env.example +++ b/.env.example @@ -113,6 +113,15 @@ CLOUDFLARE_TRUST_REMOTE_ADDR_FALLBACK=true BLOCK_PROXY_INDEXER_APPS=false BLOCK_PROXY_INDEXER_APP_USER_AGENTS=Prowlarr/,NZBHydra2 +# Behavioural detection of direct proxy fetches that spoof a downloader UA (combines Referer, UA-pair, +# download/search ratio, and IP-correlation signals into a score gated by the threshold). +PROXY_DETECTION_ENABLED=false +PROXY_DETECTION_THRESHOLD=50 +PROXY_DETECTION_WINDOW_SECONDS=3600 +PROXY_DETECTION_RATIO_MIN=0.8 +PROXY_DETECTION_MIN_SEARCHES=20 +PROXY_DETECTION_INDEXER_REFERER_PATTERNS=hydra,prowlarr,jackett,lidarr,radarr,sonarr,readarr,bazarr + APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US diff --git a/app/Http/Middleware/BlockAbusiveServices.php b/app/Http/Middleware/BlockAbusiveServices.php index 548b8b4c2..bdaea7ee4 100644 --- a/app/Http/Middleware/BlockAbusiveServices.php +++ b/app/Http/Middleware/BlockAbusiveServices.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Http\Middleware; +use App\Services\Security\IndexerProxyDetector; +use App\Services\Security\ProxyRequestContext; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -65,6 +67,8 @@ class BlockAbusiveServices */ protected int $cacheTtl = 86400; + public function __construct(private readonly IndexerProxyDetector $detector) {} + /** * Handle an incoming request. * @@ -86,6 +90,35 @@ class BlockAbusiveServices return $this->blockedResponse('Access denied: Streaming services are not allowed.'); } + // Behavioural proxy-fetch detection. Downloads are scored against the + // sliding-window signals; searches/RSS feed those windows so a later + // download from the same token/IP can be correlated. Redirected grabs + // score zero and pass through. + $isDownload = $this->isNzbDownloadRequest($request); + $isSearch = $this->isSearchRequest($request); + + if ($isDownload || $isSearch) { + $ctx = ProxyRequestContext::fromRequest($request, $isDownload, $isSearch); + + if ($isDownload) { + $verdict = $this->detector->analyze($ctx); + + if ($verdict->shouldBlock) { + Log::warning('Blocked proxied NZB download (behavioural)', [ + 'ip' => $ip, + 'user_agent' => $userAgent, + 'score' => $verdict->score, + 'reasons' => $verdict->reasons, + 'uri' => $this->safeRequestUri($request), + ]); + + return $this->blockedResponse('Access denied: Proxying NZB downloads through indexer apps is not allowed.'); + } + } else { + $this->detector->recordSearch($ctx); + } + } + if ($this->shouldBlockProxyIndexerApp($request, $userAgent)) { Log::warning('Blocked proxied NZB download from indexer app', [ 'ip' => $ip, @@ -173,6 +206,41 @@ class BlockAbusiveServices return in_array($type, ['get', 'g'], true); } + /** + * Detect search / caps / RSS requests that should feed the correlation + * windows. These populate the per-token and per-IP signals so a later + * download from the same client can be scored. + */ + protected function isSearchRequest(Request $request): bool + { + // RSS feeds are search-like: a client discovering releases to grab. + if ($request->is('rss/*')) { + return true; + } + + // v2 REST search/caps endpoints. + if ($request->is( + 'api/v2/search', + 'api/v2/movies', + 'api/v2/audio', + 'api/v2/books', + 'api/v2/anime', + 'api/v2/tv', + 'api/v2/capabilities', + )) { + return true; + } + + // v1 newznab: t= identifies the operation. + if (! $request->is('api/v1/api')) { + return false; + } + + $type = strtolower(trim((string) $request->input('t', ''))); + + return in_array($type, ['search', 'tvsearch', 'movie', 'music', 'book', 'caps'], true); + } + /** * @return array */ diff --git a/app/Services/Security/IndexerProxyDetector.php b/app/Services/Security/IndexerProxyDetector.php new file mode 100644 index 000000000..9e38974db --- /dev/null +++ b/app/Services/Security/IndexerProxyDetector.php @@ -0,0 +1,268 @@ +enabled()) { + return ProxyVerdict::allow(); + } + + $score = 0; + $reasons = []; + + // 1. Referer — indexers leak their own host (e.g. http://hydra.local:5076/...). + if ($this->refererLooksLikeIndexer($ctx->referer)) { + $score += 30; + $reasons['referer'] = 30; + } + + // 2. UA-pair — the same api_token recently searched with this exact UA. + // Same UA on search + download is the proxy fingerprint; a redirected + // grab swaps to the download client's UA and never matches. + if ($ctx->apiToken !== null + && in_array($ctx->userAgent, $this->recentUserAgentsForToken($ctx->apiToken), true) + ) { + $score += 25; + $reasons['ua_pair'] = 25; + } + + // 3. Download-to-search ratio — proxies download almost everything they + // search; humans search many and download few. Gated behind a minimum + // search count so a brand-new token can't trip it. + if ($ctx->apiToken !== null) { + $searches = $this->searchCount($ctx->apiToken); + $downloads = $this->downloadCount($ctx->apiToken); + + if ($searches >= $this->minSearches()) { + $ratio = $downloads / max($searches, 1); + + if ($ratio >= $this->ratioMin()) { + $score += 25; + $reasons['download_ratio'] = 25; + } + } + } + + // 4. IP correlation — the same client IP just searched with a known + // indexer UA and is now downloading with that same UA. + if ($this->recentIndexerUserAgentForIp($ctx->clientIp) === $ctx->userAgent) { + $score += 20; + $reasons['ip_correlation'] = 20; + } + + // Record this download so the ratio window stays accurate. + $this->recordDownload($ctx); + + return new ProxyVerdict($score >= $this->threshold(), $score, $reasons); + } + + /** + * Feed the sliding windows from a search/RSS request. + * + * Without this, signals 2, 3 and 4 have no data to correlate against. + */ + public function recordSearch(ProxyRequestContext $ctx): void + { + if (! $this->enabled()) { + return; + } + + $ttl = $this->windowSeconds(); + + if ($ctx->apiToken !== null) { + $this->increment($this->key('count', $ctx->apiToken, 'searches'), $ttl); + $this->pushUserAgentForToken($ctx->apiToken, $ctx->userAgent, $ttl); + } + + // Remember the indexer UA that just searched from this IP so a later + // download from the same IP + UA can be correlated. + if ($ctx->userAgent !== '') { + $this->cache->put($this->key('ip', $ctx->clientIp, 'indexer_ua'), $ctx->userAgent, $ttl); + } + } + + /** + * Bump the per-token download counter for the ratio signal. + */ + public function recordDownload(ProxyRequestContext $ctx): void + { + if (! $this->enabled() || $ctx->apiToken === null) { + return; + } + + $this->increment($this->key('count', $ctx->apiToken, 'downloads'), $this->windowSeconds()); + } + + /** + * @return array + */ + private function recentUserAgentsForToken(string $token): array + { + $value = $this->cache->get($this->key('ua', $token)); + + return is_array($value) ? array_values(array_filter($value, 'is_string')) : []; + } + + private function pushUserAgentForToken(string $token, string $userAgent, int $ttl): void + { + if ($userAgent === '') { + return; + } + + $userAgents = $this->recentUserAgentsForToken($token); + + // Move to front, de-duplicate, cap the list (LPUSH + LTRIM equivalent). + array_unshift($userAgents, $userAgent); + $userAgents = array_values(array_unique($userAgents)); + $userAgents = array_slice($userAgents, 0, self::MAX_TRACKED_USER_AGENTS); + + $this->cache->put($this->key('ua', $token), $userAgents, $ttl); + } + + private function recentIndexerUserAgentForIp(string $ip): ?string + { + if ($ip === '') { + return null; + } + + $value = $this->cache->get($this->key('ip', $ip, 'indexer_ua')); + + return is_string($value) ? $value : null; + } + + private function searchCount(string $token): int + { + return (int) $this->cache->get($this->key('count', $token, 'searches'), 0); + } + + private function downloadCount(string $token): int + { + return (int) $this->cache->get($this->key('count', $token, 'downloads'), 0); + } + + /** + * Increment a counter, seeding it with a TTL on first write so the whole + * window expires together (portable across cache stores). + */ + private function increment(string $key, int $ttl): void + { + if ($this->cache->get($key) === null) { + $this->cache->put($key, 1, $ttl); + + return; + } + + $this->cache->increment($key); + } + + private function refererLooksLikeIndexer(?string $referer): bool + { + if ($referer === null || $referer === '') { + return false; + } + + return $this->matchesAnyPattern($referer, $this->indexerRefererPatterns()); + } + + /** + * @param array $patterns + */ + private function matchesAnyPattern(string $haystack, array $patterns): bool + { + $lower = strtolower($haystack); + + foreach ($patterns as $pattern) { + if ($pattern !== '' && str_contains($lower, strtolower($pattern))) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function indexerRefererPatterns(): array + { + $configured = config('nntmux.proxy_detection_indexer_referer_patterns', ''); + + if (is_array($configured)) { + $patterns = $configured; + } else { + $patterns = preg_split('/[\r\n,]+/', (string) $configured) ?: []; + } + + return array_values(array_filter(array_map( + static fn (mixed $pattern): string => is_string($pattern) ? trim($pattern) : '', + $patterns, + ))); + } + + private function key(string ...$parts): string + { + return self::CACHE_PREFIX.implode(':', array_map( + static fn (string $part): string => str_contains($part, ':') || strlen($part) > 40 + ? md5($part) + : $part, + $parts, + )); + } + + private function enabled(): bool + { + return (bool) config('nntmux.proxy_detection_enabled', false); + } + + private function threshold(): int + { + return (int) config('nntmux.proxy_detection_threshold', 50); + } + + private function windowSeconds(): int + { + return (int) config('nntmux.proxy_detection_window_seconds', 3600); + } + + private function ratioMin(): float + { + return (float) config('nntmux.proxy_detection_ratio_min', 0.8); + } + + private function minSearches(): int + { + return (int) config('nntmux.proxy_detection_min_searches', 20); + } +} diff --git a/app/Services/Security/ProxyRequestContext.php b/app/Services/Security/ProxyRequestContext.php new file mode 100644 index 000000000..2f1289315 --- /dev/null +++ b/app/Services/Security/ProxyRequestContext.php @@ -0,0 +1,64 @@ +ip() ?? ''), + userAgent: $request->userAgent() ?? '', + apiToken: self::extractApiToken($request), + referer: $request->headers->get('referer'), + isDownload: $isDownload, + isSearch: $isSearch, + ); + } + + /** + * Resolve the user token used to correlate searches with downloads. + * + * Newznab (`apikey`), the RSS feeds (`api_token`) and the getnzb RSS token + * (`r`) all identify the same underlying user, so any of them is a valid + * correlation key. + */ + private static function extractApiToken(Request $request): ?string + { + foreach (['api_token', 'apikey', 'apiToken', 'r'] as $key) { + $value = $request->input($key); + + if (is_string($value) && $value !== '') { + return $value; + } + } + + return null; + } +} diff --git a/app/Services/Security/ProxyVerdict.php b/app/Services/Security/ProxyVerdict.php new file mode 100644 index 000000000..7f482a6a8 --- /dev/null +++ b/app/Services/Security/ProxyVerdict.php @@ -0,0 +1,30 @@ + $reasons Per-signal score contributions. + */ +final readonly class ProxyVerdict +{ + /** + * @param array $reasons + */ + public function __construct( + public bool $shouldBlock, + public int $score, + public array $reasons, + ) {} + + /** + * A verdict that never blocks (used when detection is disabled). + */ + public static function allow(): self + { + return new self(false, 0, []); + } +} diff --git a/config/nntmux.php b/config/nntmux.php index d4eef5c59..f0ac23961 100644 --- a/config/nntmux.php +++ b/config/nntmux.php @@ -24,6 +24,16 @@ return [ 'block_proxy_indexer_apps' => (bool) env('BLOCK_PROXY_INDEXER_APPS', false), 'block_proxy_indexer_app_user_agents' => env('BLOCK_PROXY_INDEXER_APP_USER_AGENTS', 'Prowlarr/,NZBHydra2'), + // Behavioural detection of direct proxy fetches (NZBHydra2/Prowlarr) that spoof a downloader UA. + // Combines Referer, UA-pair, download/search ratio, and IP-correlation signals into a score; blocks + // only when the score meets the threshold, so legitimate redirected grabs are left untouched. + 'proxy_detection_enabled' => (bool) env('PROXY_DETECTION_ENABLED', false), + 'proxy_detection_threshold' => (int) env('PROXY_DETECTION_THRESHOLD', 50), + 'proxy_detection_window_seconds' => (int) env('PROXY_DETECTION_WINDOW_SECONDS', 3600), + 'proxy_detection_ratio_min' => (float) env('PROXY_DETECTION_RATIO_MIN', 0.8), + 'proxy_detection_min_searches' => (int) env('PROXY_DETECTION_MIN_SEARCHES', 20), + 'proxy_detection_indexer_referer_patterns' => env('PROXY_DETECTION_INDEXER_REFERER_PATTERNS', 'hydra,prowlarr,jackett,lidarr,radarr,sonarr,readarr,bazarr'), + /* |-------------------------------------------------------------------------- | Release dedupe (import-time) diff --git a/tests/Feature/Http/Middleware/BlockAbusiveServicesTest.php b/tests/Feature/Http/Middleware/BlockAbusiveServicesTest.php index 211ccd2ae..8f520d159 100644 --- a/tests/Feature/Http/Middleware/BlockAbusiveServicesTest.php +++ b/tests/Feature/Http/Middleware/BlockAbusiveServicesTest.php @@ -16,7 +16,7 @@ final class BlockAbusiveServicesTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->middleware = new BlockAbusiveServices; + $this->middleware = app(BlockAbusiveServices::class); Cache::flush(); } diff --git a/tests/Unit/BlockAbusiveServicesTest.php b/tests/Unit/BlockAbusiveServicesTest.php index fc221f49b..66a4a4c91 100644 --- a/tests/Unit/BlockAbusiveServicesTest.php +++ b/tests/Unit/BlockAbusiveServicesTest.php @@ -5,6 +5,9 @@ declare(strict_types=1); namespace Tests\Unit; use App\Http\Middleware\BlockAbusiveServices; +use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\Repository as CacheRepository; +use Illuminate\Contracts\Cache\Repository as CacheRepositoryContract; use Illuminate\Contracts\Console\Kernel; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -136,6 +139,65 @@ class BlockAbusiveServicesTest extends TestCase $this->assertSame(Response::HTTP_OK, $response->getStatusCode()); } + public function test_behavioral_detection_blocks_spoofed_ua_direct_proxy_fetch(): void + { + $this->enableBehavioralDetection(); + + // A direct proxy fetch masquerading as a downloader: the strict UA fast path + // (block_proxy_indexer_apps) is off and SABnzbd is not a blocked UA, so only + // the behavioural signals can catch this. + $spoofedUa = 'SABnzbd/4.3.3'; + $ip = '203.0.113.10'; + + // The proxy searches first with the spoofed UA — this seeds the UA-pair + // window for the api_token and the IP->UA correlation window. + $searchResponse = $this->handleRequest( + '/api/v1/api?t=search&q=linux&apikey=user-key', + $spoofedUa, + ip: $ip, + ); + $this->assertSame(Response::HTTP_OK, $searchResponse->getStatusCode()); + + // Then it downloads with the same UA, same token, same IP, and leaks the + // indexer host in the Referer. Signals: referer (30) + ua_pair (25) + + // ip_correlation (20) = 75 >= 50 threshold. + $downloadResponse = $this->handleRequest( + '/api/v1/api?t=get&id=release-guid&apikey=user-key', + $spoofedUa, + ['Referer' => 'http://hydra.local:5076/nzb/details'], + $ip, + ); + + $this->assertSame(Response::HTTP_FORBIDDEN, $downloadResponse->getStatusCode()); + $this->assertStringContainsString( + 'Proxying NZB downloads through indexer apps is not allowed', + (string) $downloadResponse->getContent(), + ); + } + + public function test_search_routes_record_signals_for_later_correlation(): void + { + $this->enableBehavioralDetection(); + + $spoofedUa = 'SABnzbd/4.3.3'; + $ip = '203.0.113.20'; + $downloadUri = '/api/v1/api?t=get&id=release-guid&apikey=user-key'; + $referer = ['Referer' => 'http://prowlarr.local:9696/download']; + + // Without a prior search, only the Referer signal fires (30 < 50 threshold), + // so the download is allowed — the correlation windows are empty. + $coldResponse = $this->handleRequest($downloadUri, $spoofedUa, $referer, $ip); + $this->assertSame(Response::HTTP_OK, $coldResponse->getStatusCode()); + + // A search from the same token + IP + UA seeds the UA-pair and IP windows. + $this->handleRequest('/api/v1/api?t=search&q=ubuntu&apikey=user-key', $spoofedUa, ip: $ip); + + // Now the same download correlates: referer (30) + ua_pair (25) + + // ip_correlation (20) = 75, and it is blocked. + $warmResponse = $this->handleRequest($downloadUri, $spoofedUa, $referer, $ip); + $this->assertSame(Response::HTTP_FORBIDDEN, $warmResponse->getStatusCode()); + } + public function test_enabled_proxy_indexer_app_block_allows_configured_user_agent_on_unrelated_routes(): void { config()->set('nntmux.block_proxy_indexer_apps', true); @@ -207,16 +269,49 @@ class BlockAbusiveServicesTest extends TestCase $_SERVER[$key] = $value; } - private function handleRequest(string $uri, string $userAgent): Response + /** + * @param array $headers + */ + private function handleRequest(string $uri, string $userAgent, array $headers = [], string $ip = '127.0.0.1'): Response { - $request = Request::create($uri, 'GET', server: [ + $server = [ 'HTTP_USER_AGENT' => $userAgent, - 'REMOTE_ADDR' => '127.0.0.1', - ]); + 'REMOTE_ADDR' => $ip, + ]; + + foreach ($headers as $name => $value) { + $server['HTTP_'.strtoupper(str_replace('-', '_', $name))] = $value; + } + + $request = Request::create($uri, 'GET', server: $server); return app(BlockAbusiveServices::class)->handle( $request, static fn (): Response => response()->json(['ok' => true]) ); } + + /** + * Turn on behavioural proxy detection with a deterministic in-memory cache. + * + * The detector resolves its CacheRepository from the container; binding an + * ArrayStore-backed repository keeps per-token / per-IP windows isolated to the + * test and lets a recorded search feed a later download in the same test run. + * The ASN cache uses the Cache facade (a different store) and is untouched. + */ + private function enableBehavioralDetection(): void + { + config()->set('nntmux.block_proxy_indexer_apps', false); + config()->set('nntmux.proxy_detection_enabled', true); + config()->set('nntmux.proxy_detection_threshold', 50); + config()->set('nntmux.proxy_detection_window_seconds', 3600); + config()->set('nntmux.proxy_detection_ratio_min', 0.8); + config()->set('nntmux.proxy_detection_min_searches', 20); + config()->set('nntmux.proxy_detection_indexer_referer_patterns', 'hydra,prowlarr,jackett'); + + $this->app->instance( + CacheRepositoryContract::class, + new CacheRepository(new ArrayStore) + ); + } } diff --git a/tests/Unit/IndexerProxyDetectorTest.php b/tests/Unit/IndexerProxyDetectorTest.php new file mode 100644 index 000000000..16c0fc6c5 --- /dev/null +++ b/tests/Unit/IndexerProxyDetectorTest.php @@ -0,0 +1,347 @@ + + */ + private array $originalEnvironment = []; + + private IndexerProxyDetector $detector; + + public function createApplication() + { + $this->databasePath = sys_get_temp_dir().'/nntmux-indexer-proxy-detector-test.sqlite'; + + $this->originalEnvironment = [ + 'APP_ENV' => getenv('APP_ENV'), + 'DB_CONNECTION' => getenv('DB_CONNECTION'), + 'DB_DATABASE' => getenv('DB_DATABASE'), + ]; + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + $pdo = new PDO('sqlite:'.$this->databasePath); + $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); + $pdo->exec("INSERT INTO settings (name, value) VALUES + ('categorizeforeign', '0'), + ('catwebdl', '0'), + ('innerfileblacklist', '')"); + + $this->setEnvironmentValue('APP_ENV', 'testing'); + $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); + $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); + + $app = require __DIR__.'/../../bootstrap/app.php'; + + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + + protected function setUp(): void + { + parent::setUp(); + + // Each test gets an isolated in-memory cache so sliding-window state + // never leaks between cases. + $this->detector = new IndexerProxyDetector(new Repository(new ArrayStore)); + + $this->configureDetector(); + } + + protected function tearDown(): void + { + if ($this->databasePath !== '' && file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + parent::tearDown(); + + foreach ($this->originalEnvironment as $key => $value) { + $this->setEnvironmentValue($key, $value === false ? null : $value); + } + } + + public function test_disabled_detector_never_blocks_even_with_strong_signals(): void + { + $this->configureDetector(['enabled' => false]); + + $this->detector->recordSearch($this->searchContext( + apiToken: 'tok', + userAgent: 'NZBHydra2 8.3.0', + clientIp: '10.0.0.1', + )); + + $verdict = $this->detector->analyze($this->downloadContext( + apiToken: 'tok', + userAgent: 'NZBHydra2 8.3.0', + clientIp: '10.0.0.1', + referer: 'http://hydra.local:5076/getnzb/api', + )); + + $this->assertFalse($verdict->shouldBlock); + $this->assertSame(0, $verdict->score); + $this->assertSame([], $verdict->reasons); + } + + public function test_referer_matching_indexer_host_blocks(): void + { + // Isolate the Referer signal: no api_token (skips UA-pair + ratio) and a + // fresh IP (skips IP correlation). + $this->configureDetector(['threshold' => 1]); + + $verdict = $this->detector->analyze($this->downloadContext( + apiToken: null, + userAgent: 'SABnzbd/4.3.3', + clientIp: '10.0.0.10', + referer: 'http://prowlarr.local:9696/1/api', + )); + + $this->assertTrue($verdict->shouldBlock); + $this->assertSame(30, $verdict->score); + $this->assertSame(['referer' => 30], $verdict->reasons); + } + + public function test_referer_absent_does_not_block(): void + { + // Downloaders send no Referer — the redirected-grab shape. + $verdict = $this->detector->analyze($this->downloadContext( + apiToken: null, + userAgent: 'SABnzbd/4.3.3', + clientIp: '10.0.0.11', + referer: null, + )); + + $this->assertFalse($verdict->shouldBlock); + $this->assertSame(0, $verdict->score); + $this->assertSame([], $verdict->reasons); + } + + public function test_same_ua_on_search_then_download_blocks(): void + { + // Isolate the UA-pair signal: search + download share the same token and + // UA, but the download comes from a different IP so IP correlation stays + // silent, and one search is below the ratio minimum. + $this->configureDetector(['threshold' => 1]); + + $this->detector->recordSearch($this->searchContext( + apiToken: 'tok', + userAgent: 'NZBHydra2 8.3.0', + clientIp: '10.0.0.1', + )); + + $verdict = $this->detector->analyze($this->downloadContext( + apiToken: 'tok', + userAgent: 'NZBHydra2 8.3.0', + clientIp: '10.0.0.2', + referer: null, + )); + + $this->assertTrue($verdict->shouldBlock); + $this->assertSame(25, $verdict->score); + $this->assertSame(['ua_pair' => 25], $verdict->reasons); + } + + public function test_different_ua_on_search_then_download_allows(): void + { + // Redirected grab: Prowlarr searches, SABnzbd downloads from another IP. + $this->detector->recordSearch($this->searchContext( + apiToken: 'tok', + userAgent: 'Prowlarr/2.0.0', + clientIp: '10.0.0.1', + )); + + $verdict = $this->detector->analyze($this->downloadContext( + apiToken: 'tok', + userAgent: 'SABnzbd/4.3.3', + clientIp: '10.0.0.2', + referer: null, + )); + + $this->assertFalse($verdict->shouldBlock); + $this->assertSame(0, $verdict->score); + $this->assertSame([], $verdict->reasons); + } + + public function test_high_download_to_search_ratio_blocks(): void + { + // Isolate the ratio signal: keep min-searches small, search/download with a + // different UA and IP from the analyzed download so only the ratio fires. + $this->configureDetector(['threshold' => 1, 'min_searches' => 2]); + + $this->detector->recordSearch($this->searchContext('tok', 'Prowlarr/2.0.0', '10.0.0.1')); + $this->detector->recordSearch($this->searchContext('tok', 'Prowlarr/2.0.0', '10.0.0.1')); + $this->detector->recordDownload($this->downloadContext('tok', 'Prowlarr/2.0.0', '10.0.0.1')); + $this->detector->recordDownload($this->downloadContext('tok', 'Prowlarr/2.0.0', '10.0.0.1')); + + $verdict = $this->detector->analyze($this->downloadContext( + apiToken: 'tok', + userAgent: 'SABnzbd/4.3.3', + clientIp: '10.0.0.9', + referer: null, + )); + + $this->assertTrue($verdict->shouldBlock); + $this->assertSame(25, $verdict->score); + $this->assertSame(['download_ratio' => 25], $verdict->reasons); + } + + public function test_low_ratio_allows(): void + { + // Many searches, one download — the human shape. + $this->configureDetector(['min_searches' => 2]); + + for ($i = 0; $i < 5; $i++) { + $this->detector->recordSearch($this->searchContext('tok', 'Prowlarr/2.0.0', '10.0.0.1')); + } + $this->detector->recordDownload($this->downloadContext('tok', 'Prowlarr/2.0.0', '10.0.0.1')); + + $verdict = $this->detector->analyze($this->downloadContext( + apiToken: 'tok', + userAgent: 'SABnzbd/4.3.3', + clientIp: '10.0.0.9', + referer: null, + )); + + $this->assertFalse($verdict->shouldBlock); + $this->assertSame(0, $verdict->score); + $this->assertSame([], $verdict->reasons); + } + + public function test_ip_correlation_with_indexer_ua_blocks(): void + { + // Isolate the IP-correlation signal: no api_token (skips UA-pair + ratio), + // but the same IP just searched with the same indexer UA. + $this->configureDetector(['threshold' => 1]); + + $this->detector->recordSearch($this->searchContext( + apiToken: null, + userAgent: 'NZBHydra2 8.3.0', + clientIp: '10.0.0.5', + )); + + $verdict = $this->detector->analyze($this->downloadContext( + apiToken: null, + userAgent: 'NZBHydra2 8.3.0', + clientIp: '10.0.0.5', + referer: null, + )); + + $this->assertTrue($verdict->shouldBlock); + $this->assertSame(20, $verdict->score); + $this->assertSame(['ip_correlation' => 20], $verdict->reasons); + } + + public function test_score_below_threshold_allows(): void + { + // A single Referer signal (30) is below the default threshold of 50. + $verdict = $this->detector->analyze($this->downloadContext( + apiToken: null, + userAgent: 'SABnzbd/4.3.3', + clientIp: '10.0.0.12', + referer: 'http://hydra.local:5076/getnzb/api', + )); + + $this->assertFalse($verdict->shouldBlock); + $this->assertSame(30, $verdict->score); + $this->assertSame(['referer' => 30], $verdict->reasons); + } + + public function test_search_recording_feeds_subsequent_download_analysis(): void + { + // Realistic NZBHydra2 direct fetch: one recorded search lets the later + // download correlate on both UA-pair and IP, and the leaked Referer pushes + // the combined score over the default threshold. + $this->detector->recordSearch($this->searchContext( + apiToken: 'tok', + userAgent: 'NZBHydra2 8.3.0', + clientIp: '10.0.0.1', + )); + + $verdict = $this->detector->analyze($this->downloadContext( + apiToken: 'tok', + userAgent: 'NZBHydra2 8.3.0', + clientIp: '10.0.0.1', + referer: 'http://hydra.local:5076/getnzb/api', + )); + + $this->assertTrue($verdict->shouldBlock); + $this->assertSame(75, $verdict->score); + $this->assertSame([ + 'referer' => 30, + 'ua_pair' => 25, + 'ip_correlation' => 20, + ], $verdict->reasons); + } + + /** + * @param array $overrides + */ + private function configureDetector(array $overrides = []): void + { + config()->set('nntmux.proxy_detection_enabled', $overrides['enabled'] ?? true); + config()->set('nntmux.proxy_detection_threshold', $overrides['threshold'] ?? 50); + config()->set('nntmux.proxy_detection_window_seconds', $overrides['window_seconds'] ?? 3600); + config()->set('nntmux.proxy_detection_ratio_min', $overrides['ratio_min'] ?? 0.8); + config()->set('nntmux.proxy_detection_min_searches', $overrides['min_searches'] ?? 20); + config()->set( + 'nntmux.proxy_detection_indexer_referer_patterns', + $overrides['referer_patterns'] ?? 'hydra,prowlarr,jackett,lidarr,radarr,sonarr,readarr,bazarr', + ); + } + + private function searchContext(?string $apiToken, string $userAgent, string $clientIp, ?string $referer = null): ProxyRequestContext + { + return new ProxyRequestContext( + clientIp: $clientIp, + userAgent: $userAgent, + apiToken: $apiToken, + referer: $referer, + isDownload: false, + isSearch: true, + ); + } + + private function downloadContext(?string $apiToken, string $userAgent, string $clientIp, ?string $referer = null): ProxyRequestContext + { + return new ProxyRequestContext( + clientIp: $clientIp, + userAgent: $userAgent, + apiToken: $apiToken, + referer: $referer, + isDownload: true, + isSearch: false, + ); + } + + private function setEnvironmentValue(string $key, ?string $value): void + { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + + return; + } + + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } +}