From 782226674a453d9cc795b75456948bb907a43fe1 Mon Sep 17 00:00:00 2001 From: ayrisdev Date: Wed, 15 Jul 2026 13:12:44 +0300 Subject: [PATCH] Security: harden SSRF guard on stream proxies, fix vtt-proxy allowlist Resolve hosts to IPs and reject private/reserved/link-local ranges (incl. 169.254 cloud metadata) instead of matching hostname text, and re-validate every redirect hop by following them manually. Restrict curl to http(s). vtt-proxy allowlist now requires an exact host or a real subdomain. Co-Authored-By: Claude Opus 4.8 --- routes/web.php | 155 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 109 insertions(+), 46 deletions(-) diff --git a/routes/web.php b/routes/web.php index 93acd18..a3663ae 100644 --- a/routes/web.php +++ b/routes/web.php @@ -18,6 +18,46 @@ use App\Http\Controllers\Frontend\MessageController; use App\Http\Controllers\Frontend\VoiceCallController; use App\Http\Controllers\Frontend\DiscoverController; +if (! function_exists('animexe_url_is_safe')) { + /** + * SSRF guard for the server-side stream/subtitle proxies. + * + * Only http(s) URLs are allowed, and the host must resolve exclusively to + * public IPs. Resolving (instead of matching the hostname text) is what + * blocks link-local cloud metadata (169.254.169.254), loopback, private + * ranges and hostnames deliberately pointed at them (DNS rebinding). + */ + function animexe_url_is_safe(?string $url): bool + { + if (! $url) return false; + + $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); + if (! in_array($scheme, ['http', 'https'], true)) return false; + + $host = parse_url($url, PHP_URL_HOST); + if (! $host) return false; + + $ips = []; + if (filter_var($host, FILTER_VALIDATE_IP)) { + $ips[] = $host; + } else { + foreach (@gethostbynamel($host) ?: [] as $ip) $ips[] = $ip; + foreach (@dns_get_record($host, DNS_AAAA) ?: [] as $rec) { + if (! empty($rec['ipv6'])) $ips[] = $rec['ipv6']; + } + } + if (! $ips) return false; + + foreach ($ips as $ip) { + if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return false; + } + } + + return true; + } +} + Route::get('/media/{path}', [MediaController::class, 'show']) ->where('path', '.*') ->name('media.show'); @@ -428,12 +468,10 @@ Route::get("/stream/proxy", function (\Illuminate\Http\Request $request) { $url = base64_decode($u, true); if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) abort(400); - // Block private/internal IPs (SSRF protection) - $host = parse_url($url, PHP_URL_HOST) ?? ""; - if (!$host || preg_match('/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/', $host)) abort(403); - // Only allow HTTPS streaming URLs (m3u8 or ts segments) + // SSRF guard — resolves the host and rejects private/reserved/link-local IPs + if (! animexe_url_is_safe($url)) abort(403); + $host = parse_url($url, PHP_URL_HOST) ?? ""; $scheme = parse_url($url, PHP_URL_SCHEME) ?? ""; - if (!in_array($scheme, ['http', 'https'])) abort(403); // Referer: custom ref param > auto-detect from URL's own origin $customRef = $request->query("ref", ""); @@ -460,27 +498,39 @@ Route::get("/stream/proxy", function (\Illuminate\Http\Request $request) { } $origin = rtrim($referer, '/'); - // Tek istek yürüten yardımcı — farklı header setleriyle tekrar denemek için + // Tek istek yürüten yardımcı — farklı header setleriyle tekrar denemek için. + // Redirect'ler elle takip edilir ki her adım SSRF kontrolünden geçsin. $fetchUpstream = function (string $url, array $headers) { - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_MAXREDIRS => 5, - CURLOPT_TIMEOUT => 15, - CURLOPT_CONNECTTIMEOUT => 8, - CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_SSL_VERIFYHOST => false, - CURLOPT_ENCODING => '', - CURLOPT_HTTPHEADER => $headers, - ]); - $content = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - $ctype = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); - $errNo = curl_errno($ch); - $errStr = $errNo ? curl_strerror($errNo) : ''; - curl_close($ch); - return [$content, $httpCode, $ctype, $errStr]; + $hops = 0; + while (true) { + if (! animexe_url_is_safe($url)) return ['', 0, '', 'blocked_by_ssrf_guard']; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_TIMEOUT => 15, + CURLOPT_CONNECTTIMEOUT => 8, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_ENCODING => '', + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_HTTPHEADER => $headers, + ]); + $content = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $ctype = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + $location = (string) curl_getinfo($ch, CURLINFO_REDIRECT_URL); + $errNo = curl_errno($ch); + $errStr = $errNo ? curl_strerror($errNo) : ''; + curl_close($ch); + + if ($httpCode >= 300 && $httpCode < 400 && $location !== '' && $hops < 5) { + $hops++; + $url = $location; + continue; + } + return [$content, $httpCode, $ctype, $errStr]; + } }; $UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; @@ -580,10 +630,10 @@ Route::get("/stream/seg", function (\Illuminate\Http\Request $request) { $url = base64_decode($u, true); if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) abort(400); - $host = parse_url($url, PHP_URL_HOST) ?? ""; - if (!$host || preg_match('/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/', $host)) abort(403); + // SSRF guard — resolves the host and rejects private/reserved/link-local IPs + if (! animexe_url_is_safe($url)) abort(403); + $host = parse_url($url, PHP_URL_HOST) ?? ""; $scheme = parse_url($url, PHP_URL_SCHEME) ?? ""; - if (!in_array($scheme, ['http', 'https'])) abort(403); // Referer: custom ref param > auto-detect from URL's own origin $customRef = $request->query("ref", ""); @@ -611,24 +661,36 @@ Route::get("/stream/seg", function (\Illuminate\Http\Request $request) { ]); } - // CDN'den çek — önce Referer-only (oynatıcı taklidi), olmazsa Origin ekle + // CDN'den çek — önce Referer-only (oynatıcı taklidi), olmazsa Origin ekle. + // Redirect'ler elle takip edilir ki her adım SSRF kontrolünden geçsin. $segFetch = function (string $url, array $headers) { - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_MAXREDIRS => 5, - CURLOPT_TIMEOUT => 15, - CURLOPT_CONNECTTIMEOUT => 8, - CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_SSL_VERIFYHOST => false, - CURLOPT_ENCODING => '', - CURLOPT_HTTPHEADER => $headers, - ]); - $content = curl_exec($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - return [$content, $httpCode]; + $hops = 0; + while (true) { + if (! animexe_url_is_safe($url)) return ['', 0]; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_TIMEOUT => 15, + CURLOPT_CONNECTTIMEOUT => 8, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_ENCODING => '', + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_HTTPHEADER => $headers, + ]); + $content = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $location = (string) curl_getinfo($ch, CURLINFO_REDIRECT_URL); + curl_close($ch); + + if ($httpCode >= 300 && $httpCode < 400 && $location !== '' && $hops < 5) { + $hops++; + $url = $location; + continue; + } + return [$content, $httpCode]; + } }; $segUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; @@ -686,7 +748,8 @@ Route::get('/vtt-proxy', function (\Illuminate\Http\Request $request) { $host = parse_url($url, PHP_URL_HOST) ?? ''; $allowed = ['b-cdn.net', 'bunnycdn.com', 'aniziumserver.site', 'aniziumserver.com', 'aniziumserver.sbs', 'anizium.co']; $ok = false; - foreach ($allowed as $a) { if (str_ends_with($host, $a)) { $ok = true; break; } } + // Tam eşleşme veya gerçek alt-domain — "evilanizium.co" gibi sahte son ekler geçmez + foreach ($allowed as $a) { if ($host === $a || str_ends_with($host, '.' . $a)) { $ok = true; break; } } if (!$ok) abort(403); try { $resp = \Illuminate\Support\Facades\Http::timeout(15)