diff --git a/docs/man/bssh.1 b/docs/man/bssh.1 index cf9acaa5..32e326d3 100644 --- a/docs/man/bssh.1 +++ b/docs/man/bssh.1 @@ -1204,8 +1204,16 @@ requested family, and sends the matching numeric address in the .I direct-tcpip request. This gives the family request a concrete effect on the server-side connection, but it necessarily uses the client's resolver view for that forced -path. SOCKS4 requests carry a literal IPv4 destination by protocol definition and -are passed through unfiltered. +path. SOCKS4 requests carry a literal IPv4 destination by protocol definition, so +they still work under +.B any +and +.BR \-4 , +but are rejected under +.B \-6 +or +.I AddressFamily inet6 +with the usual forced-family error instead of silently tunneling IPv4. .SS What the constraint does not cover The remote listener created by diff --git a/src/cli/bssh.rs b/src/cli/bssh.rs index 23f226de..8e2c1c04 100644 --- a/src/cli/bssh.rs +++ b/src/cli/bssh.rs @@ -325,7 +325,7 @@ pub struct Cli { long = "dynamic-forward", value_name = "dynamic_forward_spec", action = clap::ArgAction::Append, - help = "Dynamic port forwarding (SOCKS proxy) [bind_address:]port[/socks_version]\nCreates a local SOCKS proxy that dynamically forwards connections via SSH.\nMultiple -D options can be specified for multiple SOCKS proxies.\nExample: -D 1080 (SOCKS5 proxy on localhost:1080), -D *:1080/4 (SOCKS4 on all interfaces)" + help = "Dynamic port forwarding (SOCKS proxy) [bind_address:]port[/socks_version]\nCreates a local SOCKS proxy that dynamically forwards connections via SSH.\nMultiple -D options can be specified for multiple SOCKS proxies.\nSOCKS4 destinations are IPv4-only by protocol and are rejected when -6 or AddressFamily inet6 is forced.\nExample: -D 1080 (SOCKS5 proxy on localhost:1080), -D *:1080/4 (SOCKS4 on all interfaces)" )] pub dynamic_forwards: Vec, } diff --git a/src/forwarding/dynamic/connection.rs b/src/forwarding/dynamic/connection.rs index 8e74c942..51883921 100644 --- a/src/forwarding/dynamic/connection.rs +++ b/src/forwarding/dynamic/connection.rs @@ -131,11 +131,15 @@ impl ConnectionHandler { address_family: AddressFamily, ) -> anyhow::Result { match socks_version { - // SOCKS4 carries a literal IPv4 destination by protocol - // definition, so there is no candidate list an address family - // could narrow. The request is passed through unfiltered. SocksVersion::V4 => { - handle_socks4_connection(tcp_stream, peer_addr, ssh_client, cancel_token).await + handle_socks4_connection( + tcp_stream, + peer_addr, + ssh_client, + cancel_token, + address_family, + ) + .await } SocksVersion::V5 => { handle_socks5_connection( diff --git a/src/forwarding/dynamic/socks.rs b/src/forwarding/dynamic/socks.rs index bda9131a..83ce2341 100644 --- a/src/forwarding/dynamic/socks.rs +++ b/src/forwarding/dynamic/socks.rs @@ -2,22 +2,64 @@ use crate::{ forwarding::tunnel::Tunnel, - ssh::tokio_client::{AddressFamily, Client}, + ssh::tokio_client::{AddressFamily, Client, Error as SshError}, }; use anyhow::Result; -use std::net::SocketAddr; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use std::future::Future; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::TcpStream; use tokio_util::sync::CancellationToken; use tracing::debug; /// Handle SOCKS4 connection protocol pub async fn handle_socks4_connection( - mut tcp_stream: TcpStream, + tcp_stream: TcpStream, peer_addr: SocketAddr, ssh_client: &Client, cancel_token: CancellationToken, + address_family: AddressFamily, ) -> Result { + handle_socks4_connection_with( + tcp_stream, + peer_addr, + cancel_token, + address_family, + |destination| async move { + ssh_client + .open_direct_tcpip_channel(destination.as_str(), None) + .await + .map_err(anyhow::Error::from) + }, + |tcp_stream, ssh_channel, cancel_token| async move { + Tunnel::run(tcp_stream, ssh_channel, cancel_token).await + }, + ) + .await +} + +async fn handle_socks4_connection_with< + IoStream, + OpenChannel, + OpenFuture, + ChannelTarget, + RunTunnel, + RunFuture, +>( + mut tcp_stream: IoStream, + peer_addr: SocketAddr, + cancel_token: CancellationToken, + address_family: AddressFamily, + open_channel: OpenChannel, + run_tunnel: RunTunnel, +) -> Result +where + IoStream: AsyncRead + AsyncWrite + Unpin, + OpenChannel: FnOnce(String) -> OpenFuture, + OpenFuture: Future>, + RunTunnel: FnOnce(IoStream, ChannelTarget, CancellationToken) -> RunFuture, + RunFuture: Future>, +{ debug!("Handling SOCKS4 connection from {}", peer_addr); // Read SOCKS4 request: VER(1) + CMD(1) + DSTPORT(2) + DSTIP(4) + USERID(variable) + NULL(1) @@ -68,21 +110,29 @@ pub async fn handle_socks4_connection( } } - let destination = format!("{dest_ip}:{dest_port}"); + let destination = match socks4_destination_for_family(dest_ip, dest_port, address_family) { + Ok(destination) => destination, + Err(e) => { + debug!( + "Rejected SOCKS4 CONNECT to {}:{} for forced {} from {}: {}", + dest_ip, dest_port, address_family, peer_addr, e + ); + let response = [0, 0x5B, 0, 0, 0, 0, 0, 0]; // Request rejected + tcp_stream.write_all(&response).await?; + return Err(e.into()); + } + }; debug!("SOCKS4 CONNECT to {} from {}", destination, peer_addr); // Create SSH channel to destination - let ssh_channel = match ssh_client - .open_direct_tcpip_channel(destination.as_str(), None) - .await - { + let ssh_channel = match open_channel(destination.clone()).await { Ok(channel) => channel, Err(e) => { debug!("Failed to create SSH channel to {}: {}", destination, e); // Send failure response let response = [0, 0x5B, 0, 0, 0, 0, 0, 0]; // Request rejected tcp_stream.write_all(&response).await?; - return Err(e.into()); + return Err(e); } }; @@ -102,7 +152,23 @@ pub async fn handle_socks4_connection( debug!("SOCKS4 tunnel established: {} ↔ {}", peer_addr, destination); // Start bidirectional tunnel - Tunnel::run(tcp_stream, ssh_channel, cancel_token).await + run_tunnel(tcp_stream, ssh_channel, cancel_token).await +} + +fn socks4_destination_for_family( + dest_ip: Ipv4Addr, + dest_port: u16, + address_family: AddressFamily, +) -> Result { + let destination = SocketAddr::new(IpAddr::V4(dest_ip), dest_port); + if address_family.is_forced() && !address_family.matches(&destination) { + return Err(SshError::NoAddressForFamily { + host: dest_ip.to_string(), + family: address_family, + }); + } + + Ok(format!("{dest_ip}:{dest_port}")) } /// Handle SOCKS5 connection protocol @@ -261,3 +327,145 @@ pub async fn handle_socks5_connection( // - No authentication (method 0x00) // - Username/password authentication (method 0x02) // - Future: GSSAPI authentication (method 0x01) + +#[cfg(test)] +mod tests { + use super::*; + use crate::forwarding::tunnel::TunnelStats; + use anyhow::anyhow; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + fn socks4_request(dest_ip: Ipv4Addr, dest_port: u16, userid: &[u8]) -> Vec { + let mut frame = Vec::with_capacity(8 + userid.len() + 1); + frame.push(0x04); + frame.push(0x01); + frame.extend_from_slice(&dest_port.to_be_bytes()); + frame.extend_from_slice(&dest_ip.octets()); + frame.extend_from_slice(userid); + frame.push(0x00); + frame + } + + async fn run_socks4_protocol_case( + address_family: AddressFamily, + ) -> Result<([u8; 8], usize, anyhow::Error)> { + let open_count = Arc::new(AtomicUsize::new(0)); + let open_count_for_task = Arc::clone(&open_count); + let peer_addr: SocketAddr = "127.0.0.1:4242".parse().expect("peer address parses"); + let (mut client_stream, server_stream) = tokio::io::duplex(128); + + let server = tokio::spawn(async move { + handle_socks4_connection_with( + server_stream, + peer_addr, + CancellationToken::new(), + address_family, + move |destination| { + let open_count = Arc::clone(&open_count_for_task); + async move { + open_count.fetch_add(1, Ordering::Relaxed); + assert_eq!(destination, "192.0.2.25:8080"); + Err(anyhow!("synthetic channel-open stop")) + } + }, + |_tcp_stream, _channel_target: (), _cancel_token| async move { + Ok(TunnelStats::default()) + }, + ) + .await + .expect_err("the synthetic seam stop must surface as an error") + }); + + client_stream + .write_all(&socks4_request( + Ipv4Addr::new(192, 0, 2, 25), + 8080, + b"acceptance-user", + )) + .await + .expect("client sends SOCKS4 request"); + + let mut response = [0u8; 8]; + client_stream + .read_exact(&mut response) + .await + .expect("client reads SOCKS4 response"); + + let err = server.await.expect("server task joins"); + Ok((response, open_count.load(Ordering::Relaxed), err)) + } + + #[test] + fn socks4_destination_accepts_ipv4_when_unforced_or_ipv4_forced() { + let dest_ip = Ipv4Addr::new(192, 0, 2, 25); + let dest_port = 8080; + + assert_eq!( + socks4_destination_for_family(dest_ip, dest_port, AddressFamily::Any) + .expect("unforced SOCKS4 must preserve the IPv4 destination"), + "192.0.2.25:8080" + ); + assert_eq!( + socks4_destination_for_family(dest_ip, dest_port, AddressFamily::V4) + .expect("forced IPv4 must still allow the SOCKS4 IPv4 destination"), + "192.0.2.25:8080" + ); + } + + #[test] + fn socks4_destination_rejects_forced_ipv6() { + let err = + socks4_destination_for_family(Ipv4Addr::new(192, 0, 2, 25), 8080, AddressFamily::V6) + .expect_err("forced IPv6 must reject the SOCKS4 IPv4 literal"); + + assert!(matches!( + err, + SshError::NoAddressForFamily { + ref host, + family: AddressFamily::V6, + } if host == "192.0.2.25" + )); + assert_eq!(err.to_string(), "no IPv6 address found for 192.0.2.25"); + } + + #[tokio::test] + async fn socks4_protocol_rejects_forced_ipv6_before_channel_open() { + let (response, open_count, err) = run_socks4_protocol_case(AddressFamily::V6) + .await + .expect("protocol case completes"); + + assert_eq!(response, [0, 0x5B, 0, 0, 0, 0, 0, 0]); + assert_eq!(open_count, 0, "forced IPv6 must reject before channel open"); + assert_eq!(err.to_string(), "no IPv6 address found for 192.0.2.25"); + } + + #[tokio::test] + async fn socks4_protocol_any_reaches_channel_open_seam() { + let (response, open_count, err) = run_socks4_protocol_case(AddressFamily::Any) + .await + .expect("protocol case completes"); + + assert_eq!(response, [0, 0x5B, 0, 0, 0, 0, 0, 0]); + assert_eq!(open_count, 1, "unforced SOCKS4 must reach channel open"); + assert!( + err.to_string().contains("synthetic channel-open stop"), + "the injected channel-open seam error must surface" + ); + } + + #[tokio::test] + async fn socks4_protocol_ipv4_reaches_channel_open_seam() { + let (response, open_count, err) = run_socks4_protocol_case(AddressFamily::V4) + .await + .expect("protocol case completes"); + + assert_eq!(response, [0, 0x5B, 0, 0, 0, 0, 0, 0]); + assert_eq!(open_count, 1, "forced IPv4 must reach channel open"); + assert!( + err.to_string().contains("synthetic channel-open stop"), + "the injected channel-open seam error must surface" + ); + } +}