From d6af4ee2b55f9f81833815b08f15cb77002af260 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 15 Jul 2026 06:06:16 +0330 Subject: [PATCH] fix(extension): harden local handoff boundaries --- Extensions/Browser | 2 +- src-tauri/src/extension_server.rs | 94 +++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/Extensions/Browser b/Extensions/Browser index 8a6dca9..a025c07 160000 --- a/Extensions/Browser +++ b/Extensions/Browser @@ -1 +1 @@ -Subproject commit 8a6dca969215c0962d6f580452f22f1ebd52cb52 +Subproject commit a025c076e1242b6a5d53483779bec280735c7027 diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 085244b..f06c9c4 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -174,6 +174,10 @@ async fn ping_handler( headers: HeaderMap, body: Bytes, ) -> Result { + if !has_allowed_request_origin(&headers) { + return Err(StatusCode::FORBIDDEN); + } + let signature = match headers .get("x-firelink-signature") .and_then(|v| v.to_str().ok()) @@ -224,6 +228,10 @@ async fn download_handler( headers: HeaderMap, body: Bytes, ) -> Result { + if !has_allowed_request_origin(&headers) || !has_valid_optional_nonce(&headers) { + return Err(StatusCode::FORBIDDEN); + } + let signature = match headers .get("x-firelink-signature") .and_then(|v| v.to_str().ok()) @@ -310,6 +318,15 @@ fn normalize_download(payload: ExtensionRequest) -> Option { if urls.is_empty() { return None; } + if payload.media + && urls.iter().any(|url| { + Url::parse(url) + .ok() + .is_none_or(|url| !matches!(url.scheme(), "http" | "https")) + }) + { + return None; + } let referer = payload.referer.and_then(|value| { let url = Url::parse(value.trim()).ok()?; @@ -405,6 +422,20 @@ fn is_valid_client_nonce(value: &str) -> bool { value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) } +fn has_allowed_request_origin(headers: &HeaderMap) -> bool { + match headers.get("origin") { + None => true, + Some(origin) => origin.to_str().ok().is_some_and(is_allowed_origin), + } +} + +fn has_valid_optional_nonce(headers: &HeaderMap) -> bool { + match headers.get(CLIENT_NONCE_HEADER) { + None => true, + Some(nonce) => nonce.to_str().ok().is_some_and(is_valid_client_nonce), + } +} + fn sign_server_proof( timestamp_text: &str, nonce: &str, @@ -491,13 +522,20 @@ fn is_allowed_origin(origin: &str) -> bool { #[cfg(test)] mod tests { use super::{ - add_server_identity, is_valid_client_nonce, normalize_download, sign_server_proof, - ExtensionRequest, PROTOCOL_VERSION_HEADER, SERVER_HEADER, + add_server_identity, claim_request, has_allowed_request_origin, has_valid_optional_nonce, + is_valid_client_nonce, normalize_download, sign_server_proof, ExtensionRequest, + PROTOCOL_VERSION_HEADER, SERVER_HEADER, + }; + use axum::{ + http::{HeaderMap, HeaderValue, StatusCode}, + middleware, + routing::get, + Router, }; - use axum::{http::StatusCode, middleware, routing::get, Router}; use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; - use std::sync::{Arc, RwLock}; + use std::collections::HashMap; + use std::sync::{Arc, Mutex, RwLock}; #[tokio::test] async fn identifies_every_extension_server_response() { @@ -534,6 +572,54 @@ mod tests { assert!(!is_valid_client_nonce("0123456789abcdef0123456789abcdeg")); } + #[test] + fn rejects_invalid_origins_and_malformed_optional_nonces() { + let mut headers = HeaderMap::new(); + assert!(has_allowed_request_origin(&headers)); + assert!(has_valid_optional_nonce(&headers)); + + headers.insert( + "origin", + HeaderValue::from_static("https://not-firelink.example"), + ); + assert!(!has_allowed_request_origin(&headers)); + + headers.insert( + "origin", + HeaderValue::from_static("moz-extension://firelink"), + ); + headers.insert( + "x-firelink-client-nonce", + HeaderValue::from_static("not-a-valid-nonce"), + ); + assert!(has_allowed_request_origin(&headers)); + assert!(!has_valid_optional_nonce(&headers)); + } + + #[test] + fn media_handoffs_reject_non_http_page_urls() { + let download = normalize_download(ExtensionRequest { + urls: vec!["ftp://example.com/audio.mp3".to_string()], + referer: None, + silent: false, + filename: None, + headers: None, + cookies: None, + media: true, + }); + + assert!(download.is_none()); + } + + #[test] + fn rejects_replayed_download_signature() { + let cache = Arc::new(Mutex::new(HashMap::new())); + let signature = "a".repeat(64); + + assert!(claim_request(&signature, 42, &cache)); + assert!(!claim_request(&signature, 42, &cache)); + } + #[test] fn explicit_media_drops_the_extension_cookie_header() { let download = normalize_download(ExtensionRequest {