diff --git a/proxy.php b/proxy.php index 4e5610e..7c6afe0 100644 --- a/proxy.php +++ b/proxy.php @@ -1,18 +1,64 @@ HTTPS restreint aux hotes autorises dans config.json */ +// ── Charger la whitelist depuis config.json ──────────────────────────────────── +$config_path = __DIR__ . '/config.json'; +$allowed_hosts = []; + +if (file_exists($config_path)) { + $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); + } + } + } +} + +// ── Valider l'URL demandee ───────────────────────────────────────────────────── $url = $_GET['url'] ?? ''; + if (empty($url) || !preg_match('#^https?://#i', $url)) { http_response_code(400); die('Invalid URL'); } +$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 = parse_url($url, PHP_URL_SCHEME) . '://' . parse_url($url, PHP_URL_HOST); -$port = parse_url($url, PHP_URL_PORT); +$origin = $parsed['scheme'] . '://' . $parsed['host']; +$port = $parsed['port'] ?? null; if ($port) $origin .= ':' . $port; -$path = parse_url($url, PHP_URL_PATH) ?? ''; +$path = $parsed['path'] ?? ''; $is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path); header('Access-Control-Allow-Origin: *'); @@ -21,7 +67,6 @@ header('Cache-Control: no-cache'); $ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0'; if ($is_segment) { - // Segment binaire — stream via cURL chunk par chunk header('Content-Type: video/MP2T'); header('X-Content-Type-Options: nosniff'); @@ -35,28 +80,20 @@ if ($is_segment) { CURLOPT_HTTPHEADER => ['Accept: */*'], CURLOPT_RETURNTRANSFER => false, CURLOPT_WRITEFUNCTION => function($ch, $data) { - echo $data; - flush(); - return strlen($data); + echo $data; flush(); return strlen($data); }, CURLOPT_HEADERFUNCTION => function($ch, $header) { $h = trim($header); - if (preg_match('/^Content-Type:/i', $h)) { - header($h); - } + if (preg_match('/^Content-Type:/i', $h)) header($h); return strlen($header); }, ]); - - $ok = curl_exec($ch); + $ok = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - if (!$ok || $code >= 400) { - http_response_code($code ?: 502); - } + if (!$ok || $code >= 400) http_response_code($code ?: 502); curl_close($ch); } else { - // Playlist .m3u8 — fetch + réécriture URLs $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_FOLLOWLOCATION => true, @@ -70,8 +107,7 @@ if ($is_segment) { curl_close($ch); if ($body === false || $code >= 400) { - http_response_code($code ?: 502); - die("Upstream error $code"); + http_response_code($code ?: 502); die("Upstream error $code"); } header('Content-Type: application/vnd.apple.mpegurl'); @@ -85,7 +121,7 @@ if ($is_segment) { foreach (explode("\n", $body) as $line) { $line = rtrim($line); if ($line === '' || $line[0] === '#') { - $line = preg_replace_callback('/URI="([^"]+)"/', function($m) use ($base, $proxy_base) { + $line = preg_replace_callback('/URI="([^"]+)"/', function($m) use ($base, $origin, $proxy_base) { $seg = strpos($m[1], 'http') === 0 ? $m[1] : ($m[1][0] === '/' ? $origin . $m[1] : $base . $m[1]); return 'URI="' . $proxy_base . urlencode($seg) . '"'; diff --git a/src/js/mobile.js b/src/js/mobile.js index d26e1f6..a72915a 100644 --- a/src/js/mobile.js +++ b/src/js/mobile.js @@ -39,12 +39,12 @@ function renderMobile() { } const timeCol = document.createElement('div'); timeCol.className='mobile-program-time'; - timeCol.innerHTML = `${fmtTime(p.start)}${fmtTime(p.stop)}`; + timeCol.innerHTML = `${esc(fmtTime(p.start))}${esc(fmtTime(p.stop))}`; item.appendChild(timeCol); const info = document.createElement('div'); info.className='mobile-program-info'; const ep = fmtEpisode(p.season, p.episode); - info.innerHTML = `
${p.title}
${ep ? ep + ' · ' : ''}${dur} min
`; + info.innerHTML = `
${esc(p.title)}
${ep ? esc(ep) + ' · ' : ''}${dur} min
`; item.appendChild(info); if (isLive) { diff --git a/src/js/search.js b/src/js/search.js index 262a4e3..d22f70d 100644 --- a/src/js/search.js +++ b/src/js/search.js @@ -179,12 +179,12 @@ function renderMobileFiltered(filteredChannels, q) { } const timeCol = document.createElement('div'); timeCol.className = 'mobile-program-time'; - timeCol.innerHTML = `${fmtTime(p.start)}${fmtTime(p.stop)}`; + timeCol.innerHTML = `${esc(fmtTime(p.start))}${esc(fmtTime(p.stop))}`; item.appendChild(timeCol); const info = document.createElement('div'); info.className = 'mobile-program-info'; const ep = fmtEpisode(p.season, p.episode); - info.innerHTML = `
${p.title}
${ep ? ep + ' · ' : ''}${dur} min
`; + info.innerHTML = `
${esc(p.title)}
${ep ? esc(ep) + ' · ' : ''}${dur} min
`; item.appendChild(info); if (isLive) { diff --git a/src/js/utils.js b/src/js/utils.js index ffc6be2..6e8c324 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -1,4 +1,11 @@ +// Echapper les donnees issues de l'EPG pour eviter les injections HTML +function esc(str) { + const d = document.createElement('div'); + d.textContent = str || ''; + return d.innerHTML; +} + let channels = []; let programs = {}; let m3uStreams = {}; // slug normalisé → url stream diff --git a/version.json b/version.json index 519ae5b..f0d7d7f 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "1.4.1" + "version": "1.4.2" }