mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-09 00:13:56 +02:00
refactor: cleanup
This commit is contained in:
+118
-6
@@ -58,13 +58,25 @@ pub struct ApiProfileResponse {
|
||||
pub struct CreateProfileRequest {
|
||||
pub name: String,
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
/// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded
|
||||
/// version of the chosen browser. A concrete version must already be
|
||||
/// downloaded; the create path does not fetch new versions.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
pub proxy_id: Option<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
pub launch_hook: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
/// Camoufox fingerprint/config. Send only when `browser` is `"camoufox"`.
|
||||
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
|
||||
/// generated automatically at creation. Provide a `fingerprint` field to
|
||||
/// pin a specific one.
|
||||
#[schema(value_type = Object)]
|
||||
pub camoufox_config: Option<serde_json::Value>,
|
||||
/// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`.
|
||||
/// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
|
||||
/// generated automatically at creation. Provide a `fingerprint` field to
|
||||
/// pin a specific one.
|
||||
#[schema(value_type = Object)]
|
||||
pub wayfern_config: Option<serde_json::Value>,
|
||||
pub group_id: Option<String>,
|
||||
@@ -74,7 +86,9 @@ pub struct CreateProfileRequest {
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateProfileRequest {
|
||||
pub name: Option<String>,
|
||||
pub browser: Option<String>,
|
||||
// No `browser` field: a profile's engine is fixed at creation (changing it
|
||||
// would invalidate the generated fingerprint and on-disk profile dir).
|
||||
// Accepting it here only to silently ignore it misled API clients.
|
||||
pub version: Option<String>,
|
||||
pub proxy_id: Option<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
@@ -508,8 +522,14 @@ async fn auth_middleware(
|
||||
}
|
||||
};
|
||||
|
||||
// Compare tokens
|
||||
if token != stored_token {
|
||||
// Constant-time comparison so the auth check doesn't leak the shared-prefix
|
||||
// length via timing. `ConstantTimeEq` on equal-length byte slices; differing
|
||||
// lengths simply compare unequal.
|
||||
use subtle::ConstantTimeEq;
|
||||
let token_bytes = token.as_bytes();
|
||||
let stored_bytes = stored_token.as_bytes();
|
||||
let matches = token_bytes.len() == stored_bytes.len() && token_bytes.ct_eq(stored_bytes).into();
|
||||
if !matches {
|
||||
log::warn!("[api] Rejected {path}: token mismatch");
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
@@ -694,14 +714,24 @@ async fn get_profile(
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a profile.
|
||||
///
|
||||
/// - `browser` must be `"wayfern"` or `"camoufox"`; any other value is rejected
|
||||
/// with 400.
|
||||
/// - `version` is optional: omit it or pass `"latest"` to use the newest
|
||||
/// already-downloaded version of that browser. The version must be present
|
||||
/// locally (this endpoint does not download new versions); 400 if none is.
|
||||
/// - Omitting the matching `wayfern_config`/`camoufox_config`, or passing an
|
||||
/// empty object `{}`, generates a fresh fingerprint automatically.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles",
|
||||
request_body = CreateProfileRequest,
|
||||
responses(
|
||||
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
|
||||
(status = 400, description = "Bad request"),
|
||||
(status = 400, description = "Invalid browser, or no downloaded version available"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Selected proxy requires payment"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -715,6 +745,34 @@ async fn create_profile(
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
|
||||
// Only Wayfern and Camoufox profiles are launchable; the rest of the system
|
||||
// (fingerprint generation, launch, run) supports nothing else. Reject anything
|
||||
// else up front — otherwise the profile is created with no fingerprint and an
|
||||
// unrecognized browser, then crashes with a 500 on /run. Mirrors the MCP
|
||||
// create_profile validation.
|
||||
if request.browser != "wayfern" && request.browser != "camoufox" {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Resolve the version. Omitted, empty, or "latest" means "newest version
|
||||
// already downloaded for this browser". The create path generates the
|
||||
// fingerprint by launching that binary, so the version must be present
|
||||
// locally — we don't fetch new versions here. 400 if none is downloaded.
|
||||
let version = match request.version.as_deref() {
|
||||
Some(v) if !v.is_empty() && v != "latest" => v.to_string(),
|
||||
_ => {
|
||||
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
let mut versions = registry.get_downloaded_versions(&request.browser);
|
||||
// browsers is a HashMap, so keys are unordered — sort newest-first by
|
||||
// semver before taking the latest.
|
||||
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
|
||||
match versions.into_iter().next() {
|
||||
Some(v) => v,
|
||||
None => return Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse camoufox config if provided
|
||||
let camoufox_config = if let Some(config) = &request.camoufox_config {
|
||||
serde_json::from_value(config.clone()).ok()
|
||||
@@ -747,7 +805,7 @@ async fn create_profile(
|
||||
&state.app_handle,
|
||||
&request.name,
|
||||
&request.browser,
|
||||
&request.version,
|
||||
&version,
|
||||
request.release_type.as_deref().unwrap_or("stable"),
|
||||
request.proxy_id.clone(),
|
||||
request.vpn_id.clone(),
|
||||
@@ -2090,3 +2148,57 @@ async fn refresh_wayfern_token(
|
||||
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
Ok(Json(WayfernTokenResponse { token }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Removing `browser` from UpdateProfileRequest, and rejecting invalid
|
||||
// `browser` values on create, must NOT make the API reject requests that
|
||||
// carry extra/unknown fields — old clients still send them. serde ignores
|
||||
// unknown fields by default; these tests lock that in so a future
|
||||
// `#[serde(deny_unknown_fields)]` can't silently break compatibility.
|
||||
#[test]
|
||||
fn update_profile_request_ignores_unknown_fields() {
|
||||
// `browser` is no longer a field, plus a wholly unknown field. Both must
|
||||
// be accepted and ignored, not rejected.
|
||||
let json = r#"{"name": "p", "browser": "wayfern", "totally_unknown": 123}"#;
|
||||
let parsed: UpdateProfileRequest =
|
||||
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
|
||||
assert_eq!(parsed.name.as_deref(), Some("p"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_request_ignores_unknown_fields() {
|
||||
let json = r#"{"name": "p", "browser": "wayfern", "version": "latest", "future_field": true}"#;
|
||||
let parsed: CreateProfileRequest =
|
||||
serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
|
||||
assert_eq!(parsed.browser, "wayfern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_request_allows_omitting_version_and_configs() {
|
||||
// Minimal body: no version, no wayfern_config/camoufox_config. Must
|
||||
// deserialize (version resolves to latest-downloaded at the handler; an
|
||||
// absent config triggers fresh-fingerprint generation).
|
||||
let json = r#"{"name": "p", "browser": "wayfern"}"#;
|
||||
let parsed: CreateProfileRequest =
|
||||
serde_json::from_str(json).expect("version and configs are optional");
|
||||
assert_eq!(parsed.browser, "wayfern");
|
||||
assert!(parsed.version.is_none());
|
||||
assert!(parsed.wayfern_config.is_none());
|
||||
assert!(parsed.camoufox_config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_profile_browser_validation_matches_supported_engines() {
|
||||
// The handler rejects anything that isn't a launchable engine; this is the
|
||||
// same predicate it uses, kept in lockstep with MCP's create_profile.
|
||||
let is_valid = |b: &str| b == "wayfern" || b == "camoufox";
|
||||
assert!(is_valid("wayfern"));
|
||||
assert!(is_valid("camoufox"));
|
||||
assert!(!is_valid("chromium"));
|
||||
assert!(!is_valid("firefox"));
|
||||
assert!(!is_valid(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,7 +1016,7 @@ impl CloudAuthManager {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = self
|
||||
let result = self
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
||||
// Bound the request: without a timeout, an unreachable
|
||||
@@ -1050,7 +1050,31 @@ impl CloudAuthManager {
|
||||
Ok(result.token)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
let token = match result {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
// The backend returns 403 (ForbiddenException) for paid-feature blocks:
|
||||
// token-reuse throttle, "active subscription required", and the
|
||||
// primary-device restriction (see donutbrowser-infra wayfern.service.ts).
|
||||
// This is distinct from a 401 (dead access token) — the session is still
|
||||
// valid, the user is just temporarily/conditionally not entitled. So we
|
||||
// do NOT invalidate the session. Instead: drop the stale wayfern token so
|
||||
// no browser launches half-authenticated, re-fetch the profile so the
|
||||
// cached plan reflects the backend's real state (it may have changed),
|
||||
// and signal the UI so the user learns why automation stopped working.
|
||||
if e.contains("(403") || e.contains("Forbidden") {
|
||||
log::warn!("Wayfern token blocked by backend (403): {e}");
|
||||
self.clear_wayfern_token().await;
|
||||
if let Err(fetch_err) = self.fetch_profile().await {
|
||||
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
|
||||
}
|
||||
let _ = crate::events::emit_empty("wayfern-paid-blocked");
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let mut wt = self.wayfern_token.lock().await;
|
||||
*wt = Some(token);
|
||||
|
||||
@@ -339,8 +339,16 @@ impl McpServer {
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.strip_prefix("Bearer "));
|
||||
|
||||
let valid =
|
||||
path_token == Some(state.token.as_str()) || header_token == Some(state.token.as_str());
|
||||
// Constant-time comparison to avoid leaking the token prefix via timing.
|
||||
use subtle::ConstantTimeEq;
|
||||
let expected = state.token.as_bytes();
|
||||
let ct_eq = |t: Option<&str>| {
|
||||
t.is_some_and(|t| {
|
||||
let b = t.as_bytes();
|
||||
b.len() == expected.len() && b.ct_eq(expected).into()
|
||||
})
|
||||
};
|
||||
let valid = ct_eq(path_token) || ct_eq(header_token);
|
||||
|
||||
if !valid {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
@@ -1671,11 +1679,10 @@ impl McpServer {
|
||||
"connect_vpn" => self.handle_connect_vpn(arguments).await,
|
||||
"disconnect_vpn" => self.handle_disconnect_vpn(arguments).await,
|
||||
"get_vpn_status" => self.handle_get_vpn_status(arguments).await,
|
||||
// Fingerprint management — viewing and editing both require a paid plan.
|
||||
"get_profile_fingerprint" => {
|
||||
Self::require_paid_subscription("Fingerprint").await?;
|
||||
self.handle_get_profile_fingerprint(arguments).await
|
||||
}
|
||||
// Fingerprint management — viewing is free everywhere (matches the REST
|
||||
// API and the get_profile tool, which already expose the config); only
|
||||
// editing requires a paid plan.
|
||||
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(arguments).await,
|
||||
"update_profile_fingerprint" => {
|
||||
Self::require_paid_subscription("Fingerprint").await?;
|
||||
self.handle_update_profile_fingerprint(arguments).await
|
||||
@@ -2592,6 +2599,15 @@ impl McpServer {
|
||||
message: "Missing proxy_type".to_string(),
|
||||
})?;
|
||||
|
||||
// The tool schema declares an enum, but JSON-Schema enums are advisory only;
|
||||
// enforce it here so a bad value can't produce a non-functional proxy.
|
||||
if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5") {
|
||||
return Err(McpError {
|
||||
code: -32602,
|
||||
message: "proxy_type must be one of: http, https, socks4, socks5".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let host = arguments
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -3,6 +3,38 @@ use crate::profile::BrowserProfile;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// True if a process command line refers to `profile_path` as a real browser
|
||||
/// profile/data-dir argument, NOT merely a substring. A bare `contains` match
|
||||
/// force-killed unrelated processes that happened to mention the path (editors,
|
||||
/// `tail`, a terminal that `cd`'d there, or another profile whose path has this
|
||||
/// one as a prefix). Mirrors the precise matching in browser_runner/wayfern_manager.
|
||||
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
|
||||
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
// Exact argument equality (Firefox/Camoufox: `-profile <path>`; some launchers
|
||||
// pass the path as its own arg).
|
||||
if *arg == profile_path {
|
||||
return true;
|
||||
}
|
||||
// `--user-data-dir=<path>` (Chromium/Wayfern) or `-profile=<path>`.
|
||||
if let Some(val) = arg
|
||||
.strip_prefix("--user-data-dir=")
|
||||
.or_else(|| arg.strip_prefix("-profile="))
|
||||
{
|
||||
if val == profile_path {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Flag followed by the path as the next argument.
|
||||
if (*arg == "-profile" || *arg == "--user-data-dir")
|
||||
&& args.get(i + 1).is_some_and(|next| *next == profile_path)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// Platform-specific modules
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(dead_code)]
|
||||
@@ -215,16 +247,7 @@ pub mod macos {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any command line argument contains the profile path
|
||||
let has_profile = cmd.iter().any(|arg| {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
arg_str.contains(profile_path)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if has_profile {
|
||||
if cmd_matches_profile_path(cmd, profile_path) {
|
||||
pids.push(pid.as_u32());
|
||||
}
|
||||
}
|
||||
@@ -832,15 +855,7 @@ pub mod linux {
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_profile = cmd.iter().any(|arg| {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
arg_str.contains(profile_path)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if has_profile {
|
||||
if cmd_matches_profile_path(cmd, profile_path) {
|
||||
pids.push(pid.as_u32());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,7 +1035,7 @@ impl ProfileManager {
|
||||
fs::create_dir_all(&dest_dir)?;
|
||||
}
|
||||
|
||||
let new_profile = BrowserProfile {
|
||||
let mut new_profile = BrowserProfile {
|
||||
id: new_id,
|
||||
name: clone_name,
|
||||
browser: source.browser,
|
||||
@@ -1071,6 +1071,21 @@ impl ProfileManager {
|
||||
updated_at: Some(crate::proxy_manager::now_secs()),
|
||||
};
|
||||
|
||||
// Donut: a clone must NOT be linkable to its source. The source
|
||||
// wayfern_config embeds the persisted fingerprint JSON (including the
|
||||
// canvas_noise_seed), so copying it verbatim makes the clone emit
|
||||
// BYTE-IDENTICAL canvas/WebGL/audio readback hashes and identical device
|
||||
// signals as the source — trivially linkable if both run concurrently. Clear
|
||||
// the fingerprint so the launch path mints a fresh one (a new
|
||||
// canvas_noise_seed via RandBytes + an independent device fingerprint),
|
||||
// exactly as create_profile does when fingerprint.is_none(). NOTE: the
|
||||
// user-data-dir copy above still duplicates cookies/localStorage/TLS state —
|
||||
// a separate storage-linkage vector the user must clear if they want full
|
||||
// isolation between a clone and its source.
|
||||
if let Some(cfg) = new_profile.wayfern_config.as_mut() {
|
||||
cfg.fingerprint = None;
|
||||
}
|
||||
|
||||
self.save_profile(&new_profile)?;
|
||||
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
|
||||
@@ -774,6 +774,17 @@ impl ProxyManager {
|
||||
list
|
||||
}
|
||||
|
||||
/// Insert/replace a stored proxy in the in-memory map. Used by sync's
|
||||
/// download_proxy after it writes the file to disk, mirroring how
|
||||
/// download_group/download_vpn/download_extension keep their managers'
|
||||
/// in-memory state in sync. Without this, get_stored_proxies (which reads
|
||||
/// only the map) never sees a downloaded proxy until restart, so sync keeps
|
||||
/// re-downloading it indefinitely.
|
||||
pub fn upsert_stored_proxy(&self, proxy: StoredProxy) {
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies.insert(proxy.id.clone(), proxy);
|
||||
}
|
||||
|
||||
// Get a stored proxy by ID
|
||||
|
||||
// Update a stored proxy
|
||||
@@ -1730,12 +1741,18 @@ impl ProxyManager {
|
||||
.arg("--id")
|
||||
.arg(&proxy_id);
|
||||
|
||||
let output = proxy_cmd.output().await.unwrap();
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("Proxy stop error: {stderr}");
|
||||
// We still return Ok since we've already removed the proxy from our tracking
|
||||
// A failed spawn (sidecar missing, permission denied, fd exhaustion) must
|
||||
// not panic the cleanup task — the proxy is already removed from tracking,
|
||||
// so degrade gracefully like the non-success branch below.
|
||||
match proxy_cmd.output().await {
|
||||
Ok(output) if !output.status.success() => {
|
||||
log::warn!(
|
||||
"Proxy stop error: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
|
||||
}
|
||||
|
||||
// Clear profile-to-proxy mapping if it references this proxy
|
||||
@@ -1795,11 +1812,16 @@ impl ProxyManager {
|
||||
.arg("--id")
|
||||
.arg(&proxy_id);
|
||||
|
||||
let output = proxy_cmd.output().await.unwrap();
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("Proxy stop error: {stderr}");
|
||||
// Don't panic if the sidecar can't be spawned — still clear the mapping.
|
||||
match proxy_cmd.output().await {
|
||||
Ok(output) if !output.status.success() => {
|
||||
log::warn!(
|
||||
"Proxy stop error: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("Failed to run donut-proxy stop: {e}"),
|
||||
}
|
||||
|
||||
// Clear profile-to-proxy mapping
|
||||
|
||||
@@ -509,47 +509,20 @@ async fn handle_http_via_socks4(
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve target host to IP (SOCKS4 requires IP addresses)
|
||||
let target_ip = match tokio::net::lookup_host((target_host, target_port)).await {
|
||||
Ok(mut addrs) => {
|
||||
if let Some(addr) = addrs.next() {
|
||||
match addr.ip() {
|
||||
std::net::IpAddr::V4(ipv4) => ipv4.octets(),
|
||||
std::net::IpAddr::V6(_) => {
|
||||
log::error!("SOCKS4 does not support IPv6");
|
||||
let mut response = Response::new(Full::new(Bytes::from(
|
||||
"SOCKS4 does not support IPv6 addresses",
|
||||
)));
|
||||
*response.status_mut() = StatusCode::BAD_GATEWAY;
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("Failed to resolve target host: {}", target_host);
|
||||
let mut response = Response::new(Full::new(Bytes::from(format!(
|
||||
"Failed to resolve target host: {}",
|
||||
target_host
|
||||
))));
|
||||
*response.status_mut() = StatusCode::BAD_GATEWAY;
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to resolve target host {}: {}", target_host, e);
|
||||
let mut response = Response::new(Full::new(Bytes::from(format!(
|
||||
"Failed to resolve target host: {}",
|
||||
e
|
||||
))));
|
||||
*response.status_mut() = StatusCode::BAD_GATEWAY;
|
||||
return Ok(response);
|
||||
}
|
||||
};
|
||||
|
||||
// Build SOCKS4 CONNECT request
|
||||
// Build a SOCKS4a CONNECT request. We deliberately do NOT resolve the target
|
||||
// hostname locally: tokio::net::lookup_host would call the HOST resolver
|
||||
// (getaddrinfo), leaking the destination domain to the host's DNS server and
|
||||
// defeating the per-profile proxy. SOCKS4a has the PROXY resolve the name —
|
||||
// send the sentinel IP 0.0.0.x (x != 0), then the NULL-terminated userid, then
|
||||
// the NULL-terminated hostname. (Most SOCKS4 proxies support 4a; a legacy
|
||||
// SOCKS4-only proxy without remote DNS cannot be used leak-free for plaintext
|
||||
// HTTP — prefer SOCKS5 there.)
|
||||
let mut socks_request = vec![0x04, 0x01]; // SOCKS4, CONNECT
|
||||
socks_request.extend_from_slice(&target_port.to_be_bytes());
|
||||
socks_request.extend_from_slice(&target_ip);
|
||||
socks_request.push(0); // NULL terminator for userid
|
||||
socks_request.extend_from_slice(&[0, 0, 0, 1]); // 0.0.0.1 => SOCKS4a remote-DNS marker
|
||||
socks_request.push(0); // empty userid, NULL-terminated
|
||||
socks_request.extend_from_slice(target_host.as_bytes()); // hostname for the proxy to resolve
|
||||
socks_request.push(0); // NULL-terminated hostname
|
||||
|
||||
// Send SOCKS4 CONNECT request
|
||||
if let Err(e) = socks_stream.write_all(&socks_request).await {
|
||||
@@ -1071,8 +1044,19 @@ fn build_reqwest_client_with_proxy(
|
||||
Proxy::http(upstream_url)?
|
||||
}
|
||||
"socks5" => {
|
||||
// For SOCKS5, reqwest supports it directly
|
||||
Proxy::all(upstream_url)?
|
||||
// Donut: force REMOTE (proxy-side) DNS for plaintext HTTP over a SOCKS5
|
||||
// upstream. reqwest maps the bare `socks5` scheme to DnsResolve::Local,
|
||||
// which resolves the destination hostname on the HOST (getaddrinfo) BEFORE
|
||||
// connecting — leaking the destination domain to the host's DNS resolver
|
||||
// and defeating the per-profile proxy. The `socks5h` scheme maps to
|
||||
// DnsResolve::Proxy, so the proxy resolves the hostname and nothing leaks.
|
||||
// (The CONNECT/HTTPS path already does remote DNS via connect_via_socks's
|
||||
// AddrKind::Domain.)
|
||||
let remote_dns_url = match upstream_url.strip_prefix("socks5://") {
|
||||
Some(rest) => format!("socks5h://{rest}"),
|
||||
None => upstream_url.to_string(),
|
||||
};
|
||||
Proxy::all(remote_dns_url)?
|
||||
}
|
||||
"socks4" => {
|
||||
// SOCKS4 is handled manually in handle_http_via_socks4
|
||||
|
||||
@@ -1597,6 +1597,13 @@ impl SyncEngine {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Keep the in-memory cache in sync with disk. Without this, get_stored_proxies
|
||||
// (which reads only the in-memory map) never sees the downloaded proxy until
|
||||
// restart, so check_for_missing_synced_entities/sync_proxy treat it as
|
||||
// missing every pass and re-download it forever. Mirrors download_group/
|
||||
// download_vpn/download_extension.
|
||||
proxy_manager.upsert_stored_proxy(proxy.clone());
|
||||
|
||||
// Emit event for UI update
|
||||
if let Some(_handle) = app_handle {
|
||||
let _ = events::emit("stored-proxies-changed", ());
|
||||
|
||||
@@ -651,7 +651,12 @@ impl WayfernManager {
|
||||
"--disable-session-crashed-bubble".to_string(),
|
||||
"--hide-crash-restore-bubble".to_string(),
|
||||
"--disable-infobars".to_string(),
|
||||
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns".to_string(),
|
||||
// Prefetch* / NoStatePrefetch: cross-site Speculation-Rules prefetch uses
|
||||
// an isolated NetworkContext that defaults to DIRECT egress (real host IP
|
||||
// leaks past the per-profile proxy). Disabling via a LAUNCH FLAG cannot be
|
||||
// re-enabled by an imported/synced network_prediction_options pref (which a
|
||||
// compile-time pref default could be).
|
||||
"--disable-features=DialMediaRouteProvider,DnsOverHttps,AsyncDns,Prefetch,PrefetchProxy,SpeculationRulesPrefetchFuture,NoStatePrefetch".to_string(),
|
||||
"--use-mock-keychain".to_string(),
|
||||
"--password-store=basic".to_string(),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user