From 2cecede110d7a207270b259486d970faa0bd70b1 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:41:26 +0200 Subject: [PATCH 1/2] fix(sync): keep a system proxy away from plain HTTP stores --- changelog/v0.1.0-beta4.txt | 1 + src-tauri/src/sync2/http.rs | 20 +++++++++-- src-tauri/tests/sync2_http_proxy.rs | 55 +++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 src-tauri/tests/sync2_http_proxy.rs diff --git a/changelog/v0.1.0-beta4.txt b/changelog/v0.1.0-beta4.txt index 1f308b7d..7e981961 100644 --- a/changelog/v0.1.0-beta4.txt +++ b/changelog/v0.1.0-beta4.txt @@ -42,6 +42,7 @@ SYNC - On the local network, an object as large as a sync object can be is now passed between devices whole, instead of being refused for its size. - Linked devices check for devices asking to join every 5 seconds only while the Sync page is on screen, and the check no longer reads the whole device list from the server. - A new device that starts from a snapshot whose data can't be read now starts from an older snapshot instead of failing the sync. +- Linked devices reach a Flow server or WebDAV folder on your local network directly, even when a system proxy is set. - Linked devices sign in to your Flow server about once every 15 minutes instead of before every sync. MUSIC diff --git a/src-tauri/src/sync2/http.rs b/src-tauri/src/sync2/http.rs index 268b20ac..c1a9c76a 100644 --- a/src-tauri/src/sync2/http.rs +++ b/src-tauri/src/sync2/http.rs @@ -28,12 +28,19 @@ pub async fn read_body(mut response: Response, limit: usize) -> Result> pub struct Transport { /// A `.local` name over plain HTTP, checked and pinned so DNS can't move it afterwards. pinned: Option<(String, SocketAddr)>, + /// A proxy would move a plain request, credentials included, off the checked address. + direct: bool, } impl Transport { pub async fn for_url(url: &Url) -> Result { match url.scheme() { - "https" => return Ok(Self { pinned: None }), + "https" => { + return Ok(Self { + pinned: None, + direct: false, + }); + } "http" => {} _ => return Err(StoreError::Insecure), } @@ -42,7 +49,10 @@ impl Transport { .ok_or(StoreError::Insecure)? .trim_matches(['[', ']']); if host.parse::().is_ok_and(is_local_ip) { - return Ok(Self { pinned: None }); + return Ok(Self { + pinned: None, + direct: true, + }); } if !host.to_ascii_lowercase().ends_with(".local") { return Err(StoreError::Insecure); @@ -55,6 +65,7 @@ impl Transport { match addresses.first() { Some(first) if addresses.iter().all(|a| is_local_ip(a.ip())) => Ok(Self { pinned: Some((host.to_owned(), *first)), + direct: true, }), _ => Err(StoreError::Insecure), } @@ -66,6 +77,11 @@ impl Transport { .redirect(redirect::Policy::none()) .user_agent(concat!("FlowDesktop/", env!("CARGO_PKG_VERSION"))) .connect_timeout(Duration::from_secs(10)); + let builder = if self.direct { + builder.no_proxy() + } else { + builder + }; match &self.pinned { Some((host, address)) => builder.resolve(host, *address), None => builder, diff --git a/src-tauri/tests/sync2_http_proxy.rs b/src-tauri/tests/sync2_http_proxy.rs new file mode 100644 index 00000000..e12e0bb4 --- /dev/null +++ b/src-tauri/tests/sync2_http_proxy.rs @@ -0,0 +1,55 @@ +//! Plain HTTP goes only to the private address that was checked (spec/store.md, Transport +//! security). This test runs alone in its process because it sets the proxy environment reqwest +//! reads. + +use flow_desktop_lib::sync2::webdav::{WebDav, WebDavLogin}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +/// Answers one request with 404 and returns the first line it read. +async fn answer_one(listener: TcpListener) -> String { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0; 4096]; + let read = stream.read(&mut request).await.unwrap(); + let _ = stream + .write_all(b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n") + .await; + let text = String::from_utf8_lossy(&request[..read]); + text.lines().next().unwrap_or_default().to_owned() +} + +#[tokio::test] +async fn plain_http_to_a_private_address_skips_a_system_proxy() { + let proxy = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let store = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let store_address = store.local_addr().unwrap(); + // Before any client is built; nothing else in this process reads the environment. + unsafe { + let address = proxy.local_addr().unwrap(); + std::env::set_var("HTTP_PROXY", format!("http://{address}")); + std::env::set_var("http_proxy", format!("http://{address}")); + std::env::remove_var("NO_PROXY"); + std::env::remove_var("no_proxy"); + } + let through_proxy = tokio::spawn(answer_one(proxy)); + let reached_store = tokio::spawn(answer_one(store)); + + let login = WebDavLogin { + user: "flow".into(), + password: "secret".into(), + }; + let client = WebDav::connect(&format!("http://{store_address}/flow-sync/"), login) + .await + .unwrap(); + client.get("x").await.unwrap(); + + assert!( + !through_proxy.is_finished(), + "the request went through the proxy" + ); + assert_eq!( + reached_store.await.unwrap(), + "GET /flow-sync/x HTTP/1.1", + "the store didn't get the request" + ); +} From c74acdb4082cb9f71bbb4b04853adbfdd9c474f5 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:41:50 +0200 Subject: [PATCH 2/2] test(sync): stop waiting when a request never reaches the store --- src-tauri/tests/sync2_http_proxy.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src-tauri/tests/sync2_http_proxy.rs b/src-tauri/tests/sync2_http_proxy.rs index e12e0bb4..bd7b811a 100644 --- a/src-tauri/tests/sync2_http_proxy.rs +++ b/src-tauri/tests/sync2_http_proxy.rs @@ -2,6 +2,8 @@ //! security). This test runs alone in its process because it sets the proxy environment reqwest //! reads. +use std::time::Duration; + use flow_desktop_lib::sync2::webdav::{WebDav, WebDavLogin}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; @@ -47,9 +49,9 @@ async fn plain_http_to_a_private_address_skips_a_system_proxy() { !through_proxy.is_finished(), "the request went through the proxy" ); + let line = tokio::time::timeout(Duration::from_secs(5), reached_store).await; assert_eq!( - reached_store.await.unwrap(), - "GET /flow-sync/x HTTP/1.1", - "the store didn't get the request" + line.expect("the store didn't get the request").unwrap(), + "GET /flow-sync/x HTTP/1.1" ); }