fix: properly handle location spoofing for socks5 proxies

This commit is contained in:
zhom
2026-07-11 15:01:42 +04:00
parent 06e34527b6
commit 86d58717b4
6 changed files with 197 additions and 44 deletions
+10 -4
View File
@@ -294,7 +294,7 @@ impl BrowserRunner {
config_for_generation.fingerprint = None;
// Generate a new fingerprint
let new_fingerprint = self
let (new_fingerprint, geolocation_applied) = self
.wayfern_manager
.generate_fingerprint_config(&app_handle, profile, &config_for_generation)
.await
@@ -318,13 +318,19 @@ impl BrowserRunner {
updated_wayfern_config.os = wayfern_config.os.clone();
}
// The fresh fingerprint's location matches the current routing; record
// its signature so launches keep it in sync with the non-randomize path.
updated_wayfern_config.geo_proxy_signature =
// its signature so launches keep it in sync with the non-randomize
// path. Only when geolocation actually applied — otherwise leave it
// unset so the refresh path can repair the location if the user later
// turns randomize off.
updated_wayfern_config.geo_proxy_signature = if geolocation_applied {
Some(crate::wayfern_manager::WayfernManager::geo_signature(
upstream_proxy.as_ref(),
profile.vpn_id.as_deref(),
wayfern_config.geoip.as_ref(),
));
))
} else {
None
};
updated_profile.wayfern_config = Some(updated_wayfern_config.clone());
log::info!(
+34 -17
View File
@@ -28,7 +28,9 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
"https://ipecho.net/plain",
];
let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(5));
// 10s rather than 5s: residential proxies that allocate an exit on first
// connect routinely need more than 5s for the initial request.
let client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(10));
let client = if let Some(proxy_url) = proxy {
let proxy = reqwest::Proxy::all(proxy_url)
@@ -46,25 +48,40 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
let mut errors = Vec::new();
// Overall deadline across all endpoints. Without it, a proxy that accepts
// connections but stalls holds callers for the full 6 x 10s; slow-but-live
// proxies still get the whole 10s on the endpoints that fit the budget.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
for url in &urls {
match client.get(*url).send().await {
Ok(response) if response.status().is_success() => match response.text().await {
Ok(text) => {
let ip = text.trim().to_string();
if validate_ip(&ip) {
return Ok(ip);
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
errors.push(format!("{}: skipped (30s overall deadline reached)", url));
continue;
}
let attempt = async {
match client.get(*url).send().await {
Ok(response) if response.status().is_success() => match response.text().await {
Ok(text) => {
let ip = text.trim().to_string();
if validate_ip(&ip) {
Ok(ip)
} else {
Err(format!("{}: response is not an IP address", url))
}
}
}
Err(e) => {
errors.push(format!("{}: {}", url, e));
}
},
Ok(response) => {
errors.push(format!("{}: HTTP {}", url, response.status()));
}
Err(e) => {
errors.push(format!("{}: {}", url, e));
Err(e) => Err(format!("{}: {}", url, e)),
},
Ok(response) => Err(format!("{}: HTTP {}", url, response.status())),
Err(e) => Err(format!("{}: {}", url, e)),
}
};
match tokio::time::timeout(remaining, attempt).await {
Ok(Ok(ip)) => return Ok(ip),
Ok(Err(e)) => errors.push(e),
Err(_) => errors.push(format!("{}: timed out (30s overall deadline reached)", url)),
}
}
+1
View File
@@ -1213,6 +1213,7 @@ async fn generate_sample_fingerprint(
manager
.generate_fingerprint_config(&app_handle, &temp_profile, &config)
.await
.map(|(fingerprint, _geolocation_applied)| fingerprint)
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
} else {
Err(format!(
+28 -10
View File
@@ -168,6 +168,11 @@ impl ProfileManager {
}
}
// Whether the fingerprint's location fields are known to match the
// profile's routing. Provided fingerprints keep the old stamping
// behavior; for generated ones this comes from the geolocation lookup.
let mut geolocation_applied = true;
// Generate fingerprint if not already provided
if config.fingerprint.is_none() {
log::info!("Generating fingerprint for Wayfern profile: {name}");
@@ -209,8 +214,9 @@ impl ProfileManager {
.generate_fingerprint_config(app_handle, &temp_profile, &config)
.await
{
Ok(generated_fingerprint) => {
Ok((generated_fingerprint, geo_applied)) => {
config.fingerprint = Some(generated_fingerprint);
geolocation_applied = geo_applied;
log::info!("Successfully generated fingerprint for Wayfern profile: {name}");
}
Err(e) => {
@@ -226,15 +232,27 @@ impl ProfileManager {
// Record which proxy/geoip the fingerprint's location data was computed
// for. On launch this is compared against the profile's current routing
// so a proxy that was changed after creation triggers a location refresh
// instead of showing a stale timezone.
config.geo_proxy_signature = Some(crate::wayfern_manager::WayfernManager::geo_signature(
proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id))
.as_ref(),
None,
config.geoip.as_ref(),
));
// instead of showing a stale timezone. Only stamped when geolocation
// actually succeeded: on failure the fingerprint carries the HOST
// timezone/locale, and a stamped signature would match at launch and
// suppress the refresh that repairs it — latching the leak permanently.
config.geo_proxy_signature = if geolocation_applied {
Some(crate::wayfern_manager::WayfernManager::geo_signature(
proxy_id
.as_ref()
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id))
.as_ref(),
None,
config.geoip.as_ref(),
))
} else {
if !matches!(config.geoip.as_ref(), Some(serde_json::Value::Bool(false))) {
log::warn!(
"Geolocation could not be applied for Wayfern profile {name}; leaving geo signature unset so the next launch refreshes location through the profile's proxy"
);
}
None
};
// Clear the proxy from config after fingerprint generation
config.proxy = None;
+3 -1
View File
@@ -329,7 +329,9 @@ impl ProfileImporter {
.generate_fingerprint_config(app_handle, &temp_profile, &config)
.await
{
Ok(fp) => config.fingerprint = Some(fp),
// geo_proxy_signature is intentionally left unset here: the first
// launch's signature-mismatch refresh verifies the location either way.
Ok((fp, _geolocation_applied)) => config.fingerprint = Some(fp),
Err(e) => {
return Err(
format!(
+121 -12
View File
@@ -284,7 +284,11 @@ impl WayfernManager {
vpn_id: Option<&str>,
geoip: Option<&serde_json::Value>,
) -> String {
match geoip {
// The "v2:" prefix invalidates every signature stamped before geolocation
// failures stopped being stamped: those may describe fingerprints that
// silently carry the host's location, so each pre-v2 profile gets one
// launch-time refresh and is re-stamped in the current format.
let base = match geoip {
Some(serde_json::Value::Bool(false)) => "off".to_string(),
Some(serde_json::Value::String(ip)) if !ip.is_empty() => format!("ip:{ip}"),
_ => {
@@ -302,7 +306,8 @@ impl WayfernManager {
"direct".to_string()
}
}
}
};
format!("v2:{base}")
}
/// Apply timezone/geolocation fields to a fingerprint object from the proxy's
@@ -397,12 +402,44 @@ impl WayfernManager {
}
}
/// True when `url` is a socks proxy on a remote (non-loopback) host — the
/// case where reqwest's SOCKS connector can't be trusted with the
/// geolocation fetch. Loopback socks URLs are the app's own donut-proxy
/// workers, whose single-segment replies don't trigger the connector bug.
fn is_remote_socks_url(url: &str) -> bool {
url.starts_with("socks")
&& url::Url::parse(url)
.ok()
.and_then(|u| match u.host() {
Some(url::Host::Ipv4(ip)) => Some(!ip.is_loopback()),
Some(url::Host::Ipv6(ip)) => Some(!ip.is_loopback()),
// socks is a non-special scheme, so the url crate keeps even
// IP-literal hosts as Domain — parse them before comparing.
Some(url::Host::Domain(domain)) => Some(
domain != "localhost"
&& domain
.parse::<std::net::IpAddr>()
.map(|ip| !ip.is_loopback())
.unwrap_or(true),
),
None => None,
})
.unwrap_or(false)
}
/// Generate a fingerprint for `config`, returning the fingerprint JSON and
/// whether fresh geolocation was applied to it. Callers must only stamp
/// `geo_proxy_signature` when geolocation succeeded: the base fingerprint
/// comes from a headless Wayfern launched without a proxy, so on failure it
/// silently carries the HOST timezone/locale — stamping the signature then
/// would tell the launch-time refresh the location is already correct for
/// this proxy and permanently disable the one path that can repair it.
pub async fn generate_fingerprint_config(
&self,
_app_handle: &AppHandle,
profile: &BrowserProfile,
config: &WayfernConfig,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
) -> Result<(String, bool), Box<dyn std::error::Error + Send + Sync>> {
let executable_path = BrowserRunner::instance()
.get_browser_executable_path(profile)
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
@@ -549,7 +586,7 @@ impl WayfernManager {
.send_cdp_command(&ws_url, "Wayfern.getFingerprint", json!({}))
.await;
let fingerprint = match get_result {
let (fingerprint, geolocation_applied) = match get_result {
Ok(result) => {
// Wayfern.getFingerprint returns { fingerprint: {...} }
// We need to extract just the fingerprint object
@@ -557,16 +594,57 @@ impl WayfernManager {
// Normalize the fingerprint: convert JSON string fields to proper types
let mut normalized = Self::normalize_fingerprint(fp);
// reqwest's SOCKS connector (hyper-util) corrupts its parse buffer
// when a proxy splits a handshake reply across TCP segments, so a
// socks upstream here can fail even though the proxy is healthy.
// Route the geolocation lookup through a temporary local donut-proxy
// worker — the same path the browser itself uses — and fall back to
// the upstream URL only if the worker can't start. Two exclusions:
// no worker when geolocation won't fetch through the proxy at all
// (disabled, or a fixed geoip IP), and none for loopback socks URLs —
// launch-time callers pass the already-running local worker's
// socks5://127.0.0.1 URL, whose single-segment replies don't trigger
// the bug, so chaining a second worker would only add latency.
let needs_proxied_geo_fetch = !matches!(
config.geoip.as_ref(),
Some(serde_json::Value::Bool(false)) | Some(serde_json::Value::String(_))
);
let remote_socks_upstream = config
.proxy
.as_deref()
.filter(|url| Self::is_remote_socks_url(url));
let (geo_proxy, temp_worker_id) = match remote_socks_upstream {
Some(url) if needs_proxied_geo_fetch => {
match crate::proxy_runner::start_proxy_process(Some(url.to_string()), None)
.await
.map_err(|e| e.to_string())
{
Ok(worker) => {
let local_url = format!("http://127.0.0.1:{}", worker.local_port.unwrap_or(0));
(Some(local_url), Some(worker.id))
}
Err(e) => {
log::warn!(
"Could not start local proxy worker for geolocation ({e}); using the socks upstream directly"
);
(config.proxy.clone(), None)
}
}
}
_ => (config.proxy.clone(), None),
};
// Apply timezone/geolocation for the proxy this fingerprint is being
// generated against. Shared with the launch-time location refresh.
Self::apply_geolocation(
&mut normalized,
config.proxy.as_deref(),
config.geoip.as_ref(),
)
.await;
let geolocation_applied =
Self::apply_geolocation(&mut normalized, geo_proxy.as_deref(), config.geoip.as_ref())
.await;
normalized
if let Some(worker_id) = temp_worker_id {
let _ = crate::proxy_runner::stop_proxy_process(&worker_id).await;
}
(normalized, geolocation_applied)
}
Err(e) => {
cleanup().await;
@@ -599,7 +677,7 @@ impl WayfernManager {
);
}
Ok(fingerprint_json)
Ok((fingerprint_json, geolocation_applied))
}
#[allow(clippy::too_many_arguments)]
@@ -1418,6 +1496,37 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
mod tests {
use super::*;
#[test]
fn remote_socks_url_detection() {
// Remote socks upstreams (the hyper-util-affected case) are detected...
assert!(WayfernManager::is_remote_socks_url(
"socks5://user:pass@gw.dataimpulse.com:10000"
));
assert!(WayfernManager::is_remote_socks_url("socks5://1.2.3.4:1080"));
assert!(WayfernManager::is_remote_socks_url("socks4://1.2.3.4:1080"));
// ...but the app's own loopback workers are not. socks is a non-special
// URL scheme, so the IP literal parses as Host::Domain — the launch-time
// randomize path depends on this returning false.
assert!(!WayfernManager::is_remote_socks_url(
"socks5://127.0.0.1:24001"
));
assert!(!WayfernManager::is_remote_socks_url("socks5://[::1]:24001"));
assert!(!WayfernManager::is_remote_socks_url(
"socks5://localhost:24001"
));
// Non-socks schemes and unparsable URLs never need the workaround.
assert!(!WayfernManager::is_remote_socks_url(
"http://gw.dataimpulse.com:10000"
));
assert!(!WayfernManager::is_remote_socks_url(
"https://gw.dataimpulse.com:10000"
));
assert!(!WayfernManager::is_remote_socks_url("socks5://"));
assert!(!WayfernManager::is_remote_socks_url("not a url"));
}
#[test]
fn window_size_prefers_outer_window_dimensions() {
// Field names + values mirror a real Wayfern fingerprint (camelCase).