From 29cd9a86882964d4aba44fdef42aaa223eb53977 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Sun, 13 Sep 2026 12:26:48 +0530 Subject: [PATCH] fix(security): prevent option injection, add SSRF URL filtering, harden parameters and XSS escaping --- .gitignore | 24 +++- backend/src/main.rs | 16 ++- backend/src/routes/info.rs | 23 ++- backend/src/routes/instagram.rs | 58 ++++++-- backend/src/routes/youtube.rs | 65 ++++++++- backend/src/services/instagram.rs | 16 ++- backend/src/services/youtube.rs | 77 +++++----- backend/src/services/ytdlp.rs | 20 ++- backend/src/utils.rs | 231 ++++++++++++++++++++++++------ frontend/index.html | 19 ++- setup.sh | 56 -------- 11 files changed, 421 insertions(+), 184 deletions(-) delete mode 100755 setup.sh diff --git a/.gitignore b/.gitignore index 64d1645..bea63c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,30 @@ +# Python __pycache__/ *.py[cod] *$py.class -.env .venv/ venv/ ENV/ -*.log -.DS_Store + +# Rust target/ +Cargo.lock.bak + +# Downloads & runtime artifacts downloads/ yt-dlp +*.log +*.pid + +# Credentials, secrets, sessions +.env +.env.* +ig_session* +*.pem +*.key +*.crt + +# OS & Editor +.DS_Store +.idea/ +.vscode/ diff --git a/backend/src/main.rs b/backend/src/main.rs index 7b3b4ee..a098a3d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -64,6 +64,19 @@ async fn main() { // Static file serving for /files/* let serve_dir = ServeDir::new(&config.download_dir); + let cors = CorsLayer::new() + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::OPTIONS, + ]) + .allow_origin(tower_http::cors::Any) + .allow_headers([ + axum::http::header::CONTENT_TYPE, + axum::http::header::ACCEPT, + axum::http::header::AUTHORIZATION, + ]); + let app = Router::new() .route("/", get(root_handler)) .route("/health", get(health_handler)) @@ -71,7 +84,8 @@ async fn main() { .nest("/api/youtube", yt_routes) .nest("/api/instagram", ig_routes) .nest_service("/files", serve_dir) - .layer(CorsLayer::permissive()) + .layer(axum::extract::DefaultBodyLimit::max(64 * 1024)) + .layer(cors) .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) .with_state(config.clone()); diff --git a/backend/src/routes/info.rs b/backend/src/routes/info.rs index ddfb238..72fa2cd 100644 --- a/backend/src/routes/info.rs +++ b/backend/src/routes/info.rs @@ -1,22 +1,16 @@ use axum::extract::State; use axum::Json; -use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::sync::LazyLock; use crate::config::AppConfig; use crate::error::AppError; use crate::services::instagram::InstagramService; use crate::services::youtube::YouTubeService; -use crate::utils::{clean_ig_url, clean_yt_url, extract_ig_username, is_ig_profile_url}; - -static YT_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"(youtube\.com|youtu\.be)").expect("valid regex") -}); -static IG_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"(instagram\.com|instagr\.am)").expect("valid regex") -}); +use crate::utils::{ + clean_ig_url, clean_yt_url, extract_ig_username, is_ig_profile_url, is_valid_instagram_url, + is_valid_youtube_url, +}; #[derive(Deserialize)] pub struct InfoRequest { @@ -30,9 +24,10 @@ pub struct DetectResponse { } pub fn detect_platform(url: &str) -> &'static str { - if YT_RE.is_match(url) { + let trimmed = url.trim(); + if is_valid_youtube_url(trimmed) { "youtube" - } else if IG_RE.is_match(url) { + } else if is_valid_instagram_url(trimmed) { "instagram" } else { "unknown" @@ -43,7 +38,7 @@ pub async fn detect(Json(req): Json) -> Result let platform = detect_platform(&req.url); if platform == "unknown" { return Err(AppError::BadRequest( - "could not detect platform from that url".to_string(), + "Unsupported or invalid URL — only YouTube and Instagram are supported".to_string(), )); } Ok(Json(DetectResponse { @@ -79,7 +74,7 @@ pub async fn preview( } _ => { return Err(AppError::BadRequest( - "unsupported platform — only YouTube and Instagram are supported".to_string(), + "Unsupported or invalid URL — only YouTube and Instagram are supported".to_string(), )); } }; diff --git a/backend/src/routes/instagram.rs b/backend/src/routes/instagram.rs index c64ced2..f16a8b7 100644 --- a/backend/src/routes/instagram.rs +++ b/backend/src/routes/instagram.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use crate::config::AppConfig; use crate::error::AppError; use crate::services::instagram::InstagramService; -use crate::utils::{clean_ig_url, extract_ig_username}; +use crate::utils::{clean_ig_url, extract_ig_username, is_valid_ig_username, is_valid_instagram_url, is_valid_time_format}; #[derive(Deserialize)] pub struct PostRequest { @@ -35,11 +35,29 @@ pub async fn post( State(config): State, Json(req): Json, ) -> Result { + let raw_url = req.url.trim(); + if !is_valid_instagram_url(raw_url) { + return Err(AppError::BadRequest("Invalid or unsupported Instagram URL".to_string())); + } + if req.format != "mp4" { + return Err(AppError::BadRequest("Invalid format for Instagram video — only mp4 is supported".to_string())); + } + if let Some(ref st) = req.start_time { + if !is_valid_time_format(st) { + return Err(AppError::BadRequest("Invalid start time format".to_string())); + } + } + if let Some(ref et) = req.end_time { + if !is_valid_time_format(et) { + return Err(AppError::BadRequest("Invalid end time format".to_string())); + } + } + let _permit = config.semaphore.acquire().await.map_err(|_| { AppError::Internal("Server busy, please retry shortly".to_string()) })?; - let url = clean_ig_url(req.url.trim()); + let url = clean_ig_url(raw_url); let (path, filename) = InstagramService::download_post( &config.ytdlp_bin, &config.download_dir, @@ -57,11 +75,26 @@ pub async fn reel( State(config): State, Json(req): Json, ) -> Result { + let raw_url = req.url.trim(); + if !is_valid_instagram_url(raw_url) { + return Err(AppError::BadRequest("Invalid or unsupported Instagram URL".to_string())); + } + if let Some(ref st) = req.start_time { + if !is_valid_time_format(st) { + return Err(AppError::BadRequest("Invalid start time format".to_string())); + } + } + if let Some(ref et) = req.end_time { + if !is_valid_time_format(et) { + return Err(AppError::BadRequest("Invalid end time format".to_string())); + } + } + let _permit = config.semaphore.acquire().await.map_err(|_| { AppError::Internal("Server busy, please retry shortly".to_string()) })?; - let url = clean_ig_url(req.url.trim()); + let url = clean_ig_url(raw_url); let (path, filename) = InstagramService::download_reel( &config.ytdlp_bin, &config.download_dir, @@ -78,10 +111,6 @@ pub async fn profile( State(config): State, Json(req): Json, ) -> Result { - let _permit = config.semaphore.acquire().await.map_err(|_| { - AppError::Internal("Server busy, please retry shortly".to_string()) - })?; - let raw = req.username.trim(); let username = if raw.contains("instagram.com") { extract_ig_username(raw).map_err(|e| AppError::BadRequest(e))? @@ -89,10 +118,14 @@ pub async fn profile( raw.trim_start_matches('@').to_string() }; - if username.is_empty() { - return Err(AppError::BadRequest("no username provided".to_string())); + if username.is_empty() || !is_valid_ig_username(&username) { + return Err(AppError::BadRequest("Invalid Instagram username provided".to_string())); } + let _permit = config.semaphore.acquire().await.map_err(|_| { + AppError::Internal("Server busy, please retry shortly".to_string()) + })?; + let (path, filename) = InstagramService::download_profile( &config.ytdlp_bin, &config.download_dir, @@ -107,11 +140,16 @@ pub async fn thumbnail( State(config): State, Json(req): Json, ) -> Result { + let raw_url = req.url.trim(); + if !is_valid_instagram_url(raw_url) { + return Err(AppError::BadRequest("Invalid or unsupported Instagram URL".to_string())); + } + let _permit = config.semaphore.acquire().await.map_err(|_| { AppError::Internal("Server busy, please retry shortly".to_string()) })?; - let url = clean_ig_url(req.url.trim()); + let url = clean_ig_url(raw_url); let (path, filename) = InstagramService::download_thumbnail( &config.ytdlp_bin, &config.download_dir, diff --git a/backend/src/routes/youtube.rs b/backend/src/routes/youtube.rs index d2b0ce9..6404c24 100644 --- a/backend/src/routes/youtube.rs +++ b/backend/src/routes/youtube.rs @@ -7,7 +7,10 @@ use serde_json::json; use crate::config::AppConfig; use crate::error::AppError; use crate::services::youtube::YouTubeService; -use crate::utils::clean_yt_url; +use crate::utils::{ + clean_yt_url, is_valid_audio_format, is_valid_audio_quality, is_valid_lang_code, + is_valid_resolution, is_valid_time_format, is_valid_video_format, is_valid_youtube_url, +}; #[derive(Deserialize)] pub struct VideoRequest { @@ -68,11 +71,32 @@ pub async fn video( State(config): State, Json(req): Json, ) -> Result { + let raw_url = req.url.trim(); + if !is_valid_youtube_url(raw_url) { + return Err(AppError::BadRequest("Invalid or unsupported YouTube URL".to_string())); + } + if !is_valid_resolution(&req.resolution) { + return Err(AppError::BadRequest("Invalid video resolution specified".to_string())); + } + if !is_valid_video_format(&req.format) { + return Err(AppError::BadRequest("Invalid video format specified".to_string())); + } + if let Some(ref st) = req.start_time { + if !is_valid_time_format(st) { + return Err(AppError::BadRequest("Invalid start time format".to_string())); + } + } + if let Some(ref et) = req.end_time { + if !is_valid_time_format(et) { + return Err(AppError::BadRequest("Invalid end time format".to_string())); + } + } + let _permit = config.semaphore.acquire().await.map_err(|_| { AppError::Internal("Server busy, please retry shortly".to_string()) })?; - let url = clean_yt_url(&req.url.trim()); + let url = clean_yt_url(raw_url); let (path, filename) = YouTubeService::download_video( &config.ytdlp_bin, &config.download_dir, @@ -91,11 +115,22 @@ pub async fn audio( State(config): State, Json(req): Json, ) -> Result { + let raw_url = req.url.trim(); + if !is_valid_youtube_url(raw_url) { + return Err(AppError::BadRequest("Invalid or unsupported YouTube URL".to_string())); + } + if !is_valid_audio_quality(&req.quality) { + return Err(AppError::BadRequest("Invalid audio quality specified".to_string())); + } + if !is_valid_audio_format(&req.format) { + return Err(AppError::BadRequest("Invalid audio format specified".to_string())); + } + let _permit = config.semaphore.acquire().await.map_err(|_| { AppError::Internal("Server busy, please retry shortly".to_string()) })?; - let url = clean_yt_url(&req.url.trim()); + let url = clean_yt_url(raw_url); let (path, filename) = YouTubeService::download_audio( &config.ytdlp_bin, &config.download_dir, @@ -112,11 +147,16 @@ pub async fn thumbnail( State(config): State, Json(req): Json, ) -> Result { + let raw_url = req.url.trim(); + if !is_valid_youtube_url(raw_url) { + return Err(AppError::BadRequest("Invalid or unsupported YouTube URL".to_string())); + } + let _permit = config.semaphore.acquire().await.map_err(|_| { AppError::Internal("Server busy, please retry shortly".to_string()) })?; - let url = clean_yt_url(&req.url.trim()); + let url = clean_yt_url(raw_url); let (path, filename) = YouTubeService::download_thumbnail( &config.ytdlp_bin, &config.download_dir, @@ -131,11 +171,19 @@ pub async fn subtitles( State(config): State, Json(req): Json, ) -> Result { + let raw_url = req.url.trim(); + if !is_valid_youtube_url(raw_url) { + return Err(AppError::BadRequest("Invalid or unsupported YouTube URL".to_string())); + } + if !is_valid_lang_code(&req.lang) { + return Err(AppError::BadRequest("Invalid language code specified".to_string())); + } + let _permit = config.semaphore.acquire().await.map_err(|_| { AppError::Internal("Server busy, please retry shortly".to_string()) })?; - let url = clean_yt_url(&req.url.trim()); + let url = clean_yt_url(raw_url); let (path, filename) = YouTubeService::download_subtitles( &config.ytdlp_bin, &config.download_dir, @@ -151,6 +199,11 @@ pub async fn channel_art( State(config): State, Json(req): Json, ) -> Result, AppError> { + let channel_url = req.channel_url.trim(); + if !is_valid_youtube_url(channel_url) { + return Err(AppError::BadRequest("Invalid YouTube channel URL".to_string())); + } + let _permit = config.semaphore.acquire().await.map_err(|_| { AppError::Internal("Server busy, please retry shortly".to_string()) })?; @@ -158,7 +211,7 @@ pub async fn channel_art( let assets = YouTubeService::download_channel_art( &config.ytdlp_bin, &config.download_dir, - req.channel_url.trim(), + channel_url, ) .await?; diff --git a/backend/src/services/instagram.rs b/backend/src/services/instagram.rs index 7961e74..8db060d 100644 --- a/backend/src/services/instagram.rs +++ b/backend/src/services/instagram.rs @@ -89,10 +89,9 @@ impl InstagramService { "-f", "bestvideo+bestaudio/best", "--merge-output-format", fmt, "-o", &out_tmpl, - url, ]; - YtDlp::execute(bin, &args).await?; + YtDlp::execute(bin, &args, url).await?; let mut final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix) .ok_or_else(|| AppError::NotFound("Instagram post file not found".to_string()))?; @@ -124,10 +123,9 @@ impl InstagramService { "-f", "bestvideo+bestaudio/best", "--merge-output-format", "mp4", "-o", &out_tmpl, - url, ]; - YtDlp::execute(bin, &args).await?; + YtDlp::execute(bin, &args, url).await?; let mut final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix) .ok_or_else(|| AppError::NotFound("Instagram reel file not found".to_string()))?; @@ -157,12 +155,15 @@ impl InstagramService { .and_then(|v| v.as_str()) .ok_or_else(|| AppError::NotFound("No thumbnail found for Instagram post".to_string()))?; + let parsed_url = crate::utils::parse_safe_url(thumb_url) + .map_err(|e| AppError::BadRequest(format!("Invalid thumbnail URL: {}", e)))?; + let filename = format!("ig_thumb_{}.jpg", uid); let dest = download_dir.join(&filename); let client = reqwest::Client::new(); let bytes = client - .get(thumb_url) + .get(parsed_url) .send() .await .map_err(|e| AppError::Internal(format!("Failed to fetch thumbnail: {}", e)))? @@ -189,13 +190,16 @@ impl InstagramService { .and_then(|v| v.as_str()) .ok_or_else(|| AppError::NotFound(format!("Could not find profile picture for @{}", username)))?; + let parsed_url = crate::utils::parse_safe_url(thumb_url) + .map_err(|e| AppError::BadRequest(format!("Invalid profile picture URL: {}", e)))?; + let safe_user = safe_filename(username); let filename = format!("{}_profile.jpg", safe_user); let dest = download_dir.join(format!("ig_pfp_{}_{}.jpg", safe_user, uid)); let client = reqwest::Client::new(); let bytes = client - .get(thumb_url) + .get(parsed_url) .send() .await .map_err(|e| AppError::Internal(format!("Failed to fetch profile picture: {}", e)))? diff --git a/backend/src/services/youtube.rs b/backend/src/services/youtube.rs index e6970fd..11e2d65 100644 --- a/backend/src/services/youtube.rs +++ b/backend/src/services/youtube.rs @@ -119,23 +119,25 @@ impl YouTubeService { let start_owned; let end_owned; if let Some(st) = start_time { - start_owned = format!("*{}", st); - args.push("--download-sections"); - args.push(&start_owned); + if crate::utils::is_valid_time_format(st) { + start_owned = format!("*{}", st); + args.push("--download-sections"); + args.push(&start_owned); + } } else if let Some(et) = end_time { - end_owned = format!("*-{}", et); - args.push("--download-sections"); - args.push(&end_owned); + if crate::utils::is_valid_time_format(et) { + end_owned = format!("*-{}", et); + args.push("--download-sections"); + args.push(&end_owned); + } } - args.push(url); - - YtDlp::execute(bin, &args).await?; + YtDlp::execute(bin, &args, url).await?; let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix) .ok_or_else(|| AppError::NotFound("Output video file not found".to_string()))?; - // Extract video title + // Extract video title safely let title = match YtDlp::dump_json(bin, url).await { Ok(json) => safe_filename(json.get("title").and_then(|v| v.as_str()).unwrap_or("video")), Err(_) => "video".to_string(), @@ -162,6 +164,7 @@ impl YouTubeService { "opus" => "opus", "flac" => "flac", "m4a" => "m4a", + "wav" => "wav", _ => "mp3", }; @@ -170,10 +173,9 @@ impl YouTubeService { "--audio-format", codec, "--audio-quality", quality, "-o", &out_tmpl, - url, ]; - YtDlp::execute(bin, &args).await?; + YtDlp::execute(bin, &args, url).await?; let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix) .ok_or_else(|| AppError::NotFound("Audio file not found".to_string()))?; @@ -202,10 +204,9 @@ impl YouTubeService { "--skip-download", "--write-thumbnail", "-o", &out_tmpl, - url, ]; - YtDlp::execute(bin, &args).await?; + YtDlp::execute(bin, &args, url).await?; let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix) .ok_or_else(|| AppError::NotFound("Thumbnail file not found".to_string()))?; @@ -238,10 +239,9 @@ impl YouTubeService { "--sub-lang", lang, "--sub-format", "srt", "-o", &out_tmpl, - url, ]; - YtDlp::execute(bin, &args).await?; + YtDlp::execute(bin, &args, url).await?; let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix) .ok_or_else(|| AppError::NotFound(format!("No subtitles found for lang={}", lang)))?; @@ -289,16 +289,19 @@ impl YouTubeService { } if let Some((_, best_avatar_url)) = avatar_candidates.into_iter().max_by_key(|c| c.0) { - let filename = format!("{}_avatar_{}.jpg", channel_name, uid); - let dest = download_dir.join(&filename); - if let Ok(resp) = client.get(best_avatar_url).send().await { - if let Ok(bytes) = resp.bytes().await { - let _ = tokio::fs::write(&dest, bytes).await; - assets.push(ChannelAsset { - asset_type: "avatar".to_string(), - filename: filename.clone(), - url: format!("/files/{}", filename), - }); + // Ensure avatar URL is valid safe HTTPS URL + if let Ok(parsed_url) = crate::utils::parse_safe_url(best_avatar_url) { + let filename = format!("{}_avatar_{}.jpg", channel_name, uid); + let dest = download_dir.join(&filename); + if let Ok(resp) = client.get(parsed_url).send().await { + if let Ok(bytes) = resp.bytes().await { + let _ = tokio::fs::write(&dest, bytes).await; + assets.push(ChannelAsset { + asset_type: "avatar".to_string(), + filename: filename.clone(), + url: format!("/files/{}", filename), + }); + } } } } @@ -316,16 +319,18 @@ impl YouTubeService { } if let Some((_, best_banner_url)) = banner_candidates.into_iter().max_by_key(|c| c.0) { - let filename = format!("{}_banner_{}.jpg", channel_name, uid); - let dest = download_dir.join(&filename); - if let Ok(resp) = client.get(best_banner_url).send().await { - if let Ok(bytes) = resp.bytes().await { - let _ = tokio::fs::write(&dest, bytes).await; - assets.push(ChannelAsset { - asset_type: "banner".to_string(), - filename: filename.clone(), - url: format!("/files/{}", filename), - }); + if let Ok(parsed_url) = crate::utils::parse_safe_url(best_banner_url) { + let filename = format!("{}_banner_{}.jpg", channel_name, uid); + let dest = download_dir.join(&filename); + if let Ok(resp) = client.get(parsed_url).send().await { + if let Ok(bytes) = resp.bytes().await { + let _ = tokio::fs::write(&dest, bytes).await; + assets.push(ChannelAsset { + asset_type: "banner".to_string(), + filename: filename.clone(), + url: format!("/files/{}", filename), + }); + } } } } diff --git a/backend/src/services/ytdlp.rs b/backend/src/services/ytdlp.rs index 92ff77a..87ef6d2 100644 --- a/backend/src/services/ytdlp.rs +++ b/backend/src/services/ytdlp.rs @@ -24,6 +24,8 @@ impl YtDlp { cmd.arg("--js-runtimes").arg("node"); } + // Use "--" delimiter to guarantee the URL is never parsed as a CLI flag + cmd.arg("--"); cmd.arg(url); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); @@ -46,8 +48,8 @@ impl YtDlp { Ok(json_val) } - /// Runs yt-dlp with custom arguments - pub async fn execute(bin: &str, args: &[&str]) -> Result<(), AppError> { + /// Runs yt-dlp with custom arguments and a target URL + pub async fn execute(bin: &str, args: &[&str], url: &str) -> Result<(), AppError> { let mut cmd = Command::new(bin); cmd.args(args); cmd.arg("--no-warnings").arg("--no-playlist"); @@ -56,9 +58,13 @@ impl YtDlp { cmd.arg("--js-runtimes").arg("node"); } + // Use "--" delimiter to guarantee the URL is never parsed as a CLI flag + cmd.arg("--"); + cmd.arg(url); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); - info!("Executing yt-dlp with {} args", args.len()); + info!("Executing yt-dlp with {} flags against target", args.len()); let output = cmd.output().await.map_err(|e| { AppError::Internal(format!("Failed to run yt-dlp: {}", e)) })?; @@ -86,12 +92,16 @@ impl YtDlp { cmd.arg("-y"); if let Some(start) = start_time { - cmd.arg("-ss").arg(start); + if crate::utils::is_valid_time_format(start) { + cmd.arg("-ss").arg(start); + } } cmd.arg("-i").arg(src); if let Some(end) = end_time { - cmd.arg("-to").arg(end); + if crate::utils::is_valid_time_format(end) { + cmd.arg("-to").arg(end); + } } cmd.arg("-c").arg("copy").arg(dest); diff --git a/backend/src/utils.rs b/backend/src/utils.rs index 69b7a51..29dfeee 100644 --- a/backend/src/utils.rs +++ b/backend/src/utils.rs @@ -1,32 +1,154 @@ use regex::Regex; +use reqwest::Url; use std::sync::LazyLock; static YT_V_RE: LazyLock = LazyLock::new(|| { Regex::new(r"[?&]v=([A-Za-z0-9_-]{11})").expect("valid regex") }); static YT_SHORT_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"https?://youtu\.be/([A-Za-z0-9_-]{11})").expect("valid regex") + Regex::new(r"^https?://youtu\.be/([A-Za-z0-9_-]{11})").expect("valid regex") }); -#[allow(dead_code)] -static IG_SHORTCODE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"/(?:p|reel|reels|tv)/([A-Za-z0-9_-]+)").expect("valid regex") +static TIME_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(\d{1,2}:)?\d{1,2}:\d{2}(\.\d+)?$|^\d+(\.\d+)?$").expect("valid regex") +}); +static LANG_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,10})?$").expect("valid regex") +}); +static IG_USER_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^[a-zA-Z0-9._]{1,30}$").expect("valid regex") }); static SAFE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"[\\/*?:"<>|]"#).expect("valid regex") + Regex::new(r#"[^a-zA-Z0-9._-]"#).expect("valid regex") }); +/// Validates that a string is a safe HTTP/HTTPS URL and returns the parsed URL. +pub fn parse_safe_url(raw: &str) -> Result { + let raw = raw.trim(); + if raw.len() > 2048 { + return Err("URL exceeds maximum allowed length"); + } + if raw.starts_with('-') || raw.contains('\0') || raw.contains('\n') || raw.contains('\r') { + return Err("Invalid characters in URL"); + } + let parsed = Url::parse(raw).map_err(|_| "Malformed URL format")?; + let scheme = parsed.scheme(); + if scheme != "http" && scheme != "https" { + return Err("Only HTTP and HTTPS URLs are allowed"); + } + let host = match parsed.host_str() { + Some(h) => h.to_ascii_lowercase(), + None => return Err("URL must have a valid hostname"), + }; + + // Block localhost, IP addresses, private IP ranges (anti-SSRF) + if host == "localhost" + || host == "127.0.0.1" + || host == "0.0.0.0" + || host == "::1" + || host.starts_with("192.168.") + || host.starts_with("10.") + || host.starts_with("172.") + || host.starts_with("169.254.") + { + return Err("Requests to local or private networks are forbidden"); + } + + Ok(parsed) +} + +/// Checks if a URL is a valid, allowed YouTube URL +pub fn is_valid_youtube_url(url: &str) -> bool { + match parse_safe_url(url) { + Ok(parsed) => { + if let Some(host) = parsed.host_str() { + let host = host.to_ascii_lowercase(); + host == "youtube.com" + || host == "www.youtube.com" + || host == "m.youtube.com" + || host == "music.youtube.com" + || host == "youtu.be" + || host == "www.youtu.be" + || host.ends_with(".youtube.com") + } else { + false + } + } + Err(_) => false, + } +} + +/// Checks if a URL is a valid, allowed Instagram URL +pub fn is_valid_instagram_url(url: &str) -> bool { + match parse_safe_url(url) { + Ok(parsed) => { + if let Some(host) = parsed.host_str() { + let host = host.to_ascii_lowercase(); + host == "instagram.com" + || host == "www.instagram.com" + || host == "instagr.am" + || host == "www.instagr.am" + || host.ends_with(".instagram.com") + } else { + false + } + } + Err(_) => false, + } +} + +pub fn is_valid_resolution(res: &str) -> bool { + matches!( + res, + "best" + | "144" + | "240" + | "360" + | "480" + | "720" + | "1080" + | "1440" + | "2160" + | "4320" + ) +} + +pub fn is_valid_video_format(fmt: &str) -> bool { + matches!(fmt, "mp4" | "webm" | "mkv") +} + +pub fn is_valid_audio_format(fmt: &str) -> bool { + matches!(fmt, "mp3" | "m4a" | "opus" | "flac" | "wav") +} + +pub fn is_valid_audio_quality(quality: &str) -> bool { + matches!(quality, "0" | "64" | "128" | "192" | "256" | "320") +} + +pub fn is_valid_lang_code(lang: &str) -> bool { + LANG_RE.is_match(lang) && lang.len() <= 15 +} + +pub fn is_valid_time_format(time: &str) -> bool { + TIME_RE.is_match(time) && time.len() <= 15 +} + +pub fn is_valid_ig_username(user: &str) -> bool { + IG_USER_RE.is_match(user) && !user.starts_with('.') && !user.contains("..") +} + pub fn clean_yt_url(url: &str) -> String { - if let Some(caps) = YT_V_RE.captures(url) { + let trimmed = url.trim(); + if let Some(caps) = YT_V_RE.captures(trimmed) { if let Some(id) = caps.get(1) { return format!("https://www.youtube.com/watch?v={}", id.as_str()); } } - if let Some(caps) = YT_SHORT_RE.captures(url) { + if let Some(caps) = YT_SHORT_RE.captures(trimmed) { if let Some(id) = caps.get(1) { return format!("https://youtu.be/{}", id.as_str()); } } - url.to_string() + trimmed.to_string() } pub fn clean_ig_url(url: &str) -> String { @@ -74,29 +196,16 @@ pub fn extract_ig_username(url: &str) -> Result { .trim_start_matches('@') .trim(); - if username.is_empty() { - Err("could not extract username from URL".to_string()) + if username.is_empty() || !is_valid_ig_username(username) { + Err("could not extract valid username from URL".to_string()) } else { Ok(username.to_string()) } } -#[allow(dead_code)] -pub fn extract_ig_shortcode(url: &str) -> Result { - if let Some(caps) = IG_SHORTCODE_RE.captures(url) { - if let Some(sc) = caps.get(1) { - return Ok(sc.as_str().to_string()); - } - } - Err(format!( - "could not extract shortcode from: {} — expected a post, reel, or tv URL", - url - )) -} - pub fn safe_filename(name: &str) -> String { let sanitized = SAFE_RE.replace_all(name, "_"); - let trimmed = sanitized.trim(); + let trimmed = sanitized.trim_matches(|c| c == '.' || c == '_' || c == '-'); if trimmed.is_empty() { "file".to_string() } else if trimmed.len() > 80 { @@ -123,22 +232,62 @@ pub fn format_duration(seconds: Option) -> String { } } -#[allow(dead_code)] -pub fn parse_time_to_seconds(t: &str) -> Option { - let parts: Vec<&str> = t.trim().split(':').collect(); - match parts.len() { - 3 => { - let h = parts[0].parse::().ok()?; - let m = parts[1].parse::().ok()?; - let s = parts[2].parse::().ok()?; - Some(h * 3600.0 + m * 60.0 + s) - } - 2 => { - let m = parts[0].parse::().ok()?; - let s = parts[1].parse::().ok()?; - Some(m * 60.0 + s) - } - 1 => parts[0].parse::().ok(), - _ => None, +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_youtube_urls() { + assert!(is_valid_youtube_url("https://www.youtube.com/watch?v=dQw4w9WgXcQ")); + assert!(is_valid_youtube_url("https://youtu.be/dQw4w9WgXcQ")); + assert!(is_valid_youtube_url("https://m.youtube.com/watch?v=dQw4w9WgXcQ")); + assert!(is_valid_youtube_url("http://youtube.com/shorts/dQw4w9WgXcQ")); + } + + #[test] + fn test_invalid_youtube_urls() { + assert!(!is_valid_youtube_url("file:///etc/passwd")); + assert!(!is_valid_youtube_url("http://localhost:8022#youtube.com")); + assert!(!is_valid_youtube_url("http://127.0.0.1:8000/youtube.com")); + assert!(!is_valid_youtube_url("http://192.168.0.124:8022#youtube.com")); + assert!(!is_valid_youtube_url("http://attacker.com/youtube.com")); + assert!(!is_valid_youtube_url("http://youtube.com.attacker.com")); + assert!(!is_valid_youtube_url("--exec echo pwned")); + } + + #[test] + fn test_valid_instagram_urls() { + assert!(is_valid_instagram_url("https://www.instagram.com/p/DF123/")); + assert!(is_valid_instagram_url("https://instagram.com/reel/C890/")); + } + + #[test] + fn test_invalid_instagram_urls() { + assert!(!is_valid_instagram_url("file:///etc/shadow")); + assert!(!is_valid_instagram_url("http://localhost:3000/instagram.com")); + assert!(!is_valid_instagram_url("http://instagram.com.evil.com")); + } + + #[test] + fn test_resolutions() { + assert!(is_valid_resolution("1080")); + assert!(is_valid_resolution("best")); + assert!(!is_valid_resolution("--exec")); + assert!(!is_valid_resolution("99999")); + } + + #[test] + fn test_formats() { + assert!(is_valid_video_format("mp4")); + assert!(is_valid_video_format("webm")); + assert!(!is_valid_video_format("exe")); + assert!(!is_valid_video_format("--exec")); + } + + #[test] + fn test_safe_filename() { + assert_eq!(safe_filename("../../etc/passwd"), "etc_passwd"); + assert_eq!(safe_filename("great video [1080p].mp4"), "great_video__1080p_.mp4"); + assert_eq!(safe_filename("..."), "file"); } } diff --git a/frontend/index.html b/frontend/index.html index be257a7..f76d409 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1051,15 +1051,22 @@ function renderPreview(data, platform) { document.getElementById('previewCard').classList.add('show'); const thumbEl = document.getElementById('previewThumb'); - if (data.thumbnail) thumbEl.innerHTML = ``; + thumbEl.innerHTML = ''; + if (data.thumbnail && typeof data.thumbnail === 'string' && (data.thumbnail.startsWith('http://') || data.thumbnail.startsWith('https://'))) { + const img = document.createElement('img'); + img.src = data.thumbnail; + img.alt = ''; + img.onerror = () => { img.style.display = 'none'; }; + thumbEl.appendChild(img); + } document.getElementById('previewTitle').textContent = data.title || data.owner || '—'; const color = platform === 'yt' ? 'var(--yt)' : 'var(--ig)'; const pills = []; - if (data.duration_str) pills.push(`${data.duration_str}`); - if (data.view_count) pills.push(`${fmtNum(data.view_count)} views`); - if (data.likes) pills.push(`${fmtNum(data.likes)} ${data.type==='profile'?'followers':'likes'}`); - if (data.uploader) pills.push(`${data.uploader}`); - if (data.owner) pills.push(`@${data.owner}`); + if (data.duration_str) pills.push(`${escapeHtml(data.duration_str)}`); + if (data.view_count) pills.push(`${escapeHtml(fmtNum(data.view_count))} views`); + if (data.likes) pills.push(`${escapeHtml(fmtNum(data.likes))} ${data.type==='profile'?'followers':'likes'}`); + if (data.uploader) pills.push(`${escapeHtml(data.uploader)}`); + if (data.owner) pills.push(`@${escapeHtml(data.owner)}`); document.getElementById('previewMeta').innerHTML = pills.join(''); const res = data.available_resolutions || []; document.getElementById('previewQuality').textContent = res.length ? `// max: ${res[0]}p available` : ''; diff --git a/setup.sh b/setup.sh deleted file mode 100755 index bdf5dcc..0000000 --- a/setup.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -set -e - -echo "==> [1/5] Installing system dependencies (ffmpeg, zram-tools, curl)..." -apt-get update -qq -apt-get install -y -qq ffmpeg zram-tools curl - -echo "==> [2/5] Downloading optimized just-download Rust binary..." -curl -sL https://github.com/oiupoyt/just-download/releases/download/v2.0.0/just-download-backend -o /usr/local/bin/just-download-backend -chmod +x /usr/local/bin/just-download-backend - -echo "==> [3/5] Downloading standalone yt-dlp binary..." -curl -sL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp -chmod +x /usr/local/bin/yt-dlp - -echo "==> [4/5] Configuring directories & systemd service..." -mkdir -p /var/www/just-download/downloads - -cat << 'EOF' > /etc/systemd/system/just-download.service -[Unit] -Description=Just-Download Rust Backend Service -After=network.target - -[Service] -Type=simple -User=root -WorkingDirectory=/var/www/just-download -Environment=HOST=0.0.0.0 -Environment=PORT=8000 -Environment=DOWNLOAD_DIR=/var/www/just-download/downloads -Environment=MAX_CONCURRENT_DOWNLOADS=2 -Environment=MAX_FILE_AGE_SECS=1800 -ExecStart=/usr/local/bin/just-download-backend -Restart=always -RestartSec=3 - -[Install] -WantedBy=multi-user.target -EOF - -echo "==> [5/5] Enabling and starting just-download service..." -systemctl daemon-reload -systemctl enable --now just-download -systemctl restart just-download - -sleep 2 -if systemctl is-active --quiet just-download; then - echo "============================================================" - echo "✓ Success: just-download Rust backend is running on port 8000!" - echo "============================================================" - curl -s http://localhost:8000/health - echo "" -else - echo "❌ Service failed to start. Logs:" - journalctl -u just-download -n 20 --no-pager -fi