diff --git a/changelog/v0.1.0-beta4.txt b/changelog/v0.1.0-beta4.txt index 3f7d9709..59479dde 100644 --- a/changelog/v0.1.0-beta4.txt +++ b/changelog/v0.1.0-beta4.txt @@ -43,6 +43,7 @@ SYNC - 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: allowing or declining remote control now takes effect whichever profile is open. +- 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..bd7b811a --- /dev/null +++ b/src-tauri/tests/sync2_http_proxy.rs @@ -0,0 +1,57 @@ +//! 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 std::time::Duration; + +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" + ); + let line = tokio::time::timeout(Duration::from_secs(5), reached_store).await; + assert_eq!( + line.expect("the store didn't get the request").unwrap(), + "GET /flow-sync/x HTTP/1.1" + ); +}