From a8dc4fb447ab575785b7ce08bb1c643e391867ef Mon Sep 17 00:00:00 2001 From: NimBold Date: Fri, 17 Jul 2026 02:05:41 +0330 Subject: [PATCH] fix(security): harden browser handoff boundaries --- Extensions/Browser | 2 +- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 2 +- src-tauri/src/extension_server.rs | 201 ++++++++++++++++++++++++++---- src-tauri/src/lib.rs | 20 ++- src/App.tsx | 28 +++-- src/bindings/ExtensionDownload.ts | 2 +- src/contexts/ToastContext.tsx | 18 ++- src/ipc.ts | 1 + 9 files changed, 240 insertions(+), 36 deletions(-) diff --git a/Extensions/Browser b/Extensions/Browser index 637f97a..cc7d858 160000 --- a/Extensions/Browser +++ b/Extensions/Browser @@ -1 +1 @@ -Subproject commit 637f97a0dace1afc0390e43451be468bf2416468 +Subproject commit cc7d8580b472cf0e8f0a915fb1b520d5d54e7e11 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 24b240c..b2d8832 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5487,6 +5487,8 @@ dependencies = [ "bitflags 2.13.1", "bytes", "http", + "http-body", + "http-body-util", "percent-encoding", "pin-project-lite", "tower-layer", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1a6a184..186b269 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,7 +45,7 @@ tauri-plugin-single-instance = { version = "2.4.3", features = ["deep-link"] } tempfile = "3" thiserror = "2.0.18" axum = "0.8.9" -tower-http = { version = "0.7", features = ["cors"] } +tower-http = { version = "0.7", features = ["cors", "limit"] } sysproxy = "0.3.0" semver = "1.0.28" keepawake = "0.6.0" diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index f2f304f..7e78e9f 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -17,13 +17,17 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, Manager}; -use tokio::sync::watch; -use tower_http::cors::{Any, CorsLayer}; +use tokio::sync::{oneshot, watch}; +use tower_http::{ + cors::{Any, CorsLayer}, + limit::RequestBodyLimitLayer, +}; use ts_rs::TS; pub const EXTENSION_SERVER_PORT: u16 = 6412; pub const EXTENSION_SERVER_PORT_RANGE: std::ops::RangeInclusive = EXTENSION_SERVER_PORT..=6422; const MAX_URL_COUNT: usize = 200; +const MAX_REQUEST_BODY_BYTES: usize = 256 * 1024; const SIGNATURE_MAX_AGE_MS: u64 = 60_000; const SERVER_HEADER: &str = "x-firelink-server"; const PROTOCOL_VERSION_HEADER: &str = "x-firelink-protocol-version"; @@ -33,11 +37,14 @@ const SERVER_PORT_HEADER: &str = "x-firelink-server-port"; const SMOKE_PROCESS_ID_HEADER: &str = "x-firelink-smoke-process-id"; const SERVER_PROOF_PREFIX: &[u8] = b"firelink-server-proof\n"; const PROTOCOL_VERSION: &str = "4"; +const MAX_PENDING_EXTENSION_ACKS: usize = 64; +const EXTENSION_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); type HmacSha256 = Hmac; pub type SharedExtensionToken = Arc>; pub type SharedFrontendReady = Arc; pub type SharedServerPort = Arc>>; +pub type SharedExtensionAcks = Arc>>>; type ReplayCache = Arc>>; #[derive(Clone)] @@ -45,6 +52,7 @@ pub struct ServerState { pub app_handle: AppHandle, pub pairing_token: SharedExtensionToken, pub frontend_ready: SharedFrontendReady, + pub extension_acks: SharedExtensionAcks, pub replay_cache: ReplayCache, pub bound_port: u16, } @@ -78,6 +86,8 @@ pub struct ExtensionCookieScope { #[derive(Clone, Serialize, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct ExtensionDownload { + #[ts(optional)] + request_id: Option, urls: Vec, referer: Option, silent: bool, @@ -92,6 +102,7 @@ pub async fn start_server( app_handle: AppHandle, pairing_token: SharedExtensionToken, frontend_ready: SharedFrontendReady, + extension_acks: SharedExtensionAcks, server_port: SharedServerPort, mut shutdown_rx: watch::Receiver, ) -> Result<(), String> { @@ -100,6 +111,7 @@ pub async fn start_server( app_handle, pairing_token, frontend_ready, + extension_acks, replay_cache: Arc::new(Mutex::new(HashMap::new())), bound_port: port, }; @@ -116,6 +128,7 @@ pub async fn start_server( .route("/ping", get(ping_handler)) .route("/download", post(download_handler)) .layer(cors) + .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES)) .layer(middleware::from_fn(add_server_identity)) .with_state(state); @@ -245,10 +258,11 @@ async fn download_handler( State(state): State, headers: HeaderMap, body: Bytes, -) -> Result { - if !has_allowed_request_origin(&headers) || !has_valid_optional_nonce(&headers) { - return Err(StatusCode::FORBIDDEN); - } +) -> Result { + let nonce = match required_client_nonce(&headers) { + Some(nonce) if has_allowed_request_origin(&headers) => nonce, + _ => return Err(StatusCode::FORBIDDEN), + }; let signature = match headers .get("x-firelink-signature") @@ -303,15 +317,45 @@ async fn download_handler( return Err(StatusCode::SERVICE_UNAVAILABLE); } + let request_id = uuid::Uuid::new_v4().simple().to_string(); + let ack_receiver = register_extension_ack(&state.extension_acks, request_id.clone()) + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let mut download = download; + download.request_id = Some(request_id.clone()); + if state .app_handle .emit("extension-add-download", download) .is_err() { + remove_extension_ack(&state.extension_acks, &request_id); return Err(StatusCode::INTERNAL_SERVER_ERROR); } - Ok(StatusCode::OK) + if tokio::time::timeout(EXTENSION_ACK_TIMEOUT, ack_receiver) + .await + .is_err() + { + remove_extension_ack(&state.extension_acks, &request_id); + // The event may already have reached the frontend even when its + // acknowledgement was delayed or lost. Do not return 503 here: + // extension callers retry 503 and could create a duplicate modal. + return Err(StatusCode::GATEWAY_TIMEOUT); + } + + let proof = sign_server_proof(timestamp_str, nonce, state.bound_port, &state.pairing_token) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let mut response = Response::new(Body::empty()); + response.headers_mut().insert( + SERVER_PROOF_HEADER, + HeaderValue::from_str(&proof).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, + ); + response.headers_mut().insert( + SERVER_PORT_HEADER, + HeaderValue::from_str(&state.bound_port.to_string()) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, + ); + Ok(response) } async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool { @@ -324,12 +368,45 @@ async fn wait_for_frontend(frontend_ready: &SharedFrontendReady) -> bool { false } +fn register_extension_ack( + registry: &SharedExtensionAcks, + request_id: String, +) -> Option> { + let (sender, receiver) = oneshot::channel(); + let mut pending = registry.lock().ok()?; + if pending.len() >= MAX_PENDING_EXTENSION_ACKS { + return None; + } + pending.insert(request_id, sender); + Some(receiver) +} + +pub fn acknowledge_extension_download(registry: &SharedExtensionAcks, request_id: &str) -> bool { + let Some(sender) = registry + .lock() + .ok() + .and_then(|mut pending| pending.remove(request_id)) + else { + return false; + }; + sender.send(()).is_ok() +} + +fn remove_extension_ack(registry: &SharedExtensionAcks, request_id: &str) { + if let Ok(mut pending) = registry.lock() { + pending.remove(request_id); + } +} + fn normalize_download(mut payload: ExtensionRequest) -> Option { + if payload.urls.len() > MAX_URL_COUNT { + return None; + } + let mut seen = HashSet::new(); let urls = payload .urls .into_iter() - .take(MAX_URL_COUNT) .filter_map(|raw_url| normalize_url(&raw_url)) .filter(|url| seen.insert(url.clone())) .collect::>(); @@ -377,6 +454,7 @@ fn normalize_download(mut payload: ExtensionRequest) -> Option bool { } } +fn required_client_nonce(headers: &HeaderMap) -> Option<&str> { + headers + .get(CLIENT_NONCE_HEADER) + .and_then(|nonce| nonce.to_str().ok()) + .filter(|nonce| is_valid_client_nonce(nonce)) +} + fn sign_server_proof( timestamp_text: &str, nonce: &str, @@ -553,16 +638,22 @@ fn claim_request(signature: &str, timestamp: u64, replay_cache: &ReplayCache) -> Some(now) => now, None => return false, }; + claim_request_at(signature, timestamp, replay_cache, now) +} + +fn claim_request_at(signature: &str, timestamp: u64, replay_cache: &ReplayCache, now: u64) -> bool { let mut cache = match replay_cache.lock() { Ok(cache) => cache, Err(_) => return false, }; - cache.retain(|_, seen_at| now.saturating_sub(*seen_at) < SIGNATURE_MAX_AGE_MS); - if cache.len() > 10_000 { + cache.retain(|_, expires_at| now < *expires_at); + let key = format!("{timestamp}:{}", signature.to_ascii_lowercase()); + if cache.len() >= 10_000 && !cache.contains_key(&key) { return false; } - let key = format!("{timestamp}:{}", signature.to_ascii_lowercase()); - cache.insert(key, now).is_none() + cache + .insert(key, timestamp.saturating_add(SIGNATURE_MAX_AGE_MS)) + .is_none() } fn current_time_millis() -> Option { @@ -605,9 +696,10 @@ fn is_allowed_origin(origin: &str) -> bool { #[cfg(test)] mod tests { use super::{ - add_server_identity, claim_request, has_allowed_request_origin, has_valid_optional_nonce, - is_valid_client_nonce, normalize_download, sign_server_proof, ExtensionRequest, - ExtensionCookieScope, PROTOCOL_VERSION_HEADER, SERVER_HEADER, + acknowledge_extension_download, add_server_identity, claim_request_at, + has_allowed_request_origin, has_valid_optional_nonce, is_valid_client_nonce, + normalize_download, required_client_nonce, sign_server_proof, ExtensionCookieScope, + ExtensionRequest, MAX_URL_COUNT, PROTOCOL_VERSION_HEADER, SERVER_HEADER, }; use axum::{ http::{HeaderMap, HeaderValue, StatusCode}, @@ -679,6 +771,27 @@ mod tests { assert!(!has_valid_optional_nonce(&headers)); } + #[test] + fn requires_a_valid_client_nonce_for_downloads() { + let mut headers = HeaderMap::new(); + assert!(required_client_nonce(&headers).is_none()); + + headers.insert( + "x-firelink-client-nonce", + HeaderValue::from_static("not-a-valid-nonce"), + ); + assert!(required_client_nonce(&headers).is_none()); + + headers.insert( + "x-firelink-client-nonce", + HeaderValue::from_static("0123456789abcdef0123456789abcdef"), + ); + assert_eq!( + required_client_nonce(&headers), + Some("0123456789abcdef0123456789abcdef") + ); + } + #[test] fn media_handoffs_reject_non_http_page_urls() { let download = normalize_download(ExtensionRequest { @@ -695,13 +808,62 @@ mod tests { assert!(download.is_none()); } + #[test] + fn rejects_oversized_url_lists_instead_of_truncating_them() { + let download = normalize_download(ExtensionRequest { + urls: (0..=MAX_URL_COUNT) + .map(|index| format!("https://example.com/file-{index}.bin")) + .collect(), + referer: None, + silent: false, + filename: None, + headers: None, + cookies: None, + cookie_scopes: None, + media: false, + }); + + assert!(download.is_none()); + } + #[test] fn rejects_replayed_download_signature() { let cache = Arc::new(Mutex::new(HashMap::new())); let signature = "a".repeat(64); + let now = 1_000_000; - assert!(claim_request(&signature, 42, &cache)); - assert!(!claim_request(&signature, 42, &cache)); + assert!(claim_request_at(&signature, now, &cache, now)); + assert!(!claim_request_at(&signature, now, &cache, now + 1)); + } + + #[test] + fn future_timestamp_replay_claim_survives_cache_pruning_window() { + let cache = Arc::new(Mutex::new(HashMap::new())); + let signature = "b".repeat(64); + let now = 1_000_000; + let future_timestamp = now + 30_000; + + assert!(claim_request_at(&signature, future_timestamp, &cache, now)); + assert!(!claim_request_at( + &signature, + future_timestamp, + &cache, + now + 70_000 + )); + } + + #[tokio::test] + async fn acknowledges_and_removes_pending_extension_event() { + let registry = Arc::new(Mutex::new(HashMap::new())); + let (sender, receiver) = tokio::sync::oneshot::channel(); + registry + .lock() + .unwrap() + .insert("request-1".to_string(), sender); + + assert!(acknowledge_extension_download(®istry, "request-1")); + assert!(!acknowledge_extension_download(®istry, "request-1")); + assert!(receiver.await.is_ok()); } #[test] @@ -776,10 +938,7 @@ mod tests { assert_eq!(download.cookies.as_deref(), Some("SID=mail-session")); assert_eq!( - download - .cookie_scopes - .as_ref() - .map(|scopes| scopes.len()), + download.cookie_scopes.as_ref().map(|scopes| scopes.len()), Some(2) ); assert_eq!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ca42ad7..f5dda7e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2705,6 +2705,7 @@ pub struct AppState { pub storage_layout: crate::storage::StorageLayout, pub extension_pairing_token: extension_server::SharedExtensionToken, pub extension_frontend_ready: extension_server::SharedFrontendReady, + pub extension_acks: extension_server::SharedExtensionAcks, pub extension_server_port: extension_server::SharedServerPort, pub extension_server_shutdown: tokio::sync::watch::Sender, pub aria2_port: std::sync::Arc, @@ -6201,6 +6202,19 @@ fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool) }); } +#[tauri::command] +fn ack_extension_download( + state: tauri::State<'_, AppState>, + request_id: String, +) -> Result<(), String> { + if request_id.len() != 32 || !request_id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("Invalid extension request id".to_string()); + } + + extension_server::acknowledge_extension_download(&state.extension_acks, &request_id); + Ok(()) +} + #[cfg(test)] mod tests { use super::{ @@ -7948,6 +7962,8 @@ pub fn run() { let server_pairing_token = extension_pairing_token.clone(); let extension_frontend_ready = Arc::new(AtomicBool::new(false)); let server_frontend_ready = extension_frontend_ready.clone(); + let extension_acks = Arc::new(Mutex::new(HashMap::new())); + let server_extension_acks = extension_acks.clone(); let extension_server_port = Arc::new(RwLock::new(None)); let server_extension_port = extension_server_port.clone(); let (extension_server_shutdown_tx, extension_server_shutdown_rx) = @@ -8074,6 +8090,7 @@ pub fn run() { storage_layout, extension_pairing_token, extension_frontend_ready, + extension_acks, extension_server_port, extension_server_shutdown: extension_server_shutdown_tx.clone(), aria2_port: aria2_port.clone(), @@ -8529,6 +8546,7 @@ pub fn run() { ext_app_handle.clone(), server_pairing_token.clone(), server_frontend_ready.clone(), + server_extension_acks.clone(), server_extension_port.clone(), extension_server_shutdown_rx.clone(), ).await { @@ -8600,7 +8618,7 @@ pub fn run() { hydrate_extension_pairing_token, get_session_pairing_token, regenerate_pairing_token, grant_keychain_access, acknowledge_pairing_token_change, check_file_exists, toggle_tray_icon, set_extension_pairing_token, - get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download, + get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_global_speed_limit, remove_download, detach_download_for_reconfigure, enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order, commands::reveal_in_file_manager, commands::open_downloaded_file, diff --git a/src/App.tsx b/src/App.tsx index 4461bef..eafc2c5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -137,7 +137,7 @@ function App() { const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading); const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length; - const { addToast } = useToast(); + const { addToast, removeToast } = useToast(); const isMacUserAgent = navigator.userAgent.includes('Mac'); const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos'; // Keep dialogs out of the titlebar area while platform detection is still @@ -179,19 +179,20 @@ function App() { const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep'; let timerId: number | null = null; + let toastId: string | null = null; const cancel = () => { - if (timerId !== null) { - window.clearTimeout(timerId); - if (pendingPostActionTimer.current === timerId) { - pendingPostActionTimer.current = null; - } - timerId = null; + clearPendingPostActionTimer(); + timerId = null; + if (toastId !== null) { + removeToast(toastId); + toastId = null; } }; - addToast({ + toastId = addToast({ variant: 'warning', isActionable: true, + onDismiss: clearPendingPostActionTimer, message: (
{actionLabel} in 10 seconds. @@ -207,6 +208,10 @@ function App() { }); timerId = window.setTimeout(() => { + if (toastId !== null) { + removeToast(toastId); + toastId = null; + } if (pendingPostActionTimer.current === timerId) { pendingPostActionTimer.current = null; } @@ -233,7 +238,7 @@ function App() { }); }, 10_000); pendingPostActionTimer.current = timerId; - }, [addToast, clearPendingPostActionTimer]); + }, [addToast, clearPendingPostActionTimer, removeToast]); const startSidebarResize = (event: React.PointerEvent) => { event.preventDefault(); @@ -326,6 +331,11 @@ function App() { } }); unlistenExtension = await listen('extension-add-download', (event) => { + if (event.payload.request_id) { + void invoke('ack_extension_download', { requestId: event.payload.request_id }).catch(error => { + console.error('Failed to acknowledge browser extension download:', error); + }); + } if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) { pendingStartupInputs.current.push({ type: 'extension', payload: event.payload }); return; diff --git a/src/bindings/ExtensionDownload.ts b/src/bindings/ExtensionDownload.ts index 0f0e4b4..24404f8 100644 --- a/src/bindings/ExtensionDownload.ts +++ b/src/bindings/ExtensionDownload.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExtensionCookieScope } from "./ExtensionCookieScope"; -export type ExtensionDownload = { urls: Array, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array | null, media: boolean, }; +export type ExtensionDownload = { request_id?: string, urls: Array, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array | null, media: boolean, }; diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx index 9da1d02..ce956d0 100644 --- a/src/contexts/ToastContext.tsx +++ b/src/contexts/ToastContext.tsx @@ -14,6 +14,7 @@ export interface ToastMessage { variant?: ToastVariant; duration?: number; isActionable?: boolean; + onDismiss?: () => void; } interface ToastState extends ToastMessage { @@ -21,7 +22,7 @@ interface ToastState extends ToastMessage { } interface ToastContextType { - addToast: (toast: Omit) => void; + addToast: (toast: Omit) => string; removeToast: (id: string) => void; } @@ -33,10 +34,12 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) = const addToast = useCallback((toast: Omit) => { nextToastId.current += 1; + const id = `toast-${nextToastId.current}`; setToasts(prev => { - const next = [...prev, { ...toast, id: `toast-${nextToastId.current}` }]; + const next = [...prev, { ...toast, id }]; return next.slice(-MAX_VISIBLE_TOASTS); }); + return id; }, []); const removeToast = useCallback((id: string) => { @@ -70,6 +73,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void const [isHovered, setIsHovered] = useState(false); const timerStartedAt = useRef(null); const remainingDuration = useRef(null); + const onDismissCalled = useRef(false); useLayoutEffect(() => { const frame = requestAnimationFrame(() => setIsMounted(true)); @@ -99,6 +103,16 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void }; }, [toast, isHovered, removeToast]); + useEffect(() => { + const dismiss = () => { + if (onDismissCalled.current) return; + onDismissCalled.current = true; + toast.onDismiss?.(); + }; + if (toast.exiting) dismiss(); + return dismiss; + }, [toast.exiting, toast.onDismiss]); + useEffect(() => { if (toast.exiting) { const fallbackTimer = setTimeout(() => { diff --git a/src/ipc.ts b/src/ipc.ts index ba73b72..9f2e9a2 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -65,6 +65,7 @@ type CommandMap = { grant_keychain_access: { args: undefined; result: PairingTokenHydration }; acknowledge_pairing_token_change: { args: undefined; result: void }; set_extension_frontend_ready: { args: { ready: boolean }; result: void }; + ack_extension_download: { args: { requestId: string }; result: void }; get_system_proxy: { args: undefined; result: string | null }; get_file_category: { args: { filename: string }; result: DownloadCategory }; check_for_updates: { args: undefined; result: ReleaseCheckOutcome };