Merge remote-tracking branch 'origin/beta'

Improve proxy redirect handling and avoid double-fetching media segments

- validate redirect chains more robustly, including scheme-relative, query-only, fragment, and normalized relative paths
- resolve media segment redirects with HEAD before streaming to avoid downloading the same segment twice
- keep strict host allowlisting in place while making HLS/CDN redirect handling more reliable
This commit is contained in:
Johnnybegood90
2026-03-14 12:20:28 +01:00
2 changed files with 61 additions and 10 deletions
+58 -8
View File
@@ -31,22 +31,72 @@ function is_allowed_url(string $url, array $allowed_hosts): bool {
return $host !== '' && in_array($host, $allowed_hosts, true); 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 { function resolve_url(string $base, string $location): string {
if (preg_match('#^https?://#i', $location)) return $location; if (preg_match('#^https?://#i', $location)) return $location;
$parts = parse_url($base); $parts = parse_url($base);
$origin = $parts['scheme'] . '://' . $parts['host']; $origin = $parts['scheme'] . '://' . $parts['host'];
if (!empty($parts['port'])) $origin .= ':' . $parts['port']; if (!empty($parts['port'])) $origin .= ':' . $parts['port'];
if ($location[0] === '/') return $origin . $location;
return $origin . rtrim(dirname($parts['path'] ?? '/'), '/') . '/' . $location; 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 * Fetch with manual redirect handling so every Location header is checked
* against the allowlist before following it. * against the allowlist before following it.
* $stream = true: stream the response chunk by chunk (video segments)
* $stream = false: return the full body (m3u8 playlists)
*/ */
function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $stream = false): array { function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $head_only = false): array {
$max_redirects = 5; $max_redirects = 5;
for ($i = 0; $i <= $max_redirects; $i++) { for ($i = 0; $i <= $max_redirects; $i++) {
@@ -60,6 +110,7 @@ function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $s
CURLOPT_FOLLOWLOCATION => false, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HEADER => true, CURLOPT_HEADER => true,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_NOBODY => $head_only,
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => $ua, CURLOPT_USERAGENT => $ua,
CURLOPT_HTTPHEADER => ['Accept: */*'], CURLOPT_HTTPHEADER => ['Accept: */*'],
@@ -114,9 +165,8 @@ if ($is_segment) {
if (ob_get_level()) ob_end_clean(); if (ob_get_level()) ob_end_clean();
// For segments, resolve redirects first and only stream once the final URL // Resolve redirects with HEAD first so we do not download the segment twice.
// has already been validated. [$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, true);
[$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
if ($code >= 400) { http_response_code($code); die(); } if ($code >= 400) { http_response_code($code); die(); }
+3 -2
View File
@@ -33,12 +33,13 @@ self.addEventListener('activate', e => {
self.addEventListener('fetch', e => { self.addEventListener('fetch', e => {
const url = new URL(e.request.url); const url = new URL(e.request.url);
// EPG/M3U feeds and the GitHub API stay network-only. // EPG/M3U feeds, the GitHub API, and version.json stay network-only.
if ( if (
url.pathname.includes('xmltv') || url.pathname.includes('xmltv') ||
url.pathname.includes('.m3u') || url.pathname.includes('.m3u') ||
url.hostname === 'api.github.com' || url.hostname === 'api.github.com' ||
url.pathname.includes('proxy.php') url.pathname.includes('proxy.php') ||
url.pathname.endsWith('version.json')
) { ) {
return; return;
} }