//! The sans-IO proxy handshake state machine. use std::collections::VecDeque; use std::net::{IpAddr, SocketAddr}; use base64::Engine as _; use crate::{Credentials, MAX_HTTP_RESPONSE_LENGTH, ProxyError, ProxyScheme, ProxySpec, Target}; const MAX_HTTP_RESPONSE_HEADERS: usize = 64; const SOCKS4_VERSION: u8 = 0x04; const SOCKS5_VERSION: u8 = 0x05; const SOCKS_COMMAND_CONNECT: u8 = 0x01; const SOCKS4_REPLY_GRANTED: u8 = 0x5A; const SOCKS5_METHOD_NO_AUTHENTICATION: u8 = 0x00; const SOCKS5_METHOD_USERNAME_PASSWORD: u8 = 0x02; const SOCKS5_ADDRESS_IPV4: u8 = 0x01; const SOCKS5_ADDRESS_DOMAIN: u8 = 0x03; const SOCKS5_ADDRESS_IPV6: u8 = 0x04; /// A proxy handshake in progress. /// /// Sans-IO: the handshake never performs I/O itself. Drive it by calling /// [`advance`](Self::advance) with bytes received from the proxy and acting /// on the returned [`Step`]s until it reports [`Step::Done`]. pub struct Handshake { exchanges: VecDeque, input: Vec, } /// What the caller must do next to make progress on the handshake. #[derive(Debug, PartialEq, Eq)] pub enum Step { /// Write these bytes to the proxy. Send(Vec), /// Read more bytes from the proxy and pass them to the next `advance`. NeedMoreInput, /// The tunnel is established. `leftover` holds any bytes the proxy sent /// past the end of the handshake; they belong to the tunneled stream and /// must be consumed before reading from the transport again. Done { leftover: Vec }, } enum Exchange { Send(Vec), Expect(Expectation), } enum Expectation { HttpResponseHead, Socks5MethodSelection { offered: u8 }, Socks5AuthReply, Socks5ConnectReply, Socks4ConnectReply, } impl Handshake { /// Plans the handshake for tunneling to `target` through the proxy /// described by `spec`. /// /// Passing a [`Target::Domain`] delegates DNS resolution to the proxy /// regardless of scheme (SOCKS4 proxies are sent the 4a extension); /// resolve the host locally and pass a [`Target::Address`] when /// [`ProxySpec::remote_dns`] is false, to follow the proxy URL's intent. pub fn new(spec: &ProxySpec, target: &Target) -> Result { // The domain is spliced into protocol messages (the CONNECT request // line, NUL-terminated SOCKS4a fields), so control bytes would let a // hostile target string forge protocol structure. No valid host name // contains them; fail closed rather than trusting the caller to have // validated. if let Target::Domain(domain, _) = target && domain .bytes() .any(|byte| byte.is_ascii_control() || byte == 0x7F) { return Err(ProxyError::DomainContainsControlBytes); } let mut exchanges = VecDeque::new(); match spec.scheme { ProxyScheme::Http { .. } => { exchanges.push_back(Exchange::Send(http_connect_request( target, spec.credentials.as_ref(), ))); exchanges.push_back(Exchange::Expect(Expectation::HttpResponseHead)); } ProxyScheme::Socks4 { .. } => { exchanges.push_back(Exchange::Send(socks4_connect_request( target, spec.credentials.as_ref(), )?)); exchanges.push_back(Exchange::Expect(Expectation::Socks4ConnectReply)); } ProxyScheme::Socks5 { .. } => { let method = if spec.credentials.is_some() { SOCKS5_METHOD_USERNAME_PASSWORD } else { SOCKS5_METHOD_NO_AUTHENTICATION }; exchanges.push_back(Exchange::Send(vec![SOCKS5_VERSION, 1, method])); exchanges.push_back(Exchange::Expect(Expectation::Socks5MethodSelection { offered: method, })); if let Some(credentials) = &spec.credentials { exchanges.push_back(Exchange::Send(socks5_auth_request(credentials)?)); exchanges.push_back(Exchange::Expect(Expectation::Socks5AuthReply)); } exchanges.push_back(Exchange::Send(socks5_connect_request(target)?)); exchanges.push_back(Exchange::Expect(Expectation::Socks5ConnectReply)); } } Ok(Self { exchanges, input: Vec::new(), }) } /// Advances the state machine with bytes newly received from the proxy /// (empty on the first call, and whenever there is nothing new to feed). /// /// Callers must not feed the same bytes twice: partial input is buffered /// internally until it can be parsed. pub fn advance(&mut self, received: &[u8]) -> Result { self.input.extend_from_slice(received); loop { match self.exchanges.pop_front() { None => { return Ok(Step::Done { leftover: std::mem::take(&mut self.input), }); } Some(Exchange::Send(bytes)) => return Ok(Step::Send(bytes)), Some(Exchange::Expect(expectation)) => { match try_parse(&expectation, &self.input)? { Some(consumed) => { self.input.drain(..consumed); } None => { self.exchanges.push_front(Exchange::Expect(expectation)); return Ok(Step::NeedMoreInput); } } } } } } } /// Attempts to parse one expected proxy message from the buffered input. /// Returns the number of bytes consumed, or `None` when more input is needed. fn try_parse(expectation: &Expectation, input: &[u8]) -> Result, ProxyError> { match expectation { Expectation::HttpResponseHead => parse_http_response_head(input), Expectation::Socks5MethodSelection { offered } => { let Some(reply) = input.get(..2) else { return Ok(None); }; if reply[0] != SOCKS5_VERSION { return Err(ProxyError::UnexpectedSocksVersion(reply[0])); } if reply[1] != *offered { return Err(ProxyError::AuthMethodRejected { selected: reply[1] }); } Ok(Some(2)) } Expectation::Socks5AuthReply => { let Some(reply) = input.get(..2) else { return Ok(None); }; // RFC 1929: any non-zero status is failure. if reply[1] != 0x00 { return Err(ProxyError::AuthenticationFailed); } Ok(Some(2)) } Expectation::Socks5ConnectReply => { let Some(header) = input.get(..4) else { return Ok(None); }; if header[0] != SOCKS5_VERSION { return Err(ProxyError::UnexpectedSocksVersion(header[0])); } if header[1] != 0x00 { return Err(ProxyError::Socks5ConnectRefused(header[1])); } let address_length = match header[3] { SOCKS5_ADDRESS_IPV4 => 4, SOCKS5_ADDRESS_IPV6 => 16, SOCKS5_ADDRESS_DOMAIN => match input.get(4) { Some(length) => 1 + *length as usize, None => return Ok(None), }, other => return Err(ProxyError::UnknownAddressType(other)), }; // The reply's bound address and port only matter for BIND, so // they are consumed and discarded. let total = 4 + address_length + 2; if input.len() < total { return Ok(None); } Ok(Some(total)) } Expectation::Socks4ConnectReply => { if input.len() < 8 { return Ok(None); } if input[1] != SOCKS4_REPLY_GRANTED { return Err(ProxyError::Socks4ConnectRefused(input[1])); } Ok(Some(8)) } } } fn parse_http_response_head(input: &[u8]) -> Result, ProxyError> { let mut headers = [httparse::EMPTY_HEADER; MAX_HTTP_RESPONSE_HEADERS]; let mut response = httparse::Response::new(&mut headers); match response.parse(input) { Ok(httparse::Status::Complete(consumed)) => { // The cap applies to complete heads too: a driver that reads in // large chunks could otherwise hand a well-formed multi-megabyte // head to a single `advance` and have it accepted. if consumed > MAX_HTTP_RESPONSE_LENGTH { return Err(ProxyError::HttpResponseTooLarge); } let code = response.code.ok_or_else(|| { ProxyError::MalformedHttpResponse("missing status code".to_string()) })?; if !(200..300).contains(&code) { return Err(ProxyError::HttpConnectRefused(code)); } Ok(Some(consumed)) } Ok(httparse::Status::Partial) => { if input.len() > MAX_HTTP_RESPONSE_LENGTH { Err(ProxyError::HttpResponseTooLarge) } else { Ok(None) } } Err(error) => Err(ProxyError::MalformedHttpResponse(error.to_string())), } } fn http_connect_request(target: &Target, credentials: Option<&Credentials>) -> Vec { let host = match target { Target::Domain(domain, port) => format!("{domain}:{port}"), // `SocketAddr`'s `Display` brackets IPv6 addresses, matching the // `host:port` form CONNECT requires. Target::Address(address) => address.to_string(), }; let mut request = format!("CONNECT {host} HTTP/1.1\r\nHost: {host}\r\nProxy-Connection: Keep-Alive\r\n"); if let Some(Credentials { username, password }) = credentials { let encoded = base64::prelude::BASE64_STANDARD.encode(format!("{username}:{password}")); request.push_str(&format!("Proxy-Authorization: Basic {encoded}\r\n")); } request.push_str("\r\n"); request.into_bytes() } fn socks5_auth_request(credentials: &Credentials) -> Result, ProxyError> { let username = credentials.username.as_bytes(); let password = credentials.password.as_bytes(); if username.len() > 255 || password.len() > 255 { return Err(ProxyError::CredentialsTooLong); } let mut request = Vec::with_capacity(3 + username.len() + password.len()); // RFC 1929 subnegotiation version. request.push(0x01); request.push(username.len() as u8); request.extend_from_slice(username); request.push(password.len() as u8); request.extend_from_slice(password); Ok(request) } fn socks5_connect_request(target: &Target) -> Result, ProxyError> { let mut request = vec![SOCKS5_VERSION, SOCKS_COMMAND_CONNECT, 0x00]; let port = match target { Target::Domain(domain, port) => { let domain = domain.as_bytes(); if domain.len() > 255 { return Err(ProxyError::DomainTooLong); } request.push(SOCKS5_ADDRESS_DOMAIN); request.push(domain.len() as u8); request.extend_from_slice(domain); *port } Target::Address(address) => { match address.ip() { IpAddr::V4(ip) => { request.push(SOCKS5_ADDRESS_IPV4); request.extend_from_slice(&ip.octets()); } IpAddr::V6(ip) => { request.push(SOCKS5_ADDRESS_IPV6); request.extend_from_slice(&ip.octets()); } } address.port() } }; request.extend_from_slice(&port.to_be_bytes()); Ok(request) } fn socks4_connect_request( target: &Target, credentials: Option<&Credentials>, ) -> Result, ProxyError> { let user_id = credentials .map(|credentials| credentials.username.as_str()) .unwrap_or(""); // The user id field is NUL-terminated on the wire, so an embedded NUL // would truncate it and shift the fields that follow. if user_id.as_bytes().contains(&0x00) { return Err(ProxyError::Socks4UserIdContainsNul); } let mut request = vec![SOCKS4_VERSION, SOCKS_COMMAND_CONNECT]; match target { Target::Address(SocketAddr::V4(address)) => { request.extend_from_slice(&address.port().to_be_bytes()); request.extend_from_slice(&address.ip().octets()); request.extend_from_slice(user_id.as_bytes()); request.push(0x00); } Target::Address(SocketAddr::V6(_)) => return Err(ProxyError::Socks4Ipv4Only), Target::Domain(domain, port) => { // SOCKS4a: the invalid destination address 0.0.0.1 signals that // a host name follows the user id. request.extend_from_slice(&port.to_be_bytes()); request.extend_from_slice(&[0, 0, 0, 1]); request.extend_from_slice(user_id.as_bytes()); request.push(0x00); request.extend_from_slice(domain.as_bytes()); request.push(0x00); } } Ok(request) } #[cfg(test)] mod tests { use proptest::prelude::*; use super::*; #[test] fn http_connect_completes_on_200_and_preserves_leftover() { let server_bytes = b"HTTP/1.1 200 Connection Established\r\n\r\ntunnel-bytes"; let (sends, leftover) = run( "http://proxy:8080", &domain_target(), server_bytes, &[server_bytes.len()], ) .unwrap(); assert_eq!(sends.len(), 1); let request = String::from_utf8(sends[0].clone()).unwrap(); assert!( request.starts_with("CONNECT cloud.example.com:443 HTTP/1.1\r\n"), "unexpected request: {request:?}" ); assert!(request.contains("Host: cloud.example.com:443\r\n")); assert!(request.ends_with("\r\n\r\n")); assert_eq!(leftover, b"tunnel-bytes"); } #[test] fn http_connect_sends_basic_authorization() { let (sends, _) = run( "http://user:pass@proxy:8080", &domain_target(), b"HTTP/1.1 200 OK\r\n\r\n", &[64], ) .unwrap(); let request = String::from_utf8(sends[0].clone()).unwrap(); // "dXNlcjpwYXNz" is base64("user:pass"). assert!( request.contains("Proxy-Authorization: Basic dXNlcjpwYXNz\r\n"), "unexpected request: {request:?}" ); } #[test] fn http_connect_fails_on_error_status() { let error = run( "http://proxy:8080", &domain_target(), b"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic\r\n\r\n", &[64], ) .unwrap_err(); assert!(matches!(error, ProxyError::HttpConnectRefused(407))); } #[test] fn http_connect_rejects_oversized_response() { // A single never-ending header keeps the response incomplete (and // under httparse's header-count limit) until the size cap trips. let mut server_bytes = b"HTTP/1.1 200 OK\r\nX-Padding: ".to_vec(); server_bytes.resize(MAX_HTTP_RESPONSE_LENGTH + 1024, b'a'); let error = run("http://proxy:8080", &domain_target(), &server_bytes, &[64]).unwrap_err(); assert!(matches!(error, ProxyError::HttpResponseTooLarge)); } #[test] fn http_connect_rejects_oversized_complete_response_in_one_chunk() { // A *well-formed* head over the cap, delivered whole in a single // `advance`, must be rejected too: the cap can't depend on the // driver's read chunking. let mut server_bytes = b"HTTP/1.1 200 OK\r\nX-Padding: ".to_vec(); server_bytes.resize(MAX_HTTP_RESPONSE_LENGTH + 1024, b'a'); server_bytes.extend_from_slice(b"\r\n\r\n"); let error = run( "http://proxy:8080", &domain_target(), &server_bytes, &[server_bytes.len()], ) .unwrap_err(); assert!(matches!(error, ProxyError::HttpResponseTooLarge)); } #[test] fn rejects_domains_containing_control_bytes() { for scheme in ["http", "socks4a", "socks5h"] { for domain in ["evil.com\r\nX-Injected: x", "evil.com\0.example.com"] { let spec = spec(&format!("{scheme}://proxy:1080")); let target = Target::Domain(domain.to_string(), 443); let Err(error) = Handshake::new(&spec, &target) else { panic!("expected {scheme} handshake construction to fail for {domain:?}"); }; assert!(matches!(error, ProxyError::DomainContainsControlBytes)); } } } #[test] fn socks4_rejects_user_id_containing_nul() { let spec = spec("socks4://user%00id@proxy:1080"); let target = Target::Address("192.0.2.10:443".parse().unwrap()); let Err(error) = Handshake::new(&spec, &target) else { panic!("expected handshake construction to fail"); }; assert!(matches!(error, ProxyError::Socks4UserIdContainsNul)); } #[test] fn socks5_connect_with_domain_target() { let mut server_bytes = vec![0x05, 0x00]; server_bytes.extend_from_slice(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); server_bytes.extend_from_slice(b"tunnel"); let (sends, leftover) = run( "socks5h://proxy:1080", &domain_target(), &server_bytes, &[server_bytes.len()], ) .unwrap(); assert_eq!(sends[0], vec![0x05, 0x01, 0x00]); let mut expected_connect = vec![0x05, 0x01, 0x00, 0x03, 17]; expected_connect.extend_from_slice(b"cloud.example.com"); expected_connect.extend_from_slice(&443u16.to_be_bytes()); assert_eq!(sends[1], expected_connect); assert_eq!(leftover, b"tunnel"); } #[test] fn socks5_connect_with_username_password() { let mut server_bytes = vec![0x05, 0x02]; server_bytes.extend_from_slice(&[0x01, 0x00]); server_bytes.extend_from_slice(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); let (sends, _) = run( "socks5h://user:pass@proxy:1080", &domain_target(), &server_bytes, &[server_bytes.len()], ) .unwrap(); assert_eq!(sends[0], vec![0x05, 0x01, 0x02]); assert_eq!(sends[1], b"\x01\x04user\x04pass".to_vec()); assert_eq!(sends.len(), 3); } #[test] fn socks5_reports_connection_refusal() { let mut server_bytes = vec![0x05, 0x00]; server_bytes.extend_from_slice(&[0x05, 0x02, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); let error = run( "socks5h://proxy:1080", &domain_target(), &server_bytes, &[server_bytes.len()], ) .unwrap_err(); assert!(matches!(error, ProxyError::Socks5ConnectRefused(0x02))); } #[test] fn socks5_reports_rejected_authentication_method() { let error = run( "socks5h://proxy:1080", &domain_target(), &[0x05, 0xFF], &[2], ) .unwrap_err(); assert!(matches!( error, ProxyError::AuthMethodRejected { selected: 0xFF } )); } #[test] fn socks5_encodes_ip_targets() { let mut server_bytes = vec![0x05, 0x00]; server_bytes.extend_from_slice(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); let target = Target::Address("192.0.2.10:443".parse().unwrap()); let (sends, _) = run("socks5://proxy:1080", &target, &server_bytes, &[64]).unwrap(); assert_eq!( sends[1], vec![0x05, 0x01, 0x00, 0x01, 192, 0, 2, 10, 0x01, 0xBB] ); let target = Target::Address("[2001:db8::1]:443".parse().unwrap()); let (sends, _) = run("socks5://proxy:1080", &target, &server_bytes, &[64]).unwrap(); assert_eq!(sends[1][3], SOCKS5_ADDRESS_IPV6); assert_eq!(sends[1].len(), 4 + 16 + 2); } #[test] fn socks5_reply_with_domain_bound_address_is_consumed() { let mut server_bytes = vec![0x05, 0x00]; server_bytes.extend_from_slice(&[0x05, 0x00, 0x00, 0x03, 4]); server_bytes.extend_from_slice(b"host"); server_bytes.extend_from_slice(&[0, 80]); server_bytes.extend_from_slice(b"rest"); let (_, leftover) = run( "socks5h://proxy:1080", &domain_target(), &server_bytes, &[server_bytes.len()], ) .unwrap(); assert_eq!(leftover, b"rest"); } #[test] fn socks4_connect_with_ipv4_target() { let target = Target::Address("192.0.2.10:443".parse().unwrap()); let (sends, leftover) = run( "socks4://proxy:1080", &target, &[0x00, 0x5A, 0, 0, 0, 0, 0, 0], &[8], ) .unwrap(); assert_eq!(sends[0], vec![0x04, 0x01, 0x01, 0xBB, 192, 0, 2, 10, 0x00]); assert_eq!(leftover, b""); } #[test] fn socks4a_connect_with_domain_target_and_user_id() { let (sends, _) = run( "socks4a://userid@proxy:1080", &domain_target(), &[0x00, 0x5A, 0, 0, 0, 0, 0, 0], &[8], ) .unwrap(); let mut expected = vec![0x04, 0x01, 0x01, 0xBB, 0, 0, 0, 1]; expected.extend_from_slice(b"userid\x00"); expected.extend_from_slice(b"cloud.example.com\x00"); assert_eq!(sends[0], expected); } #[test] fn socks4_rejects_ipv6_targets() { let spec = spec("socks4://proxy:1080"); let target = Target::Address("[2001:db8::1]:443".parse().unwrap()); let Err(error) = Handshake::new(&spec, &target) else { panic!("expected handshake construction to fail"); }; assert!(matches!(error, ProxyError::Socks4Ipv4Only)); } #[test] fn socks4_reports_rejection() { let target = Target::Address("192.0.2.10:443".parse().unwrap()); let error = run( "socks4://proxy:1080", &target, &[0x00, 0x5B, 0, 0, 0, 0, 0, 0], &[8], ) .unwrap_err(); assert!(matches!(error, ProxyError::Socks4ConnectRefused(0x5B))); } proptest! { /// The outcome of a handshake must not depend on how the proxy's /// bytes are chunked across reads. #[test] fn chunking_does_not_change_the_outcome( chunk_sizes in proptest::collection::vec(1usize..16, 1..32), ) { for (url, server_bytes) in transcripts() { let baseline = run(url, &domain_target(), &server_bytes, &[server_bytes.len()]); let chunked = run(url, &domain_target(), &server_bytes, &chunk_sizes); match (baseline, chunked) { (Ok(expected), Ok(actual)) => prop_assert_eq!(expected, actual), (Err(_), Err(_)) => {} (baseline, chunked) => { return Err(TestCaseError::fail(format!( "outcomes diverged: baseline {baseline:?} vs chunked {chunked:?}" ))); } } } } } /// Scripted proxy conversations used by the chunking property test, each /// with trailing bytes to pin leftover handling. fn transcripts() -> Vec<(&'static str, Vec)> { let mut socks5 = vec![0x05, 0x02]; socks5.extend_from_slice(&[0x01, 0x00]); socks5.extend_from_slice(&[0x05, 0x00, 0x00, 0x03, 4]); socks5.extend_from_slice(b"host"); socks5.extend_from_slice(&[0, 80]); socks5.extend_from_slice(b"leftover-bytes"); vec![ ( "http://user:pass@proxy:8080", b"HTTP/1.1 200 Connection Established\r\nVia: test\r\n\r\nleftover-bytes".to_vec(), ), ("socks5h://user:pass@proxy:1080", socks5), ( "socks4a://userid@proxy:1080", b"\x00\x5A\x00\x00\x00\x00\x00\x00leftover-bytes".to_vec(), ), ] } fn spec(url: &str) -> ProxySpec { ProxySpec::parse(&url.parse().unwrap()).unwrap() } fn domain_target() -> Target { Target::Domain("cloud.example.com".to_string(), 443) } /// Drives a handshake to completion against scripted proxy bytes, /// feeding input in the given chunk sizes (cycled). Returns everything /// the client sent and the leftover bytes reported at completion. fn run( url: &str, target: &Target, server_bytes: &[u8], chunk_sizes: &[usize], ) -> Result<(Vec>, Vec), ProxyError> { let mut handshake = Handshake::new(&spec(url), target)?; let mut sends = Vec::new(); let mut remaining = server_bytes; let mut sizes = chunk_sizes.iter().copied().cycle(); let mut pending: &[u8] = &[]; loop { let step = handshake.advance(pending)?; pending = &[]; match step { Step::Send(bytes) => sends.push(bytes), Step::NeedMoreInput => { assert!( !remaining.is_empty(), "handshake wants more input than the transcript provides" ); let size = sizes .next() .expect("chunk_sizes must not be empty") .clamp(1, remaining.len()); let (chunk, rest) = remaining.split_at(size); pending = chunk; remaining = rest; } Step::Done { leftover } => { let mut leftover = leftover; // Any transcript bytes not yet fed to the machine are // also tunnel bytes; account for them so results don't // depend on chunking. leftover.extend_from_slice(remaining); return Ok((sends, leftover)); } } } } }