diff --git a/src/jail/linux/nftables.rs b/src/jail/linux/nftables.rs index 9d4dc41f..64f82a43 100644 --- a/src/jail/linux/nftables.rs +++ b/src/jail/linux/nftables.rs @@ -121,7 +121,7 @@ table ip {} {{ }) } - /// Create namespace-side nftables rules for traffic redirection + /// Create namespace-side nftables rules for traffic redirection and egress filtering pub fn new_namespace_table( namespace: &str, host_ip: &str, @@ -130,26 +130,45 @@ table ip {} {{ ) -> Result { let table_name = "httpjail".to_string(); - // Generate the ruleset for namespace-side DNAT + // Generate the ruleset for namespace-side DNAT + FILTER let ruleset = format!( r#" table ip {} {{ + # NAT output chain: redirect HTTP/HTTPS to host proxy chain output {{ type nat hook output priority -100; policy accept; - - # Skip DNS traffic + + # Skip DNS traffic from NAT processing udp dport 53 return tcp dport 53 return - - # Redirect HTTP to proxy + + # Redirect HTTP to proxy running on host tcp dport 80 dnat to {}:{} - - # Redirect HTTPS to proxy + + # Redirect HTTPS to proxy running on host tcp dport 443 dnat to {}:{} }} + + # FILTER output chain: block non-HTTP/HTTPS egress + chain outfilter {{ + type filter hook output priority 0; policy drop; + + # Always allow established/related traffic + ct state established,related accept + + # Allow DNS to anywhere + udp dport 53 accept + tcp dport 53 accept + + # Explicitly block all other UDP (e.g., QUIC on 443) + ip protocol udp drop + + # Allow traffic to the host proxy ports after DNAT + ip daddr {} tcp dport {{ {}, {} }} accept + }} }} "#, - table_name, host_ip, http_port, host_ip, https_port + table_name, host_ip, http_port, host_ip, https_port, host_ip, http_port, https_port ); debug!( diff --git a/tests/linux_integration.rs b/tests/linux_integration.rs index 398e3be4..1235649e 100644 --- a/tests/linux_integration.rs +++ b/tests/linux_integration.rs @@ -280,4 +280,38 @@ mod tests { initial_ns_count, final_ns_count ); } + + /// Verify outbound TCP connections to non-HTTP ports are blocked inside the jail + /// + /// Uses portquiz.net which listens on all TCP ports and returns an HTTP response, + /// allowing us to test egress on non-standard ports reliably. + #[test] + fn test_outbound_tcp_non_http_blocked() { + LinuxPlatform::require_privileges(); + + // Attempt to connect to portquiz.net on port 81 (non-standard HTTP port) + // Expectation: connection is blocked by namespace egress filter + let mut cmd = httpjail_cmd(); + cmd.arg("-r").arg("allow: .*") // proxy allows HTTP/HTTPS, but port 81 should be blocked + .arg("--") + .arg("sh") + .arg("-c") + .arg("curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 8 http://portquiz.net:81 && echo CONNECTED || echo BLOCKED"); + + let output = cmd.output().expect("Failed to execute httpjail"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + eprintln!("[Linux] outbound TCP test stdout: {}", stdout); + if !stderr.is_empty() { + eprintln!("[Linux] outbound TCP test stderr: {}", stderr); + } + + assert!( + stdout.contains("BLOCKED"), + "Non-HTTP outbound TCP should be blocked. stdout: {}, stderr: {}", + stdout.trim(), + stderr.trim() + ); + } }