Fix proxy passthrough on Windows (#779)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and GitHub committed 2026-10-25 22:58:29 -07:00
1 parent 1248112fe0
commit 349e6f9197
4 files changed
+121 -8

No files matched your search

+26
View File
@@ -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
+13 -2
View File
@@ -133,6 +133,7 @@ pub struct ProxyHandle {
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
thread_handle: Option<std::thread::JoinHandle<()>>,
event_rx: Option<std_mpsc::Receiver<ProxyEvent>>,
engine: Arc<Engine>,
/// The bound port.
pub port: u16,
}
@@ -142,6 +143,14 @@ impl ProxyHandle {
pub fn take_event_rx(&mut self) -> Option<std_mpsc::Receiver<ProxyEvent>> {
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<ProxyHandle, String> {
});
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<ProxyHandle, String> {
shutdown_tx: Some(shutdown_tx),
thread_handle: Some(thread_handle),
event_rx: Some(event_rx),
engine: handle_engine,
port,
}),
Ok(Err(e)) => Err(e),
+39 -6
View File
@@ -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,
+43
View File
@@ -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<ProxyEvent> {
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);