HTTPS proxy restricted to hosts explicitly configured in config.json. * Private and LAN IPs are allowed only when the administrator configured them. * Redirects are followed manually and the host is revalidated at every hop. */ // ── Allowlist: authorized hosts extracted from 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 = strtolower(parse_url($src[$key], PHP_URL_HOST) ?? ''); if ($host !== '') $allowed_hosts[] = $host; } } } } // ── Helpers ──────────────────────────────────────────────────────────────────── 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 normalize_path(string $path): string { $segments = explode('/', $path); $normalized = []; foreach ($segments as $segment) { if ($segment === '' || $segment === '.') { continue; } if ($segment === '..') { array_pop($normalized); continue; } $normalized[] = $segment; } return '/' . implode('/', $normalized); } 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 (strpos($location, '//') === 0) { return $parts['scheme'] . ':' . $location; } if ($location === '') { return $base; } if ($location[0] === '#') { $without_fragment = preg_replace('/#.*$/', '', $base); return $without_fragment . $location; } if ($location[0] === '?') { $path = $parts['path'] ?? '/'; return $origin . $path . $location; } $location_parts = parse_url($location); $path = $location_parts['path'] ?? ''; $query = isset($location_parts['query']) ? '?' . $location_parts['query'] : ''; $fragment = isset($location_parts['fragment']) ? '#' . $location_parts['fragment'] : ''; if ($path !== '' && $path[0] === '/') { return $origin . normalize_path($path) . $query . $fragment; } $base_dir = dirname($parts['path'] ?? '/'); if ($base_dir === '\\' || $base_dir === '.') { $base_dir = '/'; } $combined = rtrim($base_dir, '/') . '/' . $path; return $origin . normalize_path($combined) . $query . $fragment; } /** * Fetch with manual redirect handling so every Location header is checked * against the allowlist before following it. */ function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $head_only = 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_NOBODY => $head_only, 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); // Follow redirects manually so each hop is rechecked against the allowlist. 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'); } // ── Validate the initial URL ─────────────────────────────────────────────────── $url = $_GET['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'); } // ── Detect the resource type ─────────────────────────────────────────────────── $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'); // ── Binary segment: stream chunk by chunk ────────────────────────────────────── if ($is_segment) { header('Content-Type: video/MP2T'); header('X-Content-Type-Options: nosniff'); if (ob_get_level()) ob_end_clean(); // Resolve redirects with HEAD first so we do not download the segment twice. // Some HLS/CDN servers reject HEAD on media objects even when GET works. [$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, true); if ($code === 403 || $code === 405) { [, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false); $code = 200; } if ($code >= 400) { http_response_code($code); die(); } // Stream the validated final URL. $ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0'; $ch = curl_init($final_url); curl_setopt_array($ch, [ CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 30, CURLOPT_USERAGENT => $ua, CURLOPT_HTTPHEADER => ['Accept: */*'], CURLOPT_RETURNTRANSFER => false, CURLOPT_WRITEFUNCTION => function($ch, $data) { echo $data; flush(); return strlen($data); }, CURLOPT_HEADERFUNCTION => function($ch, $header) { $h = trim($header); if (preg_match('/^Content-Type:/i', $h)) header($h); return strlen($header); }, ]); $ok = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); if (!$ok || $code >= 400) http_response_code($code ?: 502); curl_close($ch); // ── m3u8 playlist: fetch and rewrite URLs ────────────────────────────────────── } else { [$code, , $body, $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false); 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'], '?') . '?url='; $out = []; foreach (explode("\n", $body) as $line) { $line = rtrim($line); if ($line === '' || $line[0] === '#') { $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) . '"'; }, $line); $out[] = $line; } else { $seg = strpos($line, 'http') === 0 ? $line : ($line[0] === '/' ? $origin . $line : $base . $line); $out[] = $proxy_base . urlencode($seg); } } echo implode("\n", $out); }