Files
newznab-tmux/tests/Unit/IndexerProxyDetectorTest.php
2026-07-02 13:47:12 +02:00

348 lines
12 KiB
PHP

<?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',
);
}
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;
}
}