diff --git a/backend/main.py b/backend/main.py index e9f4174..88c5749 100644 --- a/backend/main.py +++ b/backend/main.py @@ -33,10 +33,19 @@ def get_impersonate_session(): return sess # Stream params that have dedicated meaning and must never be treated as headers. -STREAM_RESERVED_PARAMS = {'url'} +# `referer` is mapped to a real Referer header by collect_passthrough_headers, but +# `live` is purely a playback hint and must not leak upstream as a header. +STREAM_RESERVED_PARAMS = {'url', 'live'} # Headers that affect the transport layer rather than the resource itself; allowing # these to be forwarded could enable request smuggling or vhost-routing abuse. STREAM_DISALLOWED_HEADER_NAMES = {'host', 'content-length', 'transfer-encoding', 'connection', 'expect'} +# Headers curl_cffi sets coherently for the impersonated browser. Forwarding the +# client's (or extractor's) own values for these would contradict the spoofed TLS +# fingerprint and defeat impersonation, so they are never relayed upstream. +STREAM_IMPERSONATION_MANAGED_HEADERS = { + 'user-agent', 'accept', 'accept-encoding', 'accept-language', + 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', +} # RFC 7230 token charset for header field-names. HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") # Reject control characters (CR/LF/NUL etc.) that could be used for header injection. @@ -430,6 +439,17 @@ def stream_video(): headers['Cookie'] = request.headers['Cookie'] dbg("forwarding cookies") + # Relay the per-format headers (e.g. Cookie) the frontend forwarded as + # query params so cookie/token-authorized origins serve the media. + # Referer is already set above, and impersonation-managed headers are left + # to curl_cffi to keep the request coherent with the spoofed fingerprint. + for key, value in collect_passthrough_headers(request.args).items(): + lower = key.lower() + if lower == 'referer' or lower in STREAM_IMPERSONATION_MANAGED_HEADERS: + continue + if value: + headers[key] = value + # Remove keys with None values return {k: v for k, v in headers.items() if v} @@ -556,10 +576,27 @@ def stream_video(): except LookupError: return body_bytes.decode("utf-8", errors="replace") + def passthrough_param_suffix(): + # The relayed headers (e.g. Cookie) the upstream needs for authorization, + # encoded as &Name=value so they ride along on every proxied child URL + # (variant playlists, segments). Referer is appended separately by each + # rewriter; impersonation-managed headers stay with curl_cffi. + parts = [] + for key, value in collect_passthrough_headers(request.args).items(): + lower = key.lower() + if lower == 'referer' or lower in STREAM_IMPERSONATION_MANAGED_HEADERS: + continue + if not value: + continue + parts.append(f"&{urllib.parse.quote(key)}={urllib.parse.quote(str(value))}") + return ''.join(parts) + def rewrite_hls_playlist(body_text, base_url, referer): + extra = passthrough_param_suffix() + def proxied_url(target): absolute = urljoin(base_url, target) - return f"/api/stream?url={urllib.parse.quote(absolute, safe='')}&referer={urllib.parse.quote(referer, safe='')}" + return f"/api/stream?url={urllib.parse.quote(absolute, safe='')}&referer={urllib.parse.quote(referer, safe='')}{extra}" lines = body_text.splitlines() rewritten = [] @@ -720,9 +757,11 @@ def stream_video(): if not video_fmts: return None + extra = passthrough_param_suffix() + def proxied(url): return (f"/api/stream?url={urllib.parse.quote(url, safe='')}" - f"&referer={urllib.parse.quote(referer, safe='')}") + f"&referer={urllib.parse.quote(referer, safe='')}{extra}") lines = ['#EXTM3U', '#EXT-X-VERSION:3'] diff --git a/frontend/js/feed.js b/frontend/js/feed.js index a3bd5c6..638ce7c 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -229,10 +229,7 @@ App.feed = App.feed || {}; const resolved = App.videos.resolveStreamSource(videoData); if (!resolved.url) return; - const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; - const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; - const liveParam = resolved.isLive ? '&live=1' : ''; - const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`; + const streamUrl = App.videos.buildStreamUrlFromSource(resolved); const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url); video.muted = state.feedMuted; diff --git a/frontend/js/player.js b/frontend/js/player.js index 3e5d359..4f03dc0 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -157,10 +157,7 @@ App.player = App.player || {}; if (entry.direct) { streamUrl = resolved.url; } else { - const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; - const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; - const liveParam = resolved.isLive ? '&live=1' : ''; - streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`; + streamUrl = App.videos.buildStreamUrlFromSource(resolved); } let isHls = /\.m3u8($|\?)/i.test(resolved.url); let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url); diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 592a480..5499723 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -860,6 +860,23 @@ App.videos = App.videos || {}; return headers[name] || headers[name.toLowerCase()] || ''; }; + // Merge the resource-level (meta) and format-level http_headers into a single + // map so every upstream header the extractor attached (Referer, User-Agent, + // Cookie, etc.) can be relayed to the stream proxy. Format-level headers win + // on conflict since they describe the specific media URL. + const mergeHeaders = function(metaHeaders, fmtHeaders) { + const merged = {}; + [metaHeaders, fmtHeaders].forEach((headers) => { + if (!headers || typeof headers !== 'object') return; + Object.keys(headers).forEach((name) => { + const value = headers[name]; + if (value === undefined || value === null || value === '') return; + merged[name] = String(value); + }); + }); + return merged; + }; + const deriveReferer = function(url) { if (!url) return ''; try { @@ -958,7 +975,8 @@ App.videos = App.videos || {}; const explicitReferer = headerValue(fmt.http_headers, 'Referer') || metaReferer; const referer = explicitReferer || deriveReferer(fmt.url); const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent; - return { url: fmt.url, referer, userAgent, isLive, refererRequired: !!explicitReferer }; + const headers = mergeHeaders(meta.http_headers, fmt.http_headers); + return { url: fmt.url, referer, userAgent, headers, isLive, refererRequired: !!explicitReferer }; }); if (!sources.length) { @@ -968,6 +986,7 @@ App.videos = App.videos || {}; url: fallbackUrl, referer: metaReferer || deriveReferer(fallbackUrl), userAgent: metaUserAgent, + headers: mergeHeaders(meta.http_headers, null), isLive, refererRequired: !!metaReferer }); @@ -1131,10 +1150,28 @@ App.videos = App.videos || {}; // by the backend as request headers, so use real header names here. App.videos.buildStreamUrlFromSource = function(resolved) { if (!resolved || !resolved.url) return ''; - const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; - const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; - const liveParam = resolved.isLive ? '&live=1' : ''; - return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`; + const params = []; + // Referer keeps its dedicated lowercase param (the backend maps it back to + // `Referer`) so the derived-referer fallback in resolveStreamSources is + // honoured even when no explicit Referer header was present. + if (resolved.referer) params.push(`referer=${encodeURIComponent(resolved.referer)}`); + if (resolved.userAgent) params.push(`User-Agent=${encodeURIComponent(resolved.userAgent)}`); + // Relay every other upstream header the extractor attached (e.g. Cookie). + // Referer/User-Agent are already emitted above, so skip them here to avoid + // duplicating the same header under two query keys. + const headers = resolved.headers; + if (headers && typeof headers === 'object') { + Object.keys(headers).forEach((name) => { + const lower = name.toLowerCase(); + if (lower === 'referer' || lower === 'user-agent') return; + const value = headers[name]; + if (value === undefined || value === null || value === '') return; + params.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`); + }); + } + if (resolved.isLive) params.push('live=1'); + const query = params.length ? `&${params.join('&')}` : ''; + return `/api/stream?url=${encodeURIComponent(resolved.url)}${query}`; }; App.videos.buildStreamUrl = function(videoOrUrl, options) {