*/ private const SENSITIVE_QUERY_PARAMETERS = [ 'apikey', 'api_token', 'token', 'passkey', ]; /** * User-Agent patterns to block (case-insensitive). * * @var array */ protected array $blockedUserAgents = [ 'aiostreams', 'usenetstreamer', 'stremio', ]; /** * Blocked ASNs (Autonomous System Numbers). * Format: ASN number => Description * * @var array */ 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 */ 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 $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 $parameters * @return array */ 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); } }