From 349e6f91979eb9c0b0a1fab4a7545c5346e75ef9 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 25 Oct 2026 22:58:29 -0700 Subject: [PATCH] Fix proxy passthrough on Windows (#779) Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 26 ++++++++++++++++++ crates/yaak-proxy/src/lib.rs | 15 +++++++++-- crates/yaak-proxy/src/request.rs | 45 +++++++++++++++++++++++++++----- crates/yaak-proxy/tests/proxy.rs | 43 ++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd4f90825..3a0684b13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,29 @@ jobs: run: | cargo build -p yaak-cli node plugins/importer-openapi/tests/roundtrip.mjs + + test-windows: + name: Rust Tests (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: voidzero-dev/setup-vp@v1 + with: + node-version: "24" + cache: true + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci + cache-on-failure: true + - name: Install Protoc for plugin-runtime + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - run: vp install + - run: npm run bootstrap + env: + YAAK_TARGET_ARCH: x64 + - name: Run Rust Tests + run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry diff --git a/crates/yaak-proxy/src/lib.rs b/crates/yaak-proxy/src/lib.rs index 5a1d35678..00dc784fb 100644 --- a/crates/yaak-proxy/src/lib.rs +++ b/crates/yaak-proxy/src/lib.rs @@ -133,6 +133,7 @@ pub struct ProxyHandle { shutdown_tx: Option>, thread_handle: Option>, event_rx: Option>, + engine: Arc, /// The bound port. pub port: u16, } @@ -142,6 +143,14 @@ impl ProxyHandle { pub fn take_event_rx(&mut self) -> Option> { self.event_rx.take() } + + /// Handshake failures counted toward passing `addr` (`host:port`) through, and whether it + /// already is. For tests that must wait for the engine to record a failure. + #[doc(hidden)] + pub fn handshake_state(&self, addr: &str) -> (u32, bool) { + let failures = self.engine.handshake_failures.lock().unwrap().get(addr).copied(); + (failures.unwrap_or_default(), self.engine.is_passthrough(addr)) + } } impl Drop for ProxyHandle { @@ -180,8 +189,8 @@ impl Engine { self.passthrough.lock().unwrap().contains(addr) } - /// Returns whether `addr` is now passed through. macOS clients reject the CA with a bare - /// reset, indistinguishable from an abandoned handshake, so those need two in a row. + /// Returns whether `addr` is now passed through. macOS and Windows clients reject the CA with + /// a bare hang-up, indistinguishable from an abandoned connection, so those need two in a row. fn record_handshake_failure(&self, addr: &str, certificate_rejected: bool) -> bool { let mut failures = self.handshake_failures.lock().unwrap(); let count = failures.entry(addr.to_string()).or_default(); @@ -237,6 +246,7 @@ pub fn start_proxy(config: ProxyConfig) -> Result { }); let port = config.port; + let handle_engine = engine.clone(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let (ready_tx, ready_rx) = std_mpsc::channel(); @@ -286,6 +296,7 @@ pub fn start_proxy(config: ProxyConfig) -> Result { shutdown_tx: Some(shutdown_tx), thread_handle: Some(thread_handle), event_rx: Some(event_rx), + engine: handle_engine, port, }), Ok(Err(e)) => Err(e), diff --git a/crates/yaak-proxy/src/request.rs b/crates/yaak-proxy/src/request.rs index 1c14029a1..01990cff0 100644 --- a/crates/yaak-proxy/src/request.rs +++ b/crates/yaak-proxy/src/request.rs @@ -219,24 +219,57 @@ where .map(str::to_string) .unwrap_or_else(|| target.server_name.clone()); let config = engine.ca.server_config(&server_name)?; - let tls = match start.into_stream(config).await { + let mut tls = match start.into_stream(config).await { Ok(tls) => tls, Err(e) => { - if engine.record_handshake_failure(&target.addr, is_certificate_rejection(&e)) { - info!("Client rejected the proxy for {}, passing it through: {e}", target.addr); - } else { - debug!("TLS handshake with client failed for {}: {e}", target.addr); - } + record_handshake_failure(&engine, &target.addr, is_certificate_rejection(&e), &e); return Ok(()); } }; + + // SChannel (native-tls on Windows) checks the certificate only after sending its Finished, + // so it rejects the proxy by hanging up on a completed handshake. Only a request counts as + // success. + let mut buf = vec![0u8; 4096]; + let n = match tls.read(&mut buf).await { + Ok(n) => n, + Err(e) if is_hang_up(&e) => { + record_handshake_failure(&engine, &target.addr, false, &e); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + if n == 0 { + return Ok(()); + } + buf.truncate(n); engine.record_handshake_success(&target.addr); + let tls = Rewind::new(Bytes::from(buf), tls); let target = Arc::new(Target { server_name, ..target }); serve_http1(tls, move |req| forward_https(req, target.clone(), engine.clone())).await?; Ok(()) } +fn record_handshake_failure( + engine: &Engine, + addr: &str, + certificate_rejected: bool, + e: &std::io::Error, +) { + if engine.record_handshake_failure(addr, certificate_rejected) { + info!("Client rejected the proxy for {addr}, passing it through: {e}"); + } else { + debug!("TLS handshake with client failed for {addr}: {e}"); + } +} + +/// The client closed the connection without a TLS `close_notify`, or reset it. +fn is_hang_up(e: &std::io::Error) -> bool { + use std::io::ErrorKind::{ConnectionAborted, ConnectionReset, UnexpectedEof}; + matches!(e.kind(), UnexpectedEof | ConnectionReset | ConnectionAborted) +} + fn is_certificate_rejection(e: &std::io::Error) -> bool { use rustls::AlertDescription::{ BadCertificate, CertificateExpired, CertificateRevoked, CertificateUnknown, UnknownCA, diff --git a/crates/yaak-proxy/tests/proxy.rs b/crates/yaak-proxy/tests/proxy.rs index a3d0c3b72..2fe924497 100644 --- a/crates/yaak-proxy/tests/proxy.rs +++ b/crates/yaak-proxy/tests/proxy.rs @@ -203,6 +203,19 @@ impl Harness { Arc::new(config) } + /// Waits until the engine reports `state` (failures counted, passed through) for `addr`. + async fn wait_for_handshake_state(&self, addr: &str, state: (u32, bool)) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while self.proxy.handshake_state(addr) != state { + assert!( + tokio::time::Instant::now() < deadline, + "{addr} stayed at {:?}, expected {state:?}", + self.proxy.handshake_state(addr) + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + /// Events of the next flow, through its terminal event plus any stragglers. fn flow_events(&self) -> Vec { let mut events = Vec::new(); @@ -464,6 +477,36 @@ async fn certificate_alert_passes_through_on_the_next_connection() { .expect("one certificate alert should be enough to pass the host through"); } +/// SChannel (native-tls on Windows) checks the certificate only after sending its Finished, so +/// the proxy sees a completed handshake and then a hang-up instead of an alert. +#[tokio::test(flavor = "multi_thread")] +async fn hang_up_after_the_handshake_passes_through() { + let h = Harness::start(1024); + let upstream = spawn_upstream(Some(h.pki.server.clone())).await; + let port = upstream.port(); + let name = ServerName::try_from("localhost").unwrap(); + + let addr = format!("localhost:{port}"); + let hangs_up = TlsConnector::from(h.tls_client_config()); + for state in [(1, false), (0, true)] { + let tls = hangs_up.connect(name.clone(), h.connect_tunnel(port).await).await.unwrap(); + drop(tls); + h.wait_for_handshake_state(&addr, state).await; + } + + let mut roots = RootCertStore::empty(); + roots.add(h.pki.ca_der.clone()).unwrap(); + let config = ClientConfig::builder_with_provider(ring()) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + TlsConnector::from(Arc::new(config)) + .connect(name, h.connect_tunnel(port).await) + .await + .expect("two hang-ups after the handshake should pass the host through"); +} + #[tokio::test(flavor = "multi_thread")] async fn abandoned_handshake_does_not_disable_capture() { let h = Harness::start(1024);