mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
348 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|