fix(settings): harden network controls

This commit is contained in:
NimBold
2026-07-02 15:43:53 +03:30
parent d274e3af17
commit cfb5a5b6e0
9 changed files with 276 additions and 65 deletions
+2 -2
View File
@@ -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(
+5 -4
View File
@@ -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(&current_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);
+6 -4
View File
@@ -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));
+1 -1
View File
@@ -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,
+98 -31
View File
@@ -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<HTMLDivElement>(null);
const [isUserAgentMenuOpen, setIsUserAgentMenuOpen] = useState(false);
// Local state for engine status
const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null);
@@ -702,12 +737,15 @@ runEngineChecks(false);
<div className="settings-pane max-w-[720px]">
<h2 className="settings-section-title">Proxy</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-choice-row">
<span className="text-[13px] text-text-primary pt-0.5">Mode</span>
<div className="mac-settings-row settings-network-row settings-choice-row">
<div className="settings-row-label">
<span>Mode</span>
<small>Controls proxy use for new download requests.</small>
</div>
<div className="settings-radio-group">
{[
['none', 'No Proxy'],
['system', 'Use System Proxy'],
['system', 'System Proxy'],
['custom', 'Custom Proxy'],
].map(([value, label]) => (
<label key={value}>
@@ -724,64 +762,93 @@ runEngineChecks(false);
</div>
{settings.proxyMode === 'custom' && (
<>
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary pl-4">Proxy Host</span>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>Proxy host</span>
<small>Host name, IP address, or URL with scheme.</small>
</div>
<input
type="text"
value={settings.proxyHost}
onChange={(e) => 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"
/>
</div>
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary pl-4">Proxy Port</span>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>Proxy port</span>
<small>Valid range is 1 to 65535.</small>
</div>
<input
type="number" min="1" max="65535"
value={settings.proxyPort}
onChange={(e) => 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"
/>
</div>
</>
)}
<p className="settings-group-footer">
{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.')}
</p>
</div>
<h2 className="settings-section-title">Identity</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary">Custom User Agent</span>
<div className="flex-1 ml-4 relative">
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>Custom User-Agent</span>
<small>Applied to metadata fetches and download engines.</small>
</div>
<div
className="settings-combobox"
ref={userAgentMenuRef}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setIsUserAgentMenuOpen(false);
}
}}
>
<input
type="text"
list="user-agents"
value={settings.customUserAgent}
onChange={(e) => 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"
/>
<datalist id="user-agents">
<option value="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36">Chrome (Windows)</option>
<option value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36">Chrome (macOS)</option>
<option value="Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0">Firefox (Windows)</option>
<option value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0">Firefox (macOS)</option>
<option value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15">Safari (macOS)</option>
</datalist>
{isUserAgentMenuOpen && (
<div id="user-agent-suggestions" className="settings-combobox-menu" role="listbox">
{USER_AGENT_SUGGESTIONS.map(option => (
<button
key={option.label}
type="button"
className="settings-combobox-option"
role="option"
aria-selected={settings.customUserAgent === option.value}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
settings.setCustomUserAgent(option.value);
setIsUserAgentMenuOpen(false);
}}
>
<span className="settings-combobox-value">{option.value}</span>
<span className="settings-combobox-meta">{option.label} · {option.detail}</span>
</button>
))}
</div>
)}
</div>
</div>
<p className="settings-group-footer">Spoofs the browser User-Agent to bypass download restrictions. Leave blank for default.</p>
<p className="settings-group-footer">Overrides the outbound User-Agent header. Leave blank for Firelink defaults.</p>
</div>
</div>
)}
+109 -16
View File
@@ -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 {
+27 -1
View File
@@ -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<typeof useSettingsStore.getState>)).toBe('none');
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce(null);
expect(await getProxyArgs({
proxyMode: 'system',
proxyHost: '',
proxyPort: 8080
} as ReturnType<typeof useSettingsStore.getState>)).toBe('none');
expect(await getProxyArgs({
proxyMode: 'custom',
proxyHost: 'socks5://127.0.0.1',
proxyPort: 1080
} as ReturnType<typeof useSettingsStore.getState>)).toBe('socks5://127.0.0.1:1080');
});
it('Start Queue dispatches exactly once for mixed dispatched/undispatched items', async () => {
useDownloadStore.setState({
downloads: [
+23 -5
View File
@@ -54,7 +54,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
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<boolean> {
return promise;
}
const getProxyArgs = async (settings: ReturnType<typeof useSettingsStore.getState>) => {
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<typeof useSettingsStore.getState>) => {
if (settings.proxyMode === 'system') {
try {
const sysProxy = await invoke('get_system_proxy');
@@ -97,8 +115,8 @@ const getProxyArgs = async (settings: ReturnType<typeof useSettingsStore.getStat
return "none";
}
}
if (settings.proxyMode === 'custom' && settings.proxyHost) {
return `http://${settings.proxyHost}:${settings.proxyPort}`;
if (settings.proxyMode === 'custom') {
return normalizeCustomProxy(settings.proxyHost, settings.proxyPort) ?? "none";
}
if (settings.proxyMode === 'none') {
return "none";
@@ -778,7 +796,7 @@ export const useDownloadStore = create<DownloadState>((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,
+5 -1
View File
@@ -274,7 +274,11 @@ export const useSettingsStore = create<SettingsState>()(
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) => {