mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update detection
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, []);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user