diff --git a/backend/main.py b/backend/main.py index 1a77dea..f38cb54 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,6 @@ from flask import Flask, request, Response, send_from_directory, jsonify import os +import re import requests from flask_cors import CORS import urllib.parse @@ -9,6 +10,44 @@ import yt_dlp import io from urllib.parse import urljoin +# Stream params that have dedicated meaning and must never be treated as headers. +STREAM_RESERVED_PARAMS = {'url'} +# 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'} +# 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. +HEADER_VALUE_BAD_CHARS_RE = re.compile(r'[\x00-\x08\x0a-\x1f\x7f]') +MAX_HEADER_VALUE_LENGTH = 4096 + + +def collect_passthrough_headers(source): + """Treat any request param other than the reserved ones as an HTTP header to + forward to yt-dlp/upstream. Validates names and values to prevent header + injection (CRLF splitting) and disallows transport-level headers.""" + headers = {} + if not source: + return headers + for key in source: + if key.lower() in STREAM_RESERVED_PARAMS: + continue + if key.lower() in STREAM_DISALLOWED_HEADER_NAMES: + continue + if not HEADER_NAME_RE.match(key): + continue + value = source.get(key) + if value is None: + continue + value = str(value) + if not value or len(value) > MAX_HEADER_VALUE_LENGTH: + continue + if HEADER_VALUE_BAD_CHARS_RE.search(value): + continue + header_name = 'Referer' if key.lower() == 'referer' else key + headers[header_name] = value + return headers + # Serve frontend static files under `/static` to avoid colliding with API routes app = Flask(__name__, static_folder='../frontend', static_url_path='/static') app.url_map.strict_slashes = False @@ -503,14 +542,11 @@ def stream_video(): }, } - referer_url = "" - if request.method == 'POST': - referer_url = request.json.get('referer') - else: - referer_url = request.args.get('referer') - if len(referer_url) > 0: - ydl_opts['http_headers']["Referer"] = referer_url - + passthrough_source = request.json if request.method == 'POST' else request.args + passthrough_headers = collect_passthrough_headers(passthrough_source) + dbg(f"passthrough_headers={list(passthrough_headers.keys())}") + ydl_opts['http_headers'].update(passthrough_headers) + with yt_dlp.YoutubeDL(ydl_opts) as ydl: # Extract the info info = ydl.extract_info(video_url, download=False) diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 06cd17f..bbbdee5 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -100,7 +100,8 @@ App.feed = App.feed || {}; const resolved = App.videos.resolveStreamSource(videoData); if (!resolved.url) return; const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; - const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}`; + const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; + const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}`; const isHls = /\.m3u8($|\?)/i.test(resolved.url); const canUseHls = !!(window.Hls && window.Hls.isSupported()); diff --git a/frontend/js/player.js b/frontend/js/player.js index efd1cff..a2f2693 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -76,7 +76,8 @@ App.player = App.player || {}; return; } const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; - const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}`; + const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; + const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}`; 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 e6fe33f..b52df20 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -573,6 +573,7 @@ App.videos = App.videos || {}; const applyPreferredQuality = !options || options.applyPreferredQuality !== false; let sourceUrl = ''; let referer = ''; + let userAgent = ''; if (typeof videoOrUrl === 'string') { sourceUrl = videoOrUrl; } else if (videoOrUrl && typeof videoOrUrl === 'object') { @@ -589,10 +590,16 @@ App.videos = App.videos || {}; if (best.http_headers && (best.http_headers.Referer || best.http_headers.referer)) { referer = best.http_headers.Referer || best.http_headers.referer; } + if (best.http_headers && (best.http_headers['User-Agent'] || best.http_headers['user-agent'])) { + userAgent = best.http_headers['User-Agent'] || best.http_headers['user-agent']; + } } if (!referer && meta.http_headers && (meta.http_headers.Referer || meta.http_headers.referer)) { referer = meta.http_headers.Referer || meta.http_headers.referer; } + if (!userAgent && meta.http_headers && (meta.http_headers['User-Agent'] || meta.http_headers['user-agent'])) { + userAgent = meta.http_headers['User-Agent'] || meta.http_headers['user-agent']; + } } if (!referer && sourceUrl) { try { @@ -601,15 +608,17 @@ App.videos = App.videos || {}; referer = ''; } } - return { url: sourceUrl, referer }; + return { url: sourceUrl, referer, userAgent }; }; - // Builds a proxied stream URL with an optional referer parameter. + // Builds a proxied stream URL. Extra params other than `url` are forwarded + // by the backend as request headers, so use real header names here. App.videos.buildStreamUrl = function(videoOrUrl, options) { const resolved = App.videos.resolveStreamSource(videoOrUrl, options); if (!resolved.url) return ''; const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : ''; - return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}`; + const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : ''; + return `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}`; }; App.videos.downloadVideo = function(video) {