From cfb5a5b6e06ba1887d81a893857179dd5e5dbae1 Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 2 Jul 2026 15:43:53 +0330 Subject: [PATCH] fix(settings): harden network controls --- src-tauri/src/download.rs | 4 +- src-tauri/src/lib.rs | 9 +- src-tauri/src/queue.rs | 10 ++- src/components/AddDownloadsModal.tsx | 2 +- src/components/SettingsView.tsx | 129 ++++++++++++++++++++------- src/index.css | 125 ++++++++++++++++++++++---- src/store/useDownloadStore.test.ts | 28 +++++- src/store/useDownloadStore.ts | 28 ++++-- src/store/useSettingsStore.ts | 6 +- 9 files changed, 276 insertions(+), 65 deletions(-) diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index dee9300..5ace8d7 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -736,8 +736,8 @@ fn build_client(payload: &DownloadPayload) -> Result<(Client, HeaderMap), String { builder = builder.user_agent(user_agent); } - if let Some(proxy) = payload.proxy.as_deref().filter(|value| !value.is_empty()) { - if proxy == "none" { + if let Some(proxy) = payload.proxy.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + if proxy.eq_ignore_ascii_case("none") { builder = builder.no_proxy(); } else { builder = builder.proxy( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 01a0b47..5321999 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -904,13 +904,14 @@ async fn fetch_metadata( let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()); if let Some(ref ua) = user_agent { + let ua = ua.trim(); if !ua.is_empty() { builder = builder.user_agent(ua); } else { - builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"); } } else { - builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"); } let resolved_addr = validate_url_ssrf(¤t_url).await?; @@ -2544,8 +2545,8 @@ pub(crate) async fn start_media_download_internal( } } - if let Some(p) = proxy.as_ref().filter(|s| !s.is_empty()) { - if p == "none" { + if let Some(p) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + if p.eq_ignore_ascii_case("none") { cmd = cmd.arg("--proxy").arg(""); } else { cmd = cmd.arg("--proxy").arg(p); diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 11541e5..6b5364d 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -957,8 +957,10 @@ async fn probe_bounded_range_support( .redirect(reqwest::redirect::Policy::limited(5)) .timeout(std::time::Duration::from_secs(10)); - if let Some(proxy) = payload.proxy.as_deref().filter(|value| !value.is_empty()) { - if proxy != "none" { + if let Some(proxy) = payload.proxy.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + if proxy.eq_ignore_ascii_case("none") { + builder = builder.no_proxy(); + } else { builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|error| error.to_string())?); } } @@ -1129,8 +1131,8 @@ impl SidecarSpawner for ProductionSpawner { if !header_list.is_empty() { options.insert("header".to_string(), serde_json::json!(header_list)); } - if let Some(prox) = payload.proxy.as_deref().filter(|s| !s.is_empty()) { - if prox == "none" { + if let Some(prox) = payload.proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + if prox.eq_ignore_ascii_case("none") { options.insert("all-proxy".to_string(), serde_json::json!("")); } else { options.insert("all-proxy".to_string(), serde_json::json!(prox)); diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 7d5d1e6..917a2d5 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -245,7 +245,7 @@ export const AddDownloadsModal = () => { } const meta = await invoke('fetch_metadata', { url: row.sourceUrl, - userAgent: settingsStore.customUserAgent || null, + userAgent: settingsStore.customUserAgent.trim() || null, username: useAuth ? username.trim() || null : login?.username || null, password: useAuth ? password || null : keychainPassword, headers: headers?.trim() || null, diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 3906406..4f9724a 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -56,6 +56,39 @@ const upsertEngineStatus = (items: EngineStatusItem[], item: EngineStatusItem) = return next; }; +const USER_AGENT_SUGGESTIONS = [ + { + label: 'Chrome (Windows)', + detail: 'Windows desktop', + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36' + }, + { + label: 'Chrome (macOS)', + detail: 'macOS desktop', + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36' + }, + { + label: 'Edge (Windows)', + detail: 'Windows desktop', + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0' + }, + { + label: 'Firefox (Windows)', + detail: 'Windows desktop', + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0' + }, + { + label: 'Firefox (macOS)', + detail: 'macOS desktop', + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0' + }, + { + label: 'Safari (macOS)', + detail: 'macOS desktop', + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15' + } +] as const; + const buildEngineStatusError = (check: EngineCheck, error: unknown): EngineStatusItem => ({ name: check.name, kind: check.kind, @@ -200,6 +233,8 @@ export default function SettingsView() { : platform.os === 'linux' ? 'Provides quick access from the desktop tray or status area when available.' : 'Provides quick access from the OS tray area when available.'; + const userAgentMenuRef = useRef(null); + const [isUserAgentMenuOpen, setIsUserAgentMenuOpen] = useState(false); // Local state for engine status const [engineStatus, setEngineStatus] = useState(null); @@ -702,12 +737,15 @@ runEngineChecks(false);

Proxy

-
- Mode +
+
+ Mode + Controls proxy use for new download requests. +
{[ ['none', 'No Proxy'], - ['system', 'Use System Proxy'], + ['system', 'System Proxy'], ['custom', 'Custom Proxy'], ].map(([value, label]) => (
{settings.proxyMode === 'custom' && ( <> -
- Proxy Host +
+
+ Proxy host + Host name, IP address, or URL with scheme. +
settings.setProxyHost(e.target.value)} - placeholder="127.0.0.1" - className="app-control w-40 font-mono" + placeholder="127.0.0.1 or socks5://127.0.0.1" + className="app-control settings-network-input font-mono" />
-
- Proxy Port +
+
+ Proxy port + Valid range is 1 to 65535. +
settings.setProxyPort(Number(e.target.value))} - onBlur={(e) => { - const val = Number(e.target.value); - if (val < 1) settings.setProxyPort(1); - if (val > 65535) settings.setProxyPort(65535); - }} - className="app-control w-24 text-center" + className="app-control settings-port-input text-center" />
)}

{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'} - {settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy when available.`} + {settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy when available, otherwise no proxy.`} {settings.proxyMode === 'custom' && (settings.proxyHost - ? `Downloads use http://${settings.proxyHost}:${settings.proxyPort}.` + ? 'Downloads use the configured proxy URL for HTTP, HTTPS, and media engines.' : 'Enter a proxy host and port to enable the custom proxy.')}

Identity

-
- Custom User Agent -
+
+
+ Custom User-Agent + Applied to metadata fetches and download engines. +
+
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setIsUserAgentMenuOpen(false); + } + }} + > settings.setCustomUserAgent(e.target.value)} - placeholder="e.g. Mozilla/5.0..." - className="app-control w-full font-mono text-[11px]" + onFocus={() => setIsUserAgentMenuOpen(true)} + placeholder="Leave blank for Firelink default" + className="app-control settings-network-input font-mono" + role="combobox" + aria-expanded={isUserAgentMenuOpen} + aria-controls="user-agent-suggestions" /> - - - - - - - + {isUserAgentMenuOpen && ( +
+ {USER_AGENT_SUGGESTIONS.map(option => ( + + ))} +
+ )}
-

Spoofs the browser User-Agent to bypass download restrictions. Leave blank for default.

+

Overrides the outbound User-Agent header. Leave blank for Firelink defaults.

)} diff --git a/src/index.css b/src/index.css index 0669d20..cbfda49 100644 --- a/src/index.css +++ b/src/index.css @@ -372,14 +372,22 @@ html[data-list-density="relaxed"] { transition: none; } - .app-control { - min-height: 28px; - border: 1px solid hsl(var(--border-modal)); - border-radius: 6px; - background: hsl(var(--bg-input)); - color: hsl(var(--text-primary)); - transition: border-color 100ms ease; - } +.app-control { + min-height: 28px; + border: 1px solid hsl(var(--border-modal)); + border-radius: 6px; + background: hsl(var(--bg-input)); + color: hsl(var(--text-primary)); + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.35; + transition: border-color 100ms ease; +} + +.app-control::placeholder { + color: hsl(var(--text-muted)); + opacity: 0.72; +} .app-control:hover:not(:disabled) { border-color: hsl(var(--text-muted)); @@ -1230,15 +1238,100 @@ html[data-list-density="relaxed"] { gap: 7px; } - .settings-radio-group input { - accent-color: hsl(var(--accent-color)); - } +.settings-radio-group input { + accent-color: hsl(var(--accent-color)); +} - .theme-option-grid { - display: grid; - grid-template-columns: repeat(5, minmax(72px, 1fr)); - gap: 7px; - width: 100%; +.settings-network-row { + display: grid; + grid-template-columns: minmax(150px, 190px) minmax(0, 1fr); + gap: 18px; + align-items: start; +} + +.settings-network-row > .settings-row-label { + padding-top: 3px; +} + +.settings-network-input { + width: 100%; + min-width: 0; + padding: 6px 10px; + font-size: 12px; +} + +.settings-port-input { + width: 112px; + padding: 6px 10px; +} + +.settings-combobox { + position: relative; + min-width: 0; +} + +.settings-combobox-menu { + position: absolute; + z-index: 50; + top: calc(100% + 6px); + right: 0; + left: 0; + display: flex; + max-height: 246px; + flex-direction: column; + overflow: auto; + border: 1px solid hsl(var(--border-modal)); + border-radius: 8px; + background: hsl(var(--surface-overlay)); + box-shadow: + 0 18px 42px hsl(0 0% 0% / 0.28), + inset 0 1px 0 hsl(0 0% 100% / 0.06); +} + +.settings-combobox-option { + display: grid; + gap: 3px; + width: 100%; + padding: 8px 10px; + border: 0; + border-bottom: 1px solid hsl(var(--border-color)); + background: transparent; + color: hsl(var(--text-primary)); + font: inherit; + text-align: left; +} + +.settings-combobox-option:last-child { + border-bottom: 0; +} + +.settings-combobox-option:hover, +.settings-combobox-option:focus-visible, +.settings-combobox-option[aria-selected="true"] { + background: hsl(var(--item-hover)); +} + +.settings-combobox-value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: "SF Mono", Monaco, "Cascadia Code", "Fira Code", "JetBrains Mono", monospace; + font-size: 11px; + line-height: 1.25; +} + +.settings-combobox-meta { + color: hsl(var(--text-muted)); + font-size: 11px; + line-height: 1.2; +} + +.theme-option-grid { + display: grid; + grid-template-columns: repeat(5, minmax(72px, 1fr)); + gap: 7px; + width: 100%; } .theme-option { diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index a5df8ee..8d7047b 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { useDownloadStore } from './useDownloadStore'; +import { getProxyArgs, normalizeCustomProxy, useDownloadStore } from './useDownloadStore'; import { useSettingsStore } from './useSettingsStore'; import * as ipc from '../ipc'; @@ -64,6 +64,32 @@ describe('useDownloadStore', () => { }); }); + it('normalizes proxy settings for download dispatch', async () => { + expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080'); + expect(normalizeCustomProxy(' socks5://127.0.0.1 ', 1080)).toBe('socks5://127.0.0.1:1080'); + expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000'); + expect(normalizeCustomProxy('127.0.0.1', NaN)).toBeNull(); + + expect(await getProxyArgs({ + proxyMode: 'none', + proxyHost: '', + proxyPort: 8080 + } as ReturnType)).toBe('none'); + + vi.mocked(ipc.invokeCommand).mockResolvedValueOnce(null); + expect(await getProxyArgs({ + proxyMode: 'system', + proxyHost: '', + proxyPort: 8080 + } as ReturnType)).toBe('none'); + + expect(await getProxyArgs({ + proxyMode: 'custom', + proxyHost: 'socks5://127.0.0.1', + proxyPort: 1080 + } as ReturnType)).toBe('socks5://127.0.0.1:1080'); + }); + it('Start Queue dispatches exactly once for mixed dispatched/undispatched items', async () => { useDownloadStore.setState({ downloads: [ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index fc347c6..f9e787a 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -54,7 +54,7 @@ export async function dispatchItem(id: string): Promise { checksum: item.checksum || null, cookies: item.cookies || null, mirrors: item.mirrors || null, - user_agent: settings.customUserAgent || null, + user_agent: settings.customUserAgent.trim() || null, max_tries: settings.maxAutomaticRetries, proxy: await getProxyArgs(settings), format_selector: item.mediaFormatSelector || null, @@ -87,7 +87,25 @@ export async function dispatchItem(id: string): Promise { return promise; } -const getProxyArgs = async (settings: ReturnType) => { +export const normalizeCustomProxy = (host: string, port: number): string | null => { + const trimmedHost = host.trim(); + const normalizedPort = Number.isFinite(port) ? Math.trunc(port) : NaN; + if (!trimmedHost || !Number.isFinite(normalizedPort) || normalizedPort < 1 || normalizedPort > 65535) return null; + + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmedHost)) { + try { + const parsed = new URL(trimmedHost); + if (!parsed.port) parsed.port = String(normalizedPort); + return parsed.toString().replace(/\/$/, ''); + } catch { + return null; + } + } + + return `http://${trimmedHost}:${normalizedPort}`; +}; + +export const getProxyArgs = async (settings: ReturnType) => { if (settings.proxyMode === 'system') { try { const sysProxy = await invoke('get_system_proxy'); @@ -97,8 +115,8 @@ const getProxyArgs = async (settings: ReturnType((set, get) => ({ checksum: item.checksum || null, cookies: item.cookies || null, mirrors: item.mirrors || null, - user_agent: settings.customUserAgent || null, + user_agent: settings.customUserAgent.trim() || null, max_tries: settings.maxAutomaticRetries, proxy: await getProxyArgs(settings), format_selector: item.mediaFormatSelector || null, diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index fec2b40..a6a6ac0 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -274,7 +274,11 @@ export const useSettingsStore = create()( setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }), setProxyMode: (proxyMode) => set({ proxyMode }), setProxyHost: (proxyHost) => set({ proxyHost }), - setProxyPort: (proxyPort) => set({ proxyPort }), + setProxyPort: (proxyPort) => set({ + proxyPort: Number.isFinite(proxyPort) + ? Math.min(65535, Math.max(1, Math.trunc(proxyPort))) + : 8080 + }), setCustomUserAgent: (customUserAgent) => set({ customUserAgent }), setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }), setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {