Update detection

This commit is contained in:
DariusIII
2026-07-02 11:43:08 +02:00
parent 3faeb1d63a
commit 7b5313e97e
9 changed files with 896 additions and 5 deletions
+9
View File
@@ -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
@@ -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<int, string>
*/
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace App\Services\Security;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
/**
* Behavioural detector for direct NZBHydra2/Prowlarr proxy fetches.
*
* Combines four weak signals into a single score so that no individual signal
* can false-positive a legitimate user. Redirected grabs (indexer hands a URL
* to a download client that fetches it with its own downloader UA) score zero
* because none of the signals fire: downloader UA, no indexer Referer, UA
* changed between search and download, normal download/search ratio, and the
* IP's last indexer-search UA differs from the download UA.
*
* State is kept in the cache using a sliding window (default 1 hour). Cache
* operations are portable across the file, database and Redis stores.
*/
final class IndexerProxyDetector
{
private const CACHE_PREFIX = 'proxy_detect:';
private const MAX_TRACKED_USER_AGENTS = 5;
public function __construct(private readonly CacheRepository $cache) {}
/**
* Score a download request and decide whether it should be blocked.
*
* Reads the sliding-window counters populated by recordSearch(), then
* records this download so the ratio signal reflects real traffic.
*/
public function analyze(ProxyRequestContext $ctx): ProxyVerdict
{
if (! $this->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<int, string>
*/
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<int, string> $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<int, string>
*/
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);
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Services\Security;
use Illuminate\Http\Request;
/**
* Immutable snapshot of the request facts the IndexerProxyDetector needs.
*
* Keeping the detector dependent on this DTO rather than the raw HTTP Request
* makes each behavioural signal trivial to unit test in isolation.
*/
final readonly class ProxyRequestContext
{
public function __construct(
public string $clientIp,
public string $userAgent,
public ?string $apiToken,
public ?string $referer,
public bool $isDownload,
public bool $isSearch,
) {}
/**
* Build a context from the incoming request.
*
* The middleware already knows whether the request is a download or a
* search (it computes those to decide the existing UA fast path), so those
* flags are passed in rather than recomputed here.
*/
public static function fromRequest(Request $request, bool $isDownload, bool $isSearch): self
{
return new self(
clientIp: (string) ($request->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;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Services\Security;
/**
* Result of a behavioural proxy-fetch analysis.
*
* @property array<string, int> $reasons Per-signal score contributions.
*/
final readonly class ProxyVerdict
{
/**
* @param array<string, int> $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, []);
}
}
+10
View File
@@ -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)
@@ -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();
}
+99 -4
View File
@@ -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<string, string> $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)
);
}
}
+347
View File
@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Services\Security\IndexerProxyDetector;
use App\Services\Security\ProxyRequestContext;
use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\Repository;
use Illuminate\Contracts\Console\Kernel;
use PDO;
use Tests\TestCase;
class IndexerProxyDetectorTest extends TestCase
{
private string $databasePath;
/**
* @var array<string, string|false>
*/
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<string, mixed> $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;
}
}