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 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 13:12:44 +03:00
co-authored by Claude Opus 4.8
parent cf8c5be984
commit 782226674a
+109 -46
View File
@@ -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)