security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML
This commit is contained in:
@@ -1,80 +1,129 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* GridTV — proxy.php
|
* GridTV — proxy.php
|
||||||
* Proxy HTTP->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';
|
$config_path = __DIR__ . '/config.json';
|
||||||
$allowed_hosts = [];
|
$allowed_hosts = [];
|
||||||
|
|
||||||
if (file_exists($config_path)) {
|
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 ($config['epg_sources'] ?? [] as $src) {
|
||||||
foreach (['epg_url', 'm3u_url'] as $key) {
|
foreach (['epg_url', 'm3u_url'] as $key) {
|
||||||
if (!empty($src[$key])) {
|
if (!empty($src[$key])) {
|
||||||
$host = parse_url($src[$key], PHP_URL_HOST);
|
$host = strtolower(parse_url($src[$key], PHP_URL_HOST) ?? '');
|
||||||
if ($host) $allowed_hosts[] = strtolower($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'] ?? '';
|
$url = $_GET['url'] ?? '';
|
||||||
|
|
||||||
if (empty($url) || !preg_match('#^https?://#i', $url)) {
|
if (!is_allowed_url($url, $allowed_hosts)) {
|
||||||
http_response_code(400); die('Invalid URL');
|
http_response_code(empty($allowed_hosts) ? 503 : 403);
|
||||||
|
die(empty($allowed_hosts) ? 'No sources configured' : 'Host not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
$parsed = parse_url($url);
|
// ── Determiner le type de ressource ───────────────────────────────────────────
|
||||||
$host = strtolower($parsed['host'] ?? '');
|
$path = parse_url($url, PHP_URL_PATH) ?? '';
|
||||||
|
|
||||||
// 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'] ?? '';
|
|
||||||
$is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path);
|
$is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path);
|
||||||
|
|
||||||
header('Access-Control-Allow-Origin: *');
|
header('Access-Control-Allow-Origin: *');
|
||||||
header('Cache-Control: no-cache');
|
header('Cache-Control: no-cache');
|
||||||
|
|
||||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
|
// ── Segment binaire — stream chunk par chunk ───────────────────────────────────
|
||||||
|
|
||||||
if ($is_segment) {
|
if ($is_segment) {
|
||||||
header('Content-Type: video/MP2T');
|
header('Content-Type: video/MP2T');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
if (ob_get_level()) ob_end_clean();
|
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, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_FOLLOWLOCATION => true,
|
CURLOPT_FOLLOWLOCATION => false,
|
||||||
CURLOPT_TIMEOUT => 30,
|
CURLOPT_TIMEOUT => 30,
|
||||||
CURLOPT_USERAGENT => $ua,
|
CURLOPT_USERAGENT => $ua,
|
||||||
CURLOPT_HTTPHEADER => ['Accept: */*'],
|
CURLOPT_HTTPHEADER => ['Accept: */*'],
|
||||||
@@ -93,25 +142,19 @@ if ($is_segment) {
|
|||||||
if (!$ok || $code >= 400) http_response_code($code ?: 502);
|
if (!$ok || $code >= 400) http_response_code($code ?: 502);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
|
// ── Playlist m3u8 — fetch + réécriture URLs ────────────────────────────────────
|
||||||
} else {
|
} else {
|
||||||
$ch = curl_init($url);
|
[$code, , $body, $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
|
||||||
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);
|
|
||||||
|
|
||||||
if ($body === false || $code >= 400) {
|
if ($code >= 400) { http_response_code($code); die("Upstream error $code"); }
|
||||||
http_response_code($code ?: 502); die("Upstream error $code");
|
|
||||||
}
|
|
||||||
|
|
||||||
header('Content-Type: application/vnd.apple.mpegurl');
|
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')
|
$proxy_base = (isset($_SERVER['HTTPS']) ? 'https' : 'http')
|
||||||
. '://' . $_SERVER['HTTP_HOST']
|
. '://' . $_SERVER['HTTP_HOST']
|
||||||
. strtok($_SERVER['REQUEST_URI'], '?')
|
. strtok($_SERVER['REQUEST_URI'], '?')
|
||||||
|
|||||||
Reference in New Issue
Block a user