diff --git a/proxy.php b/proxy.php index 7c6afe0..da1d2da 100644 --- a/proxy.php +++ b/proxy.php @@ -1,80 +1,129 @@ HTTPS restreint aux hotes autorises dans config.json + * + * Proxy HTTP->HTTPS restreint aux hotes explicitement configures dans config.json. + * Les IP privees et LAN sont autorisees si l'administrateur les a configurees. + * Les redirections sont suivies manuellement avec revalidation de l'hote a chaque saut. */ -// ── Charger la whitelist depuis config.json ──────────────────────────────────── +// ── Whitelist : hotes autorises extraits de config.json ─────────────────────── $config_path = __DIR__ . '/config.json'; $allowed_hosts = []; if (file_exists($config_path)) { - $config = json_decode(file_get_contents($config_path), true); + $config = json_decode(file_get_contents($config_path), true) ?? []; foreach ($config['epg_sources'] ?? [] as $src) { foreach (['epg_url', 'm3u_url'] as $key) { if (!empty($src[$key])) { - $host = parse_url($src[$key], PHP_URL_HOST); - if ($host) $allowed_hosts[] = strtolower($host); + $host = strtolower(parse_url($src[$key], PHP_URL_HOST) ?? ''); + if ($host !== '') $allowed_hosts[] = $host; } } } } -// ── Valider l'URL demandee ───────────────────────────────────────────────────── +// ── Fonctions ───────────────────────────────────────────────────────────────── + +function is_allowed_url(string $url, array $allowed_hosts): bool { + if (!preg_match('#^https?://#i', $url)) return false; + $host = strtolower(parse_url($url, PHP_URL_HOST) ?? ''); + return $host !== '' && in_array($host, $allowed_hosts, true); +} + +function resolve_url(string $base, string $location): string { + if (preg_match('#^https?://#i', $location)) return $location; + $parts = parse_url($base); + $origin = $parts['scheme'] . '://' . $parts['host']; + if (!empty($parts['port'])) $origin .= ':' . $parts['port']; + if ($location[0] === '/') return $origin . $location; + return $origin . rtrim(dirname($parts['path'] ?? '/'), '/') . '/' . $location; +} + +/** + * Fetch avec redirections manuelles — chaque Location: est revalidee contre la whitelist. + * $stream = true : stream chunk par chunk (segments video) + * $stream = false : retourne le body complet (playlists m3u8) + */ +function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $stream = false): array { + $max_redirects = 5; + + for ($i = 0; $i <= $max_redirects; $i++) { + if (!is_allowed_url($url, $allowed_hosts)) { + http_response_code(403); die('Host not allowed after redirect'); + } + + $ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0'; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_HEADER => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_USERAGENT => $ua, + CURLOPT_HTTPHEADER => ['Accept: */*'], + ]); + + $response = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + curl_close($ch); + + if ($response === false) { + http_response_code(502); die('Upstream error'); + } + + $raw_headers = substr($response, 0, $header_size); + $body = substr($response, $header_size); + + // Redirection + if ($code >= 300 && $code < 400) { + if (!preg_match('/^Location:\s*(.+)$/mi', $raw_headers, $m)) { + http_response_code(502); die('Invalid redirect'); + } + $url = resolve_url($url, trim($m[1])); + continue; + } + + return [$code, $raw_headers, $body, $url]; + } + + http_response_code(508); die('Too many redirects'); +} + +// ── Valider l'URL initiale ───────────────────────────────────────────────────── $url = $_GET['url'] ?? ''; -if (empty($url) || !preg_match('#^https?://#i', $url)) { - http_response_code(400); die('Invalid URL'); +if (!is_allowed_url($url, $allowed_hosts)) { + http_response_code(empty($allowed_hosts) ? 503 : 403); + die(empty($allowed_hosts) ? 'No sources configured' : 'Host not allowed'); } -$parsed = parse_url($url); -$host = strtolower($parsed['host'] ?? ''); - -// Bloquer si hote absent de la whitelist -if (empty($allowed_hosts) || !in_array($host, $allowed_hosts, true)) { - http_response_code(403); die('Host not allowed'); -} - -// Bloquer les IPs privees, loopback, metadata cloud -function is_private_host(string $host): bool { - // Loopback / localhost - if ($host === 'localhost' || $host === '::1') return true; - // Metadata AWS/GCP/Azure - if ($host === '169.254.169.254' || $host === 'metadata.google.internal') return true; - // Resoudre et verifier si IP privee - $ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host); - if (!filter_var($ip, FILTER_VALIDATE_IP)) return true; // echec resolution - return !filter_var($ip, FILTER_VALIDATE_IP, [ - 'flags' => FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE - ]); -} - -// Note : on autorise les IPs privees si elles sont explicitement dans config.json -// (cas Tunarr/Jellyfin sur le reseau local) — on bloque seulement les hotes -// qui ne sont PAS dans la whitelist, ce qui couvre deja le SSRF. - -// ── Proxy ───────────────────────────────────────────────────────────────────── -$base = preg_replace('#[^/]*(\?.*)?$#', '', $url); -$origin = $parsed['scheme'] . '://' . $parsed['host']; -$port = $parsed['port'] ?? null; -if ($port) $origin .= ':' . $port; -$path = $parsed['path'] ?? ''; +// ── Determiner le type de ressource ─────────────────────────────────────────── +$path = parse_url($url, PHP_URL_PATH) ?? ''; $is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path); header('Access-Control-Allow-Origin: *'); header('Cache-Control: no-cache'); -$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0'; - +// ── Segment binaire — stream chunk par chunk ─────────────────────────────────── if ($is_segment) { header('Content-Type: video/MP2T'); header('X-Content-Type-Options: nosniff'); if (ob_get_level()) ob_end_clean(); - $ch = curl_init($url); + // Pour les segments, on suit les redirections en streaming direct + // apres avoir valide l'URL finale via fetch_with_checked_redirects en mode non-stream + [$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false); + + if ($code >= 400) { http_response_code($code); die(); } + + // Maintenant streamer l'URL finale + $ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0'; + $ch = curl_init($final_url); curl_setopt_array($ch, [ - CURLOPT_FOLLOWLOCATION => true, + CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 30, CURLOPT_USERAGENT => $ua, CURLOPT_HTTPHEADER => ['Accept: */*'], @@ -93,25 +142,19 @@ if ($is_segment) { if (!$ok || $code >= 400) http_response_code($code ?: 502); curl_close($ch); +// ── Playlist m3u8 — fetch + réécriture URLs ──────────────────────────────────── } else { - $ch = curl_init($url); - curl_setopt_array($ch, [ - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_TIMEOUT => 15, - CURLOPT_USERAGENT => $ua, - CURLOPT_HTTPHEADER => ['Accept: */*'], - CURLOPT_RETURNTRANSFER => true, - ]); - $body = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + [$code, , $body, $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false); - if ($body === false || $code >= 400) { - http_response_code($code ?: 502); die("Upstream error $code"); - } + if ($code >= 400) { http_response_code($code); die("Upstream error $code"); } header('Content-Type: application/vnd.apple.mpegurl'); + $final_parts = parse_url($final_url); + $origin = $final_parts['scheme'] . '://' . $final_parts['host']; + if (!empty($final_parts['port'])) $origin .= ':' . $final_parts['port']; + $base = preg_replace('#[^/]*(\?.*)?$#', '', $final_url); + $proxy_base = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . strtok($_SERVER['REQUEST_URI'], '?')