mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
403 lines
12 KiB
PHP
403 lines
12 KiB
PHP
<?php
|
|
|
|
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;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* Middleware to block abusive streaming services and cloud providers.
|
|
*
|
|
* Blocks:
|
|
* - AIOStreams (User-Agent based)
|
|
* - UsenetStreamer (User-Agent based)
|
|
* - Configured indexer apps proxying NZB downloads (User-Agent based, opt-in).
|
|
* Only NZB download/grab requests are blocked, so proxied searches and RSS
|
|
* feeds keep working. Redirected grabs (Prowlarr/NZBHydra2 "Redirect" setting)
|
|
* arrive directly from the download client, whose User-Agent does not match
|
|
* the configured proxy patterns, and are therefore allowed.
|
|
* - Oracle Cloud (ASN: AS31898)
|
|
* - Cloudflare WARP (ASN: AS13335)
|
|
*/
|
|
class BlockAbusiveServices
|
|
{
|
|
/**
|
|
* @var array<int, string>
|
|
*/
|
|
private const SENSITIVE_QUERY_PARAMETERS = [
|
|
'apikey',
|
|
'api_token',
|
|
'token',
|
|
'passkey',
|
|
];
|
|
|
|
/**
|
|
* User-Agent patterns to block (case-insensitive).
|
|
*
|
|
* @var array<string>
|
|
*/
|
|
protected array $blockedUserAgents = [
|
|
'aiostreams',
|
|
'usenetstreamer',
|
|
'stremio',
|
|
];
|
|
|
|
/**
|
|
* Blocked ASNs (Autonomous System Numbers).
|
|
* Format: ASN number => Description
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
protected array $blockedAsns = [
|
|
31898 => 'Oracle Cloud Infrastructure',
|
|
13335 => 'Cloudflare WARP',
|
|
];
|
|
|
|
/**
|
|
* Cache TTL for ASN lookups (in seconds).
|
|
* Default: 24 hours
|
|
*/
|
|
protected int $cacheTtl = 86400;
|
|
|
|
public function __construct(private readonly IndexerProxyDetector $detector) {}
|
|
|
|
/**
|
|
* Handle an incoming request.
|
|
*
|
|
* @param Closure(Request): (Response) $next
|
|
*/
|
|
public function handle(Request $request, Closure $next): Response
|
|
{
|
|
$ip = $request->ip();
|
|
$userAgent = $request->userAgent() ?? '';
|
|
|
|
// Check for blocked User-Agents
|
|
if ($this->isBlockedUserAgent($userAgent)) {
|
|
Log::warning('Blocked abusive User-Agent', [
|
|
'ip' => $ip,
|
|
'user_agent' => $userAgent,
|
|
'uri' => $this->safeRequestUri($request),
|
|
]);
|
|
|
|
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,
|
|
'user_agent' => $userAgent,
|
|
'uri' => $this->safeRequestUri($request),
|
|
]);
|
|
|
|
return $this->blockedResponse('Access denied: Proxying NZB downloads through indexer apps is not allowed.');
|
|
}
|
|
|
|
// Check for blocked ASNs
|
|
$asnInfo = $this->getAsnInfo($ip);
|
|
if ($asnInfo !== null && isset($this->blockedAsns[$asnInfo['asn']])) {
|
|
Log::warning('Blocked ASN', [
|
|
'ip' => $ip,
|
|
'asn' => $asnInfo['asn'],
|
|
'org' => $asnInfo['org'] ?? 'Unknown',
|
|
'blocked_reason' => $this->blockedAsns[$asnInfo['asn']],
|
|
'user_agent' => $userAgent,
|
|
'uri' => $this->safeRequestUri($request),
|
|
]);
|
|
|
|
return $this->blockedResponse(
|
|
sprintf('Access denied: %s is not allowed.', $this->blockedAsns[$asnInfo['asn']])
|
|
);
|
|
}
|
|
|
|
return $next($request);
|
|
}
|
|
|
|
/**
|
|
* Check if the User-Agent matches any blocked patterns.
|
|
*/
|
|
protected function isBlockedUserAgent(string $userAgent): bool
|
|
{
|
|
$lowerUserAgent = strtolower($userAgent);
|
|
|
|
foreach ($this->blockedUserAgents as $pattern) {
|
|
if (str_contains($lowerUserAgent, strtolower($pattern))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check whether an opt-in proxy indexer app block should apply.
|
|
*/
|
|
protected function shouldBlockProxyIndexerApp(Request $request, string $userAgent): bool
|
|
{
|
|
if (! (bool) config('nntmux.block_proxy_indexer_apps', false)) {
|
|
return false;
|
|
}
|
|
|
|
if (! $this->isNzbDownloadRequest($request)) {
|
|
return false;
|
|
}
|
|
|
|
return $this->matchesAnyUserAgentPattern(
|
|
$userAgent,
|
|
$this->configuredProxyIndexerAppUserAgents()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Detect NZB download/grab requests so only proxied downloads are blocked.
|
|
*
|
|
* Searches, caps and RSS feeds are intentionally out of scope so proxied
|
|
* searches keep working. Redirected grabs arrive directly from the download
|
|
* client, whose User-Agent does not match the configured proxy patterns.
|
|
*/
|
|
protected function isNzbDownloadRequest(Request $request): bool
|
|
{
|
|
if ($request->is('api/v2/getnzb')) {
|
|
return true;
|
|
}
|
|
|
|
if (! $request->is('api/v1/api')) {
|
|
return false;
|
|
}
|
|
|
|
$type = strtolower(trim((string) $request->input('t', '')));
|
|
|
|
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>
|
|
*/
|
|
protected function configuredProxyIndexerAppUserAgents(): array
|
|
{
|
|
$configured = config('nntmux.block_proxy_indexer_app_user_agents', []);
|
|
|
|
if (is_string($configured)) {
|
|
$configured = preg_split('/[\r\n,]+/', $configured) ?: [];
|
|
}
|
|
|
|
if (! is_array($configured)) {
|
|
Log::warning('Ignoring invalid proxy indexer app User-Agent configuration.', [
|
|
'configured_type' => get_debug_type($configured),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter(array_map(
|
|
static fn (mixed $pattern): string => is_string($pattern) ? trim($pattern) : '',
|
|
$configured,
|
|
)));
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $patterns
|
|
*/
|
|
protected function matchesAnyUserAgentPattern(string $userAgent, array $patterns): bool
|
|
{
|
|
$lowerUserAgent = strtolower($userAgent);
|
|
|
|
foreach ($patterns as $pattern) {
|
|
if (str_contains($lowerUserAgent, strtolower($pattern))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Return the request URI with sensitive query parameter values redacted for logs.
|
|
*/
|
|
protected function safeRequestUri(Request $request): string
|
|
{
|
|
$query = $request->query();
|
|
if ($query === []) {
|
|
return $request->getPathInfo();
|
|
}
|
|
|
|
$queryString = http_build_query(
|
|
$this->redactSensitiveQueryParameters($query),
|
|
'',
|
|
'&',
|
|
PHP_QUERY_RFC3986
|
|
);
|
|
|
|
return $queryString === ''
|
|
? $request->getPathInfo()
|
|
: $request->getPathInfo().'?'.$queryString;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $parameters
|
|
* @return array<string, mixed>
|
|
*/
|
|
protected function redactSensitiveQueryParameters(array $parameters): array
|
|
{
|
|
$redacted = [];
|
|
|
|
foreach ($parameters as $key => $value) {
|
|
if (in_array(strtolower((string) $key), self::SENSITIVE_QUERY_PARAMETERS, true)) {
|
|
$redacted[$key] = '[redacted]';
|
|
|
|
continue;
|
|
}
|
|
|
|
$redacted[$key] = is_array($value)
|
|
? $this->redactSensitiveQueryParameters($value)
|
|
: $value;
|
|
}
|
|
|
|
return $redacted;
|
|
}
|
|
|
|
/**
|
|
* Get ASN information for an IP address.
|
|
* Uses ip-api.com with caching to minimize API calls.
|
|
*
|
|
* @return array{asn: int, org: string}|null
|
|
*/
|
|
protected function getAsnInfo(string $ip): ?array
|
|
{
|
|
// Skip private/local IPs
|
|
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
|
return null;
|
|
}
|
|
|
|
$cacheKey = 'asn_lookup_'.md5($ip);
|
|
|
|
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($ip) {
|
|
return $this->fetchAsnFromApi($ip);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch ASN info from ip-api.com.
|
|
*
|
|
* @return array{asn: int, org: string}|null
|
|
*/
|
|
protected function fetchAsnFromApi(string $ip): ?array
|
|
{
|
|
try {
|
|
$response = Http::timeout(3)
|
|
->retry(2, 100)
|
|
->get("https://ip-api.com/json/{$ip}", [
|
|
'fields' => 'status,as,org',
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
Log::debug('ASN lookup failed', ['ip' => $ip, 'status' => $response->status()]);
|
|
|
|
return null;
|
|
}
|
|
|
|
$data = $response->json();
|
|
|
|
if (($data['status'] ?? '') !== 'success' || empty($data['as'])) {
|
|
return null;
|
|
}
|
|
|
|
// Parse ASN from "AS31898 Oracle Corporation" format
|
|
if (preg_match('/^AS(\d+)/', $data['as'], $matches)) {
|
|
return [
|
|
'asn' => (int) $matches[1],
|
|
'org' => $data['org'] ?? $data['as'],
|
|
];
|
|
}
|
|
|
|
return null;
|
|
} catch (\Exception $e) {
|
|
Log::debug('ASN lookup exception', ['ip' => $ip, 'error' => $e->getMessage()]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a blocked response.
|
|
*/
|
|
protected function blockedResponse(string $message): Response
|
|
{
|
|
return response()->json([
|
|
'error' => true,
|
|
'message' => $message,
|
|
], 403);
|
|
}
|
|
}
|