fix(proxy): harden system proxy metadata flow

Normalize system proxy values from sysproxy, Windows registry, and environment fallbacks before handing them to download engines.

Apply the resolved proxy to direct metadata probes and yt-dlp media metadata requests so preview and final downloads use the same network path.

Enable reqwest SOCKS support and key media metadata caches by proxy/cookie inputs to avoid stale cross-proxy reuse.
This commit is contained in:
NimBold
2026-07-06 00:41:34 +03:30
parent 017d9cd879
commit c13f993150
6 changed files with 269 additions and 59 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] } tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
regex = "1.10" regex = "1.10"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "socks"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] } ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
tauri-plugin-notification = "2.3.3" tauri-plugin-notification = "2.3.3"
+42 -7
View File
@@ -17,6 +17,10 @@ fn get_metadata_cache() -> &'static std::sync::Mutex<HashMap<String, String>> {
CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())) CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
} }
fn metadata_info_cache_key(url: &str, cookie_source: Option<&str>, proxy: Option<&str>) -> String {
serde_json::json!([url, cookie_source.unwrap_or(""), proxy.unwrap_or("")]).to_string()
}
fn sanitize_metadata_filename(filename: &str) -> Option<String> { fn sanitize_metadata_filename(filename: &str) -> Option<String> {
let normalized = filename.trim().replace('\\', "/"); let normalized = filename.trim().replace('\\', "/");
let basename = std::path::Path::new(&normalized) let basename = std::path::Path::new(&normalized)
@@ -1057,6 +1061,7 @@ async fn fetch_metadata(
password: Option<String>, password: Option<String>,
headers: Option<String>, headers: Option<String>,
cookies: Option<String>, cookies: Option<String>,
proxy: Option<String>,
) -> Result<MetadataResponse, String> { ) -> Result<MetadataResponse, String> {
let mut current_url = url.clone(); let mut current_url = url.clone();
let original_host = reqwest::Url::parse(&url).ok().and_then(|u| u.host_str().map(|s| s.to_string())); let original_host = reqwest::Url::parse(&url).ok().and_then(|u| u.host_str().map(|s| s.to_string()));
@@ -1069,6 +1074,13 @@ async fn fetch_metadata(
} }
let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()); let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());
if let Some(proxy) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
if proxy.eq_ignore_ascii_case("none") {
builder = builder.no_proxy();
} else {
builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|e| e.to_string())?);
}
}
if let Some(ref ua) = user_agent { if let Some(ref ua) = user_agent {
let ua = ua.trim(); let ua = ua.trim();
@@ -1244,12 +1256,14 @@ fn media_metadata_cache_key(
cookie_browser: &Option<String>, cookie_browser: &Option<String>,
username: &Option<String>, username: &Option<String>,
password: &Option<String>, password: &Option<String>,
proxy: &Option<String>,
) -> u64 { ) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new(); let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher); url.hash(&mut hasher);
cookie_browser.hash(&mut hasher); cookie_browser.hash(&mut hasher);
username.hash(&mut hasher); username.hash(&mut hasher);
password.hash(&mut hasher); password.hash(&mut hasher);
proxy.hash(&mut hasher);
hasher.finish() hasher.finish()
} }
@@ -1283,9 +1297,10 @@ async fn fetch_media_metadata(
cookie_browser: Option<String>, cookie_browser: Option<String>,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
proxy: Option<String>,
) -> Result<MediaMetadata, String> { ) -> Result<MediaMetadata, String> {
validate_url_ssrf(&url).await?; validate_url_ssrf(&url).await?;
let cache_key = media_metadata_cache_key(&url, &cookie_browser, &username, &password); let cache_key = media_metadata_cache_key(&url, &cookie_browser, &username, &password, &proxy);
let cache = MEDIA_METADATA_CACHE.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); let cache = MEDIA_METADATA_CACHE.get_or_init(|| tokio::sync::Mutex::new(HashMap::new()));
let mut cache_guard = cache.lock().await; let mut cache_guard = cache.lock().await;
@@ -1316,8 +1331,15 @@ async fn fetch_media_metadata(
} }
drop(cache_guard); drop(cache_guard);
let result = let result = fetch_media_metadata_uncached(
fetch_media_metadata_uncached(app_handle, url, cookie_browser, username, password).await; app_handle,
url,
cookie_browser,
username,
password,
proxy,
)
.await;
let result = match result { let result = match result {
Ok(metadata) if metadata.formats.is_empty() => { Ok(metadata) if metadata.formats.is_empty() => {
@@ -1354,7 +1376,10 @@ async fn fetch_media_metadata_uncached(
cookie_browser: Option<String>, cookie_browser: Option<String>,
username: Option<String>, username: Option<String>,
password: Option<String>, password: Option<String>,
proxy: Option<String>,
) -> Result<MediaMetadata, String> { ) -> Result<MediaMetadata, String> {
let info_cache_key = metadata_info_cache_key(&url, cookie_browser.as_deref(), proxy.as_deref());
// Pass bundled tools by absolute path so extraction never depends on // Pass bundled tools by absolute path so extraction never depends on
// system Python, a user-managed PATH, or auto-detection heuristics. // system Python, a user-managed PATH, or auto-detection heuristics.
let deno_path = resolve_bundled_binary_path(&app_handle, "deno") let deno_path = resolve_bundled_binary_path(&app_handle, "deno")
@@ -1389,9 +1414,17 @@ async fn fetch_media_metadata_uncached(
.arg("--print") .arg("--print")
.arg("%(.{title,duration,thumbnail,formats})j"); .arg("%(.{title,duration,thumbnail,formats})j");
if let Some(browser) = cookie_browser { if let Some(browser) = cookie_browser.as_deref() {
if !browser.is_empty() { if !browser.is_empty() {
cmd = cmd.arg("--cookies-from-browser").arg(&browser); cmd = cmd.arg("--cookies-from-browser").arg(browser);
}
}
if let Some(proxy) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
if proxy.eq_ignore_ascii_case("none") {
cmd = cmd.arg("--proxy").arg("");
} else {
cmd = cmd.arg("--proxy").arg(proxy);
} }
} }
@@ -1437,7 +1470,7 @@ async fn fetch_media_metadata_uncached(
if let Ok(mut cache) = get_metadata_cache().lock() { if let Ok(mut cache) = get_metadata_cache().lock() {
if let Ok(json_str) = String::from_utf8(output.stdout.clone()) { if let Ok(json_str) = String::from_utf8(output.stdout.clone()) {
cache.insert(url.to_string(), json_str); cache.insert(info_cache_key, json_str);
} }
} }
@@ -2720,7 +2753,9 @@ pub(crate) async fn start_media_download_internal(
let mut temp_info_path = None; let mut temp_info_path = None;
if let Ok(mut cache) = get_metadata_cache().lock() { if let Ok(mut cache) = get_metadata_cache().lock() {
if let Some(json_str) = cache.remove(&url) { let info_cache_key =
metadata_info_cache_key(&url, cookie_source.as_deref(), proxy.as_deref());
if let Some(json_str) = cache.remove(&info_cache_key) {
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
let path = temp_dir.join(format!("firelink_ytdlp_{}.info.json", id)); let path = temp_dir.join(format!("firelink_ytdlp_{}.info.json", id));
if std::fs::write(&path, json_str).is_ok() { if std::fs::write(&path, json_str).is_ok() {
+215 -44
View File
@@ -8,12 +8,11 @@ pub async fn get_system_proxy() -> Result<Option<String>, String> {
match sysproxy::Sysproxy::get_system_proxy() { match sysproxy::Sysproxy::get_system_proxy() {
Ok(proxy) => { Ok(proxy) => {
if proxy.enable { if proxy.enable {
let protocol = if proxy.host.contains("://") { if proxy.host.contains('=') {
"" Ok(parse_windows_proxy_server(&proxy.host))
} else { } else {
"http://" Ok(normalize_sysproxy_address(&proxy.host, proxy.port))
}; }
Ok(Some(format!("{}{}:{}", protocol, proxy.host, proxy.port)))
} else { } else {
Ok(None) Ok(None)
} }
@@ -24,21 +23,8 @@ pub async fn get_system_proxy() -> Result<Option<String>, String> {
return Ok(Some(proxy)); return Ok(Some(proxy));
} }
if let Ok(proxy) = std::env::var("HTTP_PROXY") if let Some(proxy) = proxy_from_environment() {
.or_else(|_| std::env::var("http_proxy")) return Ok(Some(proxy));
.or_else(|_| std::env::var("HTTPS_PROXY"))
.or_else(|_| std::env::var("https_proxy"))
.or_else(|_| std::env::var("ALL_PROXY"))
.or_else(|_| std::env::var("all_proxy"))
{
if !proxy.is_empty() {
let protocol = if proxy.contains("://") {
""
} else {
"http://"
};
return Ok(Some(format!("{}{}", protocol, proxy)));
}
} }
Err(format!("failed to read system proxy settings: {error}")) Err(format!("failed to read system proxy settings: {error}"))
@@ -46,12 +32,100 @@ pub async fn get_system_proxy() -> Result<Option<String>, String> {
} }
} }
fn proxy_from_environment() -> Option<String> {
[
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"ALL_PROXY",
"all_proxy",
]
.into_iter()
.find_map(|name| {
std::env::var(name)
.ok()
.and_then(|value| normalize_proxy_address(&value, "http"))
})
}
fn normalize_proxy_address(raw: &str, default_scheme: &str) -> Option<String> {
let trimmed = raw.trim().trim_matches('"').trim_end_matches('/');
if trimmed.is_empty() {
return None;
}
let candidate = if trimmed.contains("://") {
trimmed.to_string()
} else {
format!("{default_scheme}://{trimmed}")
};
let parsed = url::Url::parse(&candidate).ok()?;
match parsed.scheme() {
"http" | "https" | "socks4" | "socks4a" | "socks5" | "socks5h" => {}
_ => return None,
}
parsed.host_str()?;
Some(candidate)
}
fn normalize_sysproxy_address(host: &str, port: u16) -> Option<String> {
let host = host.trim();
if host.is_empty() {
return None;
}
if host.contains("://") {
let mut parsed = url::Url::parse(host).ok()?;
if parsed.port().is_none() && port != 0 {
parsed.set_port(Some(port)).ok()?;
}
return normalize_proxy_address(parsed.as_str(), "http");
}
if port == 0 {
normalize_proxy_address(host, "http")
} else {
normalize_proxy_address(&format!("{host}:{port}"), "http")
}
}
fn parse_windows_proxy_server(value: &str) -> Option<String> {
let value = value.trim().trim_matches('"');
if value.is_empty() {
return None;
}
if !value.contains('=') {
return normalize_proxy_address(value, "http");
}
let mut http = None;
let mut https = None;
let mut socks = None;
for entry in value.split(';') {
let Some((kind, address)) = entry.split_once('=') else {
continue;
};
let kind = kind.trim().to_ascii_lowercase();
let address = address.trim();
match kind.as_str() {
"http" => http = normalize_proxy_address(address, "http"),
"https" => https = normalize_proxy_address(address, "http"),
"socks" => socks = normalize_proxy_address(address, "socks5"),
_ => {}
}
}
https.or(http).or(socks)
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn fallback_windows_proxy() -> Result<Option<String>, ()> { fn fallback_windows_proxy() -> Result<Option<String>, ()> {
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
use std::process::Command; use std::process::Command;
const CREATE_NO_WINDOW: u32 = 0x08000000; const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new("reg") let output = Command::new("reg")
.args(&[ .args(&[
"query", "query",
@@ -62,9 +136,16 @@ fn fallback_windows_proxy() -> Result<Option<String>, ()> {
.creation_flags(CREATE_NO_WINDOW) .creation_flags(CREATE_NO_WINDOW)
.output() .output()
.map_err(|_| ())?; .map_err(|_| ())?;
if !output.status.success() {
return Err(());
}
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.contains("0x1") { let enabled = registry_value(&stdout, "ProxyEnable")
.as_deref()
.is_some_and(windows_proxy_enabled);
if !enabled {
return Ok(None); return Ok(None);
} }
@@ -78,30 +159,120 @@ fn fallback_windows_proxy() -> Result<Option<String>, ()> {
.creation_flags(CREATE_NO_WINDOW) .creation_flags(CREATE_NO_WINDOW)
.output() .output()
.map_err(|_| ())?; .map_err(|_| ())?;
if !output.status.success() {
return Err(());
}
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() { Ok(registry_value(&stdout, "ProxyServer").and_then(|value| parse_windows_proxy_server(&value)))
if line.contains("ProxyServer") { }
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 { #[cfg_attr(not(target_os = "windows"), allow(dead_code))]
let proxy_val = parts[2]; fn windows_proxy_enabled(value: &str) -> bool {
if proxy_val.contains('=') { let value = value.trim();
for part in proxy_val.split(';') { if value == "1" {
if part.starts_with("http=") || part.starts_with("https=") || part.starts_with("socks=") { return true;
let addr = part.split('=').nth(1).unwrap_or(""); }
if !addr.is_empty() { value
let protocol = if part.starts_with("socks") { "socks5://" } else { "http://" }; .strip_prefix("0x")
return Ok(Some(format!("{}{}", protocol, addr))); .or_else(|| value.strip_prefix("0X"))
} .and_then(|hex| u32::from_str_radix(hex, 16).ok())
} .is_some_and(|enabled| enabled == 1)
} }
} else {
return Ok(Some(format!("http://{}", proxy_val))); #[cfg_attr(not(target_os = "windows"), allow(dead_code))]
} fn registry_value(output: &str, name: &str) -> Option<String> {
} for line in output.lines() {
let trimmed = line.trim();
let mut parts = trimmed.split_whitespace();
let Some(key) = parts.next() else {
continue;
};
if !key.eq_ignore_ascii_case(name) {
continue;
}
if parts.next().is_none() {
continue;
}
let data = parts.collect::<Vec<_>>().join(" ");
if !data.is_empty() {
return Some(data);
} }
} }
Ok(None) None
}
#[cfg(test)]
mod proxy_tests {
use super::{
normalize_proxy_address, normalize_sysproxy_address, parse_windows_proxy_server,
registry_value, windows_proxy_enabled,
};
#[test]
fn normalizes_bare_proxy_addresses() {
assert_eq!(
normalize_proxy_address("127.0.0.1:8080", "http").as_deref(),
Some("http://127.0.0.1:8080")
);
assert_eq!(
normalize_proxy_address(" socks5://127.0.0.1:1080/ ", "http").as_deref(),
Some("socks5://127.0.0.1:1080")
);
assert_eq!(normalize_proxy_address("file:///tmp/proxy", "http"), None);
}
#[test]
fn parses_windows_protocol_proxy_server_values() {
assert_eq!(
parse_windows_proxy_server("http=127.0.0.1:8080;https=127.0.0.1:8081").as_deref(),
Some("http://127.0.0.1:8081")
);
assert_eq!(
parse_windows_proxy_server("socks=127.0.0.1:1080").as_deref(),
Some("socks5://127.0.0.1:1080")
);
assert_eq!(
parse_windows_proxy_server("proxy.local:9000").as_deref(),
Some("http://proxy.local:9000")
);
}
#[test]
fn normalizes_sysproxy_host_without_duplicating_ports() {
assert_eq!(
normalize_sysproxy_address("http://proxy.local", 8080).as_deref(),
Some("http://proxy.local:8080")
);
assert_eq!(
normalize_sysproxy_address("http://proxy.local:9000", 8080).as_deref(),
Some("http://proxy.local:9000")
);
assert_eq!(
normalize_sysproxy_address("proxy.local", 8080).as_deref(),
Some("http://proxy.local:8080")
);
}
#[test]
fn parses_reg_query_output_values() {
let output = r#"
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings
ProxyEnable REG_DWORD 0x1
ProxyServer REG_SZ http=127.0.0.1:8080;https=127.0.0.1:8081
"#;
assert!(registry_value(output, "ProxyEnable")
.as_deref()
.is_some_and(windows_proxy_enabled));
assert_eq!(
registry_value(output, "ProxyServer").as_deref(),
Some("http=127.0.0.1:8080;https=127.0.0.1:8081")
);
assert!(!windows_proxy_enabled("0X0"));
assert!(windows_proxy_enabled("0X1"));
}
} }
#[tauri::command] #[tauri::command]
+7 -4
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { import {
useDownloadStore, useDownloadStore,
getSiteLogin, getSiteLogin,
getProxyArgs,
type AddDownloadAction type AddDownloadAction
} from '../store/useDownloadStore'; } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore'; import { useSettingsStore } from '../store/useSettingsStore';
@@ -186,8 +187,9 @@ export const AddDownloadsModal = () => {
void (async () => { void (async () => {
try { try {
const settingsStore = useSettingsStore.getState();
const proxy = await getProxyArgs(settingsStore);
if (row.isMedia) { if (row.isMedia) {
const settingsStore = useSettingsStore.getState();
const { mediaCookieSource } = settingsStore; const { mediaCookieSource } = settingsStore;
const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null; const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null;
const login = getSiteLogin(row.sourceUrl, settingsStore); const login = getSiteLogin(row.sourceUrl, settingsStore);
@@ -204,7 +206,8 @@ export const AddDownloadsModal = () => {
url: row.sourceUrl, url: row.sourceUrl,
cookieBrowser: browserArg, cookieBrowser: browserArg,
username: useAuth ? username.trim() || null : login?.username || null, username: useAuth ? username.trim() || null : login?.username || null,
password: useAuth ? password || null : keychainPassword password: useAuth ? password || null : keychainPassword,
proxy
}); });
if (mediaData && mediaData.formats.length > 0) { if (mediaData && mediaData.formats.length > 0) {
const mappedFormats = mediaData.formats.map(f => { const mappedFormats = mediaData.formats.map(f => {
@@ -245,7 +248,6 @@ export const AddDownloadsModal = () => {
throw new Error("Invalid media metadata or no formats found"); throw new Error("Invalid media metadata or no formats found");
} }
} else { } else {
const settingsStore = useSettingsStore.getState();
const login = getSiteLogin(row.sourceUrl, settingsStore); const login = getSiteLogin(row.sourceUrl, settingsStore);
let keychainPassword = null; let keychainPassword = null;
if (login) { if (login) {
@@ -261,7 +263,8 @@ export const AddDownloadsModal = () => {
username: useAuth ? username.trim() || null : login?.username || null, username: useAuth ? username.trim() || null : login?.username || null,
password: useAuth ? password || null : keychainPassword, password: useAuth ? password || null : keychainPassword,
headers: headers?.trim() || null, headers: headers?.trim() || null,
cookies: cookies?.trim() || null cookies: cookies?.trim() || null,
proxy
}); });
const nextDownloadUrl = meta.url || row.sourceUrl; const nextDownloadUrl = meta.url || row.sourceUrl;
if (cookiesFromExtensionRef.current && isCrossHostRedirect(row.sourceUrl, nextDownloadUrl)) { if (cookiesFromExtensionRef.current && isCrossHostRedirect(row.sourceUrl, nextDownloadUrl)) {
+2 -2
View File
@@ -17,11 +17,11 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
type CommandMap = { type CommandMap = {
fetch_metadata: { fetch_metadata: {
args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null }; args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null };
result: MetadataResponse; result: MetadataResponse;
}; };
fetch_media_metadata: { fetch_media_metadata: {
args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null }; args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null; proxy: string | null };
result: MediaMetadata; result: MediaMetadata;
}; };
get_aria2_engine_status: { args: undefined; result: EngineStatusItem }; get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
+2 -1
View File
@@ -6,12 +6,13 @@ type FetchMediaMetadataArgs = {
cookieBrowser: string | null; cookieBrowser: string | null;
username: string | null; username: string | null;
password: string | null; password: string | null;
proxy: string | null;
}; };
const inFlightMediaMetadata = new Map<string, Promise<MediaMetadata>>(); const inFlightMediaMetadata = new Map<string, Promise<MediaMetadata>>();
const metadataKey = (args: FetchMediaMetadataArgs) => const metadataKey = (args: FetchMediaMetadataArgs) =>
JSON.stringify([args.url, args.cookieBrowser, args.username, args.password]); JSON.stringify([args.url, args.cookieBrowser, args.username, args.password, args.proxy]);
export const fetchMediaMetadataDeduped = (args: FetchMediaMetadataArgs): Promise<MediaMetadata> => { export const fetchMediaMetadataDeduped = (args: FetchMediaMetadataArgs): Promise<MediaMetadata> => {
const key = metadataKey(args); const key = metadataKey(args);