diff --git a/app/Http/Middleware/BlockAbusiveServices.php b/app/Http/Middleware/BlockAbusiveServices.php new file mode 100644 index 000000000..0bac5a6b7 --- /dev/null +++ b/app/Http/Middleware/BlockAbusiveServices.php @@ -0,0 +1,180 @@ + + */ + 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; + + /** + * 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' => $request->getRequestUri(), + ]); + + return $this->blockedResponse('Access denied: Streaming services are 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' => $request->getRequestUri(), + ]); + + 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; + } + + /** + * 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); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index a6045a2fd..1bb1dfd39 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -37,6 +37,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->append([ \Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode::class, \App\Http\Middleware\ForceJsonOnAPI::class, + \App\Http\Middleware\BlockAbusiveServices::class, // Block AIOStreams, Oracle Cloud, UsenetStreamer, Cloudflare WARP ]); $middleware->replace( diff --git a/tests/Feature/Http/Middleware/BlockAbusiveServicesTest.php b/tests/Feature/Http/Middleware/BlockAbusiveServicesTest.php new file mode 100644 index 000000000..0af0c0452 --- /dev/null +++ b/tests/Feature/Http/Middleware/BlockAbusiveServicesTest.php @@ -0,0 +1,323 @@ +middleware = new BlockAbusiveServices; + Cache::flush(); + } + + /** + * Test that normal requests pass through. + */ + public function test_allows_normal_requests(): void + { + Http::fake([ + 'ip-api.com/*' => Http::response([ + 'status' => 'success', + 'as' => 'AS15169 Google LLC', + 'org' => 'Google LLC', + ], 200), + ]); + + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0'); + $request->server->set('REMOTE_ADDR', '8.8.8.8'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('OK', $response->getContent()); + } + + /** + * Test that AIOStreams User-Agent is blocked. + */ + public function test_blocks_aiostreams_user_agent(): void + { + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'AIOStreams/1.0'); + $request->server->set('REMOTE_ADDR', '8.8.8.8'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $this->assertEquals(403, $response->getStatusCode()); + $this->assertStringContainsString('Streaming services are not allowed', $response->getContent()); + } + + /** + * Test that UsenetStreamer User-Agent is blocked. + */ + public function test_blocks_usenetstreamer_user_agent(): void + { + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'UsenetStreamer/2.0'); + $request->server->set('REMOTE_ADDR', '8.8.8.8'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $this->assertEquals(403, $response->getStatusCode()); + $this->assertStringContainsString('Streaming services are not allowed', $response->getContent()); + } + + /** + * Test that Stremio User-Agent is blocked. + */ + public function test_blocks_stremio_user_agent(): void + { + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'Stremio/4.5.0'); + $request->server->set('REMOTE_ADDR', '8.8.8.8'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $this->assertEquals(403, $response->getStatusCode()); + $this->assertStringContainsString('Streaming services are not allowed', $response->getContent()); + } + + /** + * Test that User-Agent check is case-insensitive. + */ + public function test_blocks_user_agent_case_insensitive(): void + { + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'AIOSTREAMS/1.0 (uppercase)'); + $request->server->set('REMOTE_ADDR', '8.8.8.8'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $this->assertEquals(403, $response->getStatusCode()); + } + + /** + * Test that Oracle Cloud ASN is blocked. + */ + public function test_blocks_oracle_cloud_asn(): void + { + Http::fake([ + 'ip-api.com/*' => Http::response([ + 'status' => 'success', + 'as' => 'AS31898 Oracle Corporation', + 'org' => 'Oracle Corporation', + ], 200), + ]); + + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'Mozilla/5.0'); + $request->server->set('REMOTE_ADDR', '129.146.10.50'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $this->assertEquals(403, $response->getStatusCode()); + $json = json_decode($response->getContent(), true); + $this->assertStringContainsString('Oracle Cloud Infrastructure', $json['message']); + } + + /** + * Test that Cloudflare WARP ASN is blocked. + */ + public function test_blocks_cloudflare_warp_asn(): void + { + Http::fake([ + 'ip-api.com/*' => Http::response([ + 'status' => 'success', + 'as' => 'AS13335 Cloudflare Inc', + 'org' => 'Cloudflare Inc', + ], 200), + ]); + + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'Mozilla/5.0'); + $request->server->set('REMOTE_ADDR', '104.16.50.100'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $this->assertEquals(403, $response->getStatusCode()); + $json = json_decode($response->getContent(), true); + $this->assertStringContainsString('Cloudflare WARP', $json['message']); + } + + /** + * Test that non-blocked ASNs pass through. + */ + public function test_allows_non_blocked_asn(): void + { + Http::fake([ + 'ip-api.com/*' => Http::response([ + 'status' => 'success', + 'as' => 'AS15169 Google LLC', + 'org' => 'Google LLC', + ], 200), + ]); + + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'Mozilla/5.0'); + $request->server->set('REMOTE_ADDR', '8.8.8.8'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * Test that private IPs are allowed (skip ASN lookup). + */ + public function test_allows_private_ips(): void + { + Http::fake(); // Should not be called + + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'Mozilla/5.0'); + $request->server->set('REMOTE_ADDR', '192.168.1.100'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $this->assertEquals(200, $response->getStatusCode()); + Http::assertNothingSent(); + } + + /** + * Test that localhost is allowed. + */ + public function test_allows_localhost(): void + { + Http::fake(); // Should not be called + + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'Mozilla/5.0'); + $request->server->set('REMOTE_ADDR', '127.0.0.1'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $this->assertEquals(200, $response->getStatusCode()); + Http::assertNothingSent(); + } + + /** + * Test that API failures allow request to pass through. + */ + public function test_allows_request_on_api_failure(): void + { + Http::fake([ + 'ip-api.com/*' => Http::response(null, 500), + ]); + + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'Mozilla/5.0'); + $request->server->set('REMOTE_ADDR', '8.8.8.8'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * Test that ASN lookups are cached. + */ + public function test_caches_asn_lookups(): void + { + Http::fake([ + 'ip-api.com/*' => Http::response([ + 'status' => 'success', + 'as' => 'AS15169 Google LLC', + 'org' => 'Google LLC', + ], 200), + ]); + + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'Mozilla/5.0'); + $request->server->set('REMOTE_ADDR', '8.8.8.8'); + + // First request + $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + // Second request (should use cache) + $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + // API should only be called once + Http::assertSentCount(1); + } + + /** + * Test that empty User-Agent is allowed. + */ + public function test_allows_empty_user_agent(): void + { + Http::fake([ + 'ip-api.com/*' => Http::response([ + 'status' => 'success', + 'as' => 'AS15169 Google LLC', + 'org' => 'Google LLC', + ], 200), + ]); + + $request = Request::create('/api/test', 'GET'); + $request->server->set('REMOTE_ADDR', '8.8.8.8'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * Test response is JSON format. + */ + public function test_blocked_response_is_json(): void + { + $request = Request::create('/api/test', 'GET'); + $request->headers->set('User-Agent', 'AIOStreams/1.0'); + $request->server->set('REMOTE_ADDR', '8.8.8.8'); + + $response = $this->middleware->handle($request, function ($req) { + return new Response('OK', 200); + }); + + $json = json_decode($response->getContent(), true); + + $this->assertIsArray($json); + $this->assertArrayHasKey('error', $json); + $this->assertArrayHasKey('message', $json); + $this->assertTrue($json['error']); + } +}