mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Add policy-checked TCP upstream tunneling for SOCKS (#60523)
## Why Allowing a hostname must not bypass deny or review rules on its resolved IP addresses. Forwarding DNS resolution to an upstream proxy also requires checking whether those address restrictions can still be enforced. ## What changed - Add `TcpUpstreamProxy` and `set_tcp_upstream_proxy` to require a configured HTTP `CONNECT` next hop for opaque SOCKS TCP traffic. Wait for a successful response before forwarding payloads and preserve bytes read past the response headers. - Enforce resolved IP deny and review rules at direct connection time, carry destination-scoped authorization through HTTP, SOCKS, and MITM forwarding, and report blocked resolutions as policy denials. - Recognize equivalent numeric IP spellings for restrictive rules. Permit remote TCP DNS only when local binding is allowed and no deny or review pattern can restrict IP addresses; reject upstream proxy loops. - Restrict Linux proxy bridges to managed proxy environment keys and supplied loopback ports. Bound concurrent bridge workers and release them after connection failures while allowing queued responses to drain. - Add test-only Linux DNS name mapping and SOCKS relay helpers with stable synthetic addresses and validation before opening upstream connections. ## Testing Add regression coverage for CONNECT refusal and buffered tunnel data, resolved-address policy denials, approval handling across policy refreshes, equivalent IP literals, DNS mapping stability, rejected relay requests, and bridge cleanup after resets. GitOrigin-RevId: bdd6e16de3373d0eb39995f689dad0230680b99e
This commit is contained in:
Generated
+2
File diff suppressed because one or more lines are too long
Generated
+33
-1
@@ -4033,7 +4033,9 @@ dependencies = [
|
||||
"codex-uds",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"hickory-proto 0.26.3",
|
||||
"ignore",
|
||||
"indexmap 2.14.0",
|
||||
"landlock",
|
||||
"libc",
|
||||
"pretty_assertions",
|
||||
@@ -4042,6 +4044,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"socks5-protocol",
|
||||
"tempfile",
|
||||
"test-case",
|
||||
"tokio",
|
||||
@@ -9087,6 +9090,25 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hickory-proto"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"once_cell",
|
||||
"rand 0.10.1",
|
||||
"ring",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hickory-resolver"
|
||||
version = "0.25.2"
|
||||
@@ -9095,7 +9117,7 @@ checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"hickory-proto",
|
||||
"hickory-proto 0.25.2",
|
||||
"ipconfig",
|
||||
"moka",
|
||||
"once_cell",
|
||||
@@ -14722,6 +14744,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socks5-protocol"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24b4925e3858bd0a1ac69668653234b4bebb0d4cd32a111c31057837d3d99117"
|
||||
dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sonora"
|
||||
version = "0.2.0"
|
||||
|
||||
@@ -26,6 +26,7 @@ codex-sandboxing = { workspace = true }
|
||||
codex-uds = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
ignore = { workspace = true }
|
||||
indexmap = { workspace = true }
|
||||
landlock = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
rustix = { workspace = true }
|
||||
@@ -33,12 +34,15 @@ seccompiler = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
url = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
codex-core = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
hickory-proto = "=0.26.3"
|
||||
pretty_assertions = { workspace = true }
|
||||
socks5-protocol = { version = "=0.3.5", features = ["sync"] }
|
||||
tempfile = { workspace = true }
|
||||
test-case = "3.3.1"
|
||||
tokio = { workspace = true, features = [
|
||||
|
||||
@@ -19,6 +19,10 @@ mod fd_mount;
|
||||
mod launcher;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_run_main;
|
||||
#[cfg(all(target_os = "linux", test))]
|
||||
mod managed_tcp;
|
||||
#[cfg(all(target_os = "linux", test))]
|
||||
mod managed_tcp_dns;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod proxy_lifecycle;
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -266,8 +266,11 @@ pub fn run_main() -> ! {
|
||||
// Outer stage: bubblewrap first, then re-enter this binary in the
|
||||
// sandboxed environment to apply seccomp. This path never falls back
|
||||
// to legacy Landlock on failure.
|
||||
let (proxy_route_spec, proxy_controls) = if allow_network_for_proxy {
|
||||
let (proxy_route_spec, controls) = prepare_host_proxy_route_spec()
|
||||
let (proxy_route_spec, proxy_controls) = if let Some(context) = managed_network.as_ref() {
|
||||
// Older explicit-proxy callers may omit the port list.
|
||||
let ports =
|
||||
(!context.loopback_ports.is_empty()).then_some(context.loopback_ports.as_slice());
|
||||
let (proxy_route_spec, controls) = prepare_host_proxy_route_spec(ports)
|
||||
.unwrap_or_else(|err| panic!("failed to prepare host proxy routing bridge: {err}"));
|
||||
(Some(proxy_route_spec), controls)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Translate a TUN worker’s TCP destination to the original hostname or literal IP
|
||||
//! for the managed SOCKS5 bridge.
|
||||
|
||||
use crate::managed_tcp_dns::Names;
|
||||
use crate::proxy_routing::proxy_bidirectional;
|
||||
use socks5_protocol::Address;
|
||||
use socks5_protocol::AuthMethod;
|
||||
use socks5_protocol::AuthRequest;
|
||||
use socks5_protocol::AuthResponse;
|
||||
use socks5_protocol::Command as SocksCommand;
|
||||
use socks5_protocol::CommandReply;
|
||||
use socks5_protocol::CommandRequest;
|
||||
use socks5_protocol::CommandResponse;
|
||||
use socks5_protocol::Version;
|
||||
use socks5_protocol::sync::FromIO;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpStream;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
fn relay(
|
||||
mut client: TcpStream,
|
||||
proxy: SocketAddr,
|
||||
names: &Mutex<Names>,
|
||||
) -> socks5_protocol::Result<()> {
|
||||
client.set_read_timeout(Some(TIMEOUT))?;
|
||||
client.set_write_timeout(Some(TIMEOUT))?;
|
||||
Version::read_from(&mut client)?;
|
||||
let offered = AuthRequest::read_from(&mut client)?;
|
||||
if !offered.0.contains(&AuthMethod::Noauth) {
|
||||
return Err(io::Error::other("unsupported SOCKS greeting").into());
|
||||
}
|
||||
Version::V5.write_to(&mut client)?;
|
||||
AuthResponse::new(AuthMethod::Noauth).write_to(&mut client)?;
|
||||
|
||||
let upstream = match connect(&mut client, proxy, names) {
|
||||
Ok(stream) => stream,
|
||||
Err(err) => {
|
||||
let denied = CommandResponse::reply_error(CommandReply::ConnectionNotAllowedByRuleset);
|
||||
let _ = write_frame(&mut client, &denied);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
write_frame(&mut client, &CommandResponse::success(Address::default()))?;
|
||||
client.set_read_timeout(None)?;
|
||||
client.set_write_timeout(None)?;
|
||||
upstream.set_read_timeout(None)?;
|
||||
upstream.set_write_timeout(None)?;
|
||||
Ok(proxy_bidirectional(upstream, client)?)
|
||||
}
|
||||
|
||||
fn connect(
|
||||
client: &mut impl io::Read,
|
||||
proxy: SocketAddr,
|
||||
names: &Mutex<Names>,
|
||||
) -> socks5_protocol::Result<TcpStream> {
|
||||
let request = CommandRequest::read_from(client)?;
|
||||
let (SocksCommand::Connect, Address::SocketAddr(target)) = (request.command, request.address)
|
||||
else {
|
||||
return Err(io::Error::other("expected TCP CONNECT to a TUN destination IP").into());
|
||||
};
|
||||
let destination = match names
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("DNS name table is unavailable"))?
|
||||
.lookup(target.ip())?
|
||||
{
|
||||
Some(host) => Address::Domain(host, target.port()),
|
||||
None => Address::SocketAddr(target),
|
||||
};
|
||||
|
||||
let mut upstream = TcpStream::connect_timeout(&proxy, TIMEOUT)?;
|
||||
upstream.set_read_timeout(Some(TIMEOUT))?;
|
||||
upstream.set_write_timeout(Some(TIMEOUT))?;
|
||||
Version::V5.write_to(&mut upstream)?;
|
||||
AuthRequest::new([AuthMethod::Noauth]).write_to(&mut upstream)?;
|
||||
Version::read_from(&mut upstream)?;
|
||||
if AuthResponse::read_from(&mut upstream)?.method() != AuthMethod::Noauth {
|
||||
return Err(io::Error::other("managed SOCKS5 handshake failed").into());
|
||||
}
|
||||
write_frame(&mut upstream, &CommandRequest::connect(destination))?;
|
||||
// The trusted proxy owns the approval deadline. The relay's handshake
|
||||
// timeout applies to framing and negotiation, not the human decision.
|
||||
upstream.set_read_timeout(None)?;
|
||||
let reply = CommandResponse::read_from(&mut upstream)?;
|
||||
if reply.reply != CommandReply::Succeeded
|
||||
|| matches!(reply.address, Address::Domain(ref name, _) if name.is_empty())
|
||||
{
|
||||
return Err(io::Error::other("invalid managed SOCKS5 success reply").into());
|
||||
}
|
||||
Ok(upstream)
|
||||
}
|
||||
|
||||
// Buffer the codec's field-by-field output before calling write_all. A denial
|
||||
// reply is best effort; the authorization decision does not depend on its delivery.
|
||||
fn write_frame(stream: &mut TcpStream, message: &impl FromIO) -> socks5_protocol::Result<()> {
|
||||
let mut bytes = Vec::new();
|
||||
message.write_to(&mut bytes)?;
|
||||
Ok(stream.write_all(&bytes)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "managed_tcp_relay_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Assign stable per-command addresses to names. Addresses never change owners
|
||||
//! while the command runs, including after the client's DNS cache expires.
|
||||
|
||||
use hickory_proto::op::Header;
|
||||
use hickory_proto::op::Message;
|
||||
use hickory_proto::op::MessageType;
|
||||
use hickory_proto::op::OpCode;
|
||||
use hickory_proto::op::Query;
|
||||
use hickory_proto::op::ResponseCode;
|
||||
use hickory_proto::rr::DNSClass;
|
||||
use hickory_proto::rr::RData;
|
||||
use hickory_proto::rr::Record;
|
||||
use hickory_proto::rr::RecordType;
|
||||
use hickory_proto::serialize::binary::BinDecodable;
|
||||
use hickory_proto::serialize::binary::BinDecoder;
|
||||
use hickory_proto::serialize::binary::BinEncodable;
|
||||
use indexmap::IndexSet;
|
||||
use std::io;
|
||||
use std::net::IpAddr;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
const MAX_NAMES: usize = 8192;
|
||||
const V4_BASE: u32 = u32::from_be_bytes([198, 18, 0, 0]);
|
||||
const V6_BASE: u128 = 0xfdc0_de00_0000_0000_0000_0000_0000_0000;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Names {
|
||||
// Append only: both synthetic address families encode the same one-based index.
|
||||
names: IndexSet<String>,
|
||||
}
|
||||
|
||||
impl Names {
|
||||
pub(super) fn lookup(&self, ip: IpAddr) -> io::Result<Option<String>> {
|
||||
let id = match ip.to_canonical() {
|
||||
IpAddr::V4(ip) if u32::from(ip) & 0xfffe_0000 == V4_BASE => u32::from(ip) - V4_BASE,
|
||||
IpAddr::V6(ip) if u128::from(ip) >> 32 == V6_BASE >> 32 => u128::from(ip) as u32,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let name = id
|
||||
.checked_sub(1)
|
||||
.and_then(|index| self.names.get_index(index as usize));
|
||||
name.cloned()
|
||||
.map(Some)
|
||||
.ok_or_else(|| io::Error::other("unassigned synthetic address"))
|
||||
}
|
||||
|
||||
fn id(&mut self, host: String) -> Option<u32> {
|
||||
let index = match self.names.get_index_of(&host) {
|
||||
Some(index) => index,
|
||||
None if self.names.len() < MAX_NAMES => self.names.insert_full(host).0,
|
||||
None => return None,
|
||||
};
|
||||
Some(index as u32 + 1)
|
||||
}
|
||||
|
||||
fn answer(&mut self, packet: &[u8]) -> Option<Vec<u8>> {
|
||||
if packet.len() > 4096 {
|
||||
return None;
|
||||
}
|
||||
// Decode one question only. EDNS and other trailing bytes never go to a resolver
|
||||
// or into the reply, and untrusted section counts never size an allocation.
|
||||
let mut decoder = BinDecoder::new(packet);
|
||||
let header = Header::read(&mut decoder).ok()?;
|
||||
if header.message_type != MessageType::Query
|
||||
|| header.op_code != OpCode::Query
|
||||
|| header.counts.queries != 1
|
||||
|| header.counts.answers != 0
|
||||
|| header.counts.authorities != 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let start = decoder.index();
|
||||
let query = Query::read(&mut decoder).ok()?;
|
||||
// The first question has no earlier name to reference. Reject compression
|
||||
// and labels that would require escapes when converted to a SOCKS hostname.
|
||||
if query.to_bytes().ok()? != decoder.slice_from(start).ok()?
|
||||
|| !query
|
||||
.name
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut reply = Message::response(header.id, OpCode::Query);
|
||||
reply.metadata.recursion_desired = header.recursion_desired;
|
||||
reply.metadata.recursion_available = true;
|
||||
if query.query_class == DNSClass::IN
|
||||
&& matches!(query.query_type, RecordType::A | RecordType::AAAA)
|
||||
&& !query.name.is_root()
|
||||
{
|
||||
let host = query
|
||||
.name
|
||||
.to_ascii()
|
||||
.trim_end_matches('.')
|
||||
.to_ascii_lowercase();
|
||||
if let Some(id) = self.id(host) {
|
||||
let ip = if query.query_type == RecordType::A {
|
||||
IpAddr::V4(Ipv4Addr::from(V4_BASE + id))
|
||||
} else {
|
||||
IpAddr::V6(Ipv6Addr::from(V6_BASE + u128::from(id)))
|
||||
};
|
||||
reply.add_answer(Record::from_rdata(query.name.clone(), 60, RData::from(ip)));
|
||||
} else {
|
||||
reply.metadata.response_code = ResponseCode::ServFail;
|
||||
}
|
||||
}
|
||||
reply.add_query(query);
|
||||
reply.to_vec().ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "managed_tcp_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,72 @@
|
||||
#![cfg(test)]
|
||||
|
||||
//! Verify rejected TUN requests cannot open an upstream connection.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn invalid_tun_requests_never_open_upstream() {
|
||||
let proxy = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let addr = proxy.local_addr().unwrap();
|
||||
for request in [
|
||||
[5, 3, 0, 1, 127, 0, 0, 1, 0, 53],
|
||||
[5, 2, 0, 1, 127, 0, 0, 1, 0, 80],
|
||||
[5, 1, 1, 1, 127, 0, 0, 1, 0, 80],
|
||||
[5, 1, 0, 3, 3, b'd', b'b', b'x', 0, 80],
|
||||
[5, 1, 0, 1, 198, 18, 0, 99, 1, 187],
|
||||
] {
|
||||
assert!(connect(&mut request.as_slice(), addr, &Mutex::new(Names::default())).is_err());
|
||||
}
|
||||
proxy.set_nonblocking(true).unwrap();
|
||||
assert_eq!(
|
||||
proxy.accept().unwrap_err().kind(),
|
||||
io::ErrorKind::WouldBlock
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_socks_connect_never_forwards_pipelined_payload() {
|
||||
let proxy = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
rustix::net::sockopt::set_socket_timeout(
|
||||
&proxy,
|
||||
rustix::net::sockopt::Timeout::Recv,
|
||||
Some(TIMEOUT),
|
||||
)
|
||||
.unwrap();
|
||||
let proxy_addr = proxy.local_addr().unwrap();
|
||||
let incoming = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let mut client = TcpStream::connect(incoming.local_addr().unwrap()).unwrap();
|
||||
let (server, _) = incoming.accept().unwrap();
|
||||
let task = thread::spawn(move || relay(server, proxy_addr, &Mutex::new(Names::default())));
|
||||
|
||||
// A malicious client can send application data before either handshake completes.
|
||||
let connect = [5, 1, 0, 1, 192, 0, 2, 7, 0x15, 0x38];
|
||||
client.write_all(&[5, 2, 2, 0]).unwrap();
|
||||
client.write_all(&connect).unwrap();
|
||||
client.write_all(b"private marker").unwrap();
|
||||
client.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
|
||||
let (mut upstream, _) = proxy.accept().unwrap();
|
||||
upstream.set_read_timeout(Some(TIMEOUT)).unwrap();
|
||||
let mut greeting = [0; 3];
|
||||
upstream.read_exact(&mut greeting).unwrap();
|
||||
assert_eq!(greeting, [5, 1, 0]);
|
||||
upstream.write_all(&[5, 0]).unwrap();
|
||||
let mut request = [0; 10];
|
||||
upstream.read_exact(&mut request).unwrap();
|
||||
assert_eq!(request, connect);
|
||||
upstream.write_all(&[5, 2, 0, 1, 0, 0, 0, 0, 0, 0]).unwrap();
|
||||
|
||||
let mut response = [0; 12]; // greeting and refusal sent to the client
|
||||
client.read_exact(&mut response).unwrap();
|
||||
assert_eq!(&response[..4], &[5, 0, 5, 2]);
|
||||
let mut byte = [0];
|
||||
assert_eq!(upstream.read(&mut byte).unwrap(), 0);
|
||||
assert!(task.join().unwrap().is_err());
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
#![cfg(test)]
|
||||
|
||||
//! Check DNS identity across address families, cache exhaustion and literal-address requests.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn query(name: &str, kind: RecordType) -> Vec<u8> {
|
||||
let mut packet = Message::new(/*id*/ 1, MessageType::Query, OpCode::Query);
|
||||
packet.add_query(Query::query(name.parse().unwrap(), kind));
|
||||
packet.to_vec().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_families_retain_the_original_name_and_literal_ips_are_separate() {
|
||||
let mut names = Names::default();
|
||||
let a = names.answer(&query("Db.Example", RecordType::A)).unwrap();
|
||||
let aaaa = names
|
||||
.answer(&query("db.example", RecordType::AAAA))
|
||||
.unwrap();
|
||||
let ip4 = Ipv4Addr::from(<[u8; 4]>::try_from(&a[a.len() - 4..]).unwrap());
|
||||
let ip6 = Ipv6Addr::from(<[u8; 16]>::try_from(&aaaa[aaaa.len() - 16..]).unwrap());
|
||||
for address in [
|
||||
IpAddr::V4(ip4),
|
||||
IpAddr::V6(ip6),
|
||||
IpAddr::V6(ip4.to_ipv6_mapped()),
|
||||
] {
|
||||
assert_eq!(
|
||||
names.lookup(address).unwrap().as_deref(),
|
||||
Some("db.example")
|
||||
);
|
||||
}
|
||||
assert_eq!(names.lookup("203.0.113.19".parse().unwrap()).unwrap(), None);
|
||||
assert_eq!(names.lookup("2001:db8::19".parse().unwrap()).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_exhaustion_never_reassigns_an_existing_address() {
|
||||
let mut names = Names::default();
|
||||
let first = names
|
||||
.answer(&query("first.example", RecordType::A))
|
||||
.unwrap();
|
||||
for n in 1..MAX_NAMES {
|
||||
names
|
||||
.answer(&query(&format!("n{n}.example"), RecordType::A))
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
names.answer(&query("new.example", RecordType::A)).unwrap()[3] & 15,
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
names
|
||||
.answer(&query("first.example", RecordType::A))
|
||||
.unwrap(),
|
||||
first
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_unsupported_questions_cannot_allocate_an_alias() {
|
||||
let mut names = Names::default();
|
||||
assert!(names.answer(&[0; 11]).is_none());
|
||||
let mut packet = query("valid.example", RecordType::A);
|
||||
packet[12] = 0xc0;
|
||||
assert!(names.answer(&packet).is_none());
|
||||
let mut counts = query("valid.example", RecordType::A);
|
||||
counts[6..12].fill(0xff);
|
||||
assert!(names.answer(&counts).is_none());
|
||||
let mut header_pointer = query("valid.example", RecordType::A);
|
||||
header_pointer.splice(12..27, [0xc0, 0x00]);
|
||||
assert!(names.answer(&header_pointer).is_none());
|
||||
assert_eq!(
|
||||
names
|
||||
.answer(&query("valid.example", RecordType::MX))
|
||||
.unwrap()[7],
|
||||
0
|
||||
);
|
||||
assert!(names.names.is_empty());
|
||||
}
|
||||
@@ -23,28 +23,12 @@ use std::os::fd::AsRawFd;
|
||||
use std::os::fd::FromRawFd;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Semaphore;
|
||||
use url::Url;
|
||||
|
||||
const PROXY_ENV_KEYS: &[&str] = &[
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"WS_PROXY",
|
||||
"WSS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"FTP_PROXY",
|
||||
"YARN_HTTP_PROXY",
|
||||
"YARN_HTTPS_PROXY",
|
||||
"NPM_CONFIG_HTTP_PROXY",
|
||||
"NPM_CONFIG_HTTPS_PROXY",
|
||||
"NPM_CONFIG_PROXY",
|
||||
"BUNDLE_HTTP_PROXY",
|
||||
"BUNDLE_HTTPS_PROXY",
|
||||
"PIP_PROXY",
|
||||
"DOCKER_HTTP_PROXY",
|
||||
"DOCKER_HTTPS_PROXY",
|
||||
];
|
||||
|
||||
const HOST_BRIDGE_READY: u8 = 1;
|
||||
const LOOPBACK_INTERFACE_NAME: &[u8] = b"lo";
|
||||
const HANDOFF_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
|
||||
@@ -72,7 +56,9 @@ struct ProxyRoutePlan {
|
||||
has_proxy_config: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<(String, Vec<File>)> {
|
||||
pub(crate) fn prepare_host_proxy_route_spec(
|
||||
port_allowlist: Option<&[u16]>,
|
||||
) -> io::Result<(String, Vec<File>)> {
|
||||
let (attribution_token, plan) = extract_attribution_token_and_plan(std::env::vars().collect());
|
||||
// SAFETY: the sandbox helper is single-threaded here, before it forks bridge workers or
|
||||
// executes the user command.
|
||||
@@ -89,6 +75,20 @@ pub(crate) fn prepare_host_proxy_route_spec() -> io::Result<(String, Vec<File>)>
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, message));
|
||||
}
|
||||
|
||||
// Validate the entire plan before forking any host-side workers or sending
|
||||
// attribution. An empty enforced list grants no routes.
|
||||
if let Some(ports) = port_allowlist
|
||||
&& plan
|
||||
.routes
|
||||
.iter()
|
||||
.any(|route| !ports.contains(&route.endpoint.port()))
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"proxy endpoint is outside the managed sandbox context",
|
||||
));
|
||||
}
|
||||
|
||||
let mut control_by_endpoint = BTreeMap::new();
|
||||
for route in &plan.routes {
|
||||
if control_by_endpoint.contains_key(&route.endpoint) {
|
||||
@@ -237,8 +237,12 @@ fn plan_proxy_routes(env: &HashMap<String, String>) -> ProxyRoutePlan {
|
||||
}
|
||||
|
||||
fn is_proxy_env_key(key: &str) -> bool {
|
||||
let upper = key.to_ascii_uppercase();
|
||||
PROXY_ENV_KEYS.contains(&upper.as_str())
|
||||
// Only exact key spellings overwritten by managed setup may nominate a host
|
||||
// socket. A caller-controlled mixed-case alias can otherwise create a second bridge.
|
||||
codex_network_proxy::PROXY_ENV_KEYS.contains(&key)
|
||||
&& codex_network_proxy::PROXY_URL_ENV_KEYS
|
||||
.iter()
|
||||
.any(|url_key| key.eq_ignore_ascii_case(url_key))
|
||||
}
|
||||
|
||||
fn parse_loopback_proxy_endpoint(proxy_url: &str) -> Option<SocketAddr> {
|
||||
@@ -352,6 +356,26 @@ fn spawn_host_bridge(
|
||||
Ok(File::from(OwnedFd::from(control)))
|
||||
}
|
||||
|
||||
/// Each connection worker holds a permit until it exits.
|
||||
pub(super) struct ConnectionSlots(Arc<Semaphore>);
|
||||
|
||||
impl ConnectionSlots {
|
||||
pub(super) fn new() -> Self {
|
||||
const MAX_CONNECTIONS: usize = 128;
|
||||
Self(Arc::new(Semaphore::new(MAX_CONNECTIONS)))
|
||||
}
|
||||
|
||||
pub(super) fn spawn(&self, serve: impl FnOnce() + Send + 'static) {
|
||||
let Ok(permit) = Arc::clone(&self.0).try_acquire_owned() else {
|
||||
return;
|
||||
};
|
||||
let _ = std::thread::Builder::new().spawn(move || {
|
||||
let _permit = permit;
|
||||
serve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn run_host_bridge(
|
||||
endpoint: SocketAddr,
|
||||
mut control: UnixStream,
|
||||
@@ -368,10 +392,11 @@ fn run_host_bridge(
|
||||
drop(control);
|
||||
|
||||
let attribution_token = attribution_token.map(str::to_owned);
|
||||
let slots = ConnectionSlots::new();
|
||||
loop {
|
||||
let (client_stream, _) = listener.accept()?;
|
||||
let attribution_token = attribution_token.clone();
|
||||
std::thread::spawn(move || {
|
||||
slots.spawn(move || {
|
||||
let mut tcp_stream = match TcpStream::connect(endpoint) {
|
||||
Ok(stream) => stream,
|
||||
Err(_) => return,
|
||||
@@ -468,22 +493,38 @@ fn ensure_loopback_interface_up() -> io::Result<()> {
|
||||
close_fd(fd)
|
||||
}
|
||||
|
||||
fn proxy_bidirectional(mut upstream: TcpStream, mut client: TcpStream) -> io::Result<()> {
|
||||
let mut upstream_reader = upstream.try_clone()?;
|
||||
let mut client_writer = client.try_clone()?;
|
||||
let upstream_to_client = std::thread::spawn(move || {
|
||||
let result = std::io::copy(&mut upstream_reader, &mut client_writer);
|
||||
let _ = client_writer.shutdown(Shutdown::Write);
|
||||
result
|
||||
});
|
||||
let client_to_upstream = std::io::copy(&mut client, &mut upstream);
|
||||
let _ = upstream.shutdown(Shutdown::Write);
|
||||
let upstream_to_client = upstream_to_client
|
||||
.join()
|
||||
.map_err(|_| io::Error::other("bridge thread panicked"))?;
|
||||
upstream_to_client?;
|
||||
client_to_upstream?;
|
||||
Ok(())
|
||||
pub(super) fn proxy_bidirectional(upstream: TcpStream, client: TcpStream) -> io::Result<()> {
|
||||
let (upload_done, upload) = mpsc::channel();
|
||||
let (download_done, download) = mpsc::channel();
|
||||
std::thread::scope(|scope| {
|
||||
let response = std::thread::Builder::new().spawn_scoped(scope, || {
|
||||
copy_and_shutdown(&upstream, &client, download_done, upload)
|
||||
})?;
|
||||
let request = copy_and_shutdown(&client, &upstream, upload_done, download);
|
||||
response
|
||||
.join()
|
||||
.map_err(|_| io::Error::other("bridge thread panicked"))??;
|
||||
request
|
||||
})
|
||||
}
|
||||
|
||||
fn copy_and_shutdown(
|
||||
mut source: &TcpStream,
|
||||
mut destination: &TcpStream,
|
||||
done: mpsc::Sender<()>,
|
||||
peer: mpsc::Receiver<()>,
|
||||
) -> io::Result<()> {
|
||||
let result = io::copy(&mut source, &mut destination);
|
||||
let _ = destination.shutdown(Shutdown::Write);
|
||||
let _ = done.send(());
|
||||
// Let the other direction flush queued bytes. A stalled peer must still release the permit.
|
||||
if result.is_err()
|
||||
&& peer.recv_timeout(Duration::from_secs(1)) == Err(mpsc::RecvTimeoutError::Timeout)
|
||||
{
|
||||
let _ = source.shutdown(Shutdown::Both);
|
||||
let _ = destination.shutdown(Shutdown::Both);
|
||||
}
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Verify proxy route planning, attribution handling, and URL rewriting.
|
||||
//! Verify proxy route planning, attribution handling, URL rewriting, and bridge cleanup.
|
||||
|
||||
use super::ConnectionSlots;
|
||||
use super::PROXY_ATTRIBUTION_TOKEN_ENV_KEY;
|
||||
use super::ProxyRouteEntry;
|
||||
use super::ProxyRouteSpec;
|
||||
@@ -8,17 +9,81 @@ use super::extract_attribution_token_and_plan;
|
||||
use super::is_proxy_env_key;
|
||||
use super::parse_loopback_proxy_endpoint;
|
||||
use super::plan_proxy_routes;
|
||||
use super::proxy_bidirectional;
|
||||
use super::rewrite_proxy_env_value;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::Shutdown;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpListener;
|
||||
use std::net::TcpStream;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reset_on_either_side_releases_the_bridge_permit() {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let connect = || {
|
||||
let peer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
(peer, listener.accept().unwrap().0)
|
||||
};
|
||||
for reset_upstream in [false, true] {
|
||||
let (reset_peer, reset_socket) = connect();
|
||||
let (idle_peer, idle_socket) = connect();
|
||||
let slots = ConnectionSlots(Arc::new(Semaphore::new(1)));
|
||||
let (upstream, client) = if reset_upstream {
|
||||
(reset_socket, idle_socket)
|
||||
} else {
|
||||
(idle_socket, reset_socket)
|
||||
};
|
||||
let (done, result) = tokio::sync::oneshot::channel();
|
||||
slots.spawn(move || {
|
||||
let _ = done.send(proxy_bidirectional(upstream, client));
|
||||
});
|
||||
rustix::net::sockopt::set_socket_linger(&reset_peer, Some(Duration::ZERO)).unwrap();
|
||||
drop(reset_peer);
|
||||
|
||||
let _permit = tokio::time::timeout(Duration::from_secs(5), slots.0.acquire())
|
||||
.await
|
||||
.expect("bridge must release its permit while the other peer is still open")
|
||||
.unwrap();
|
||||
assert!(result.await.unwrap().is_err());
|
||||
drop(idle_peer);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_proxy_env_keys_case_insensitively() {
|
||||
fn upload_failure_drains_the_pending_response() {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let mut server = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
let upstream = listener.accept().unwrap().0;
|
||||
let mut peer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
let client = listener.accept().unwrap().0;
|
||||
|
||||
server.write_all(b"response").unwrap();
|
||||
assert!(upstream.peek(&mut [0]).unwrap() > 0);
|
||||
rustix::net::sockopt::set_socket_linger(&server, Some(Duration::ZERO)).unwrap();
|
||||
drop(server);
|
||||
peer.write_all(b"upload").unwrap();
|
||||
peer.shutdown(Shutdown::Write).unwrap();
|
||||
assert!(proxy_bidirectional(upstream, client).is_err());
|
||||
let mut response = Vec::new();
|
||||
peer.read_to_end(&mut response).unwrap();
|
||||
assert_eq!(response, b"response");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_only_proxy_url_keys_written_by_managed_setup() {
|
||||
assert_eq!(is_proxy_env_key("HTTP_PROXY"), true);
|
||||
assert_eq!(is_proxy_env_key("http_proxy"), true);
|
||||
assert_eq!(is_proxy_env_key("WS_PROXY"), true);
|
||||
assert_eq!(is_proxy_env_key("wss_proxy"), true);
|
||||
assert_eq!(is_proxy_env_key("HtTp_PrOxY"), false);
|
||||
assert_eq!(is_proxy_env_key("pip_proxy"), false);
|
||||
assert_eq!(is_proxy_env_key("PATH"), false);
|
||||
}
|
||||
|
||||
@@ -54,6 +119,10 @@ fn plan_proxy_routes_only_includes_valid_loopback_endpoints() {
|
||||
"HTTPS_PROXY".to_string(),
|
||||
"http://example.com:3128".to_string(),
|
||||
);
|
||||
env.insert(
|
||||
"HtTp_PrOxY".to_string(),
|
||||
"http://127.0.0.1:32102".to_string(),
|
||||
);
|
||||
env.insert("PATH".to_string(), "/usr/bin".to_string());
|
||||
|
||||
let plan = plan_proxy_routes(&env);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
use crate::network_policy::BlockDecisionAuditEventArgs;
|
||||
use crate::network_policy::NetworkDecisionSource;
|
||||
use crate::network_policy::NetworkPolicyDecision;
|
||||
use crate::network_policy::emit_block_decision_audit_event;
|
||||
use crate::policy::is_non_public_ip;
|
||||
use crate::reasons::REASON_POLICY_DENIED;
|
||||
use crate::runtime::HostAuthorization;
|
||||
use crate::runtime::HostBlockDecision;
|
||||
use crate::state::BlockedRequest;
|
||||
use crate::state::BlockedRequestArgs;
|
||||
use crate::state::NetworkProxyState;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::system_dns::SystemDnsResolver;
|
||||
@@ -17,12 +24,84 @@ use rama_net::transport::TryRefIntoTransportContext;
|
||||
use rama_tcp::TcpStream;
|
||||
use rama_tcp::client::TcpStreamConnector;
|
||||
use rama_tcp::client::service::TcpConnector;
|
||||
use std::error::Error;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU8;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
const BLOCKED: u8 = 1;
|
||||
const DIALED: u8 = 2;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("all resolved addresses were blocked by network policy")]
|
||||
pub(crate) struct ResolvedTargetsDenied;
|
||||
|
||||
impl ResolvedTargetsDenied {
|
||||
pub(crate) fn caused(error: &(dyn Error + 'static)) -> bool {
|
||||
let mut cause = Some(error);
|
||||
while let Some(error) = cause {
|
||||
if error.is::<Self>() {
|
||||
return true;
|
||||
}
|
||||
cause = error.source();
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata from the trusted admission decision, scoped to its logical destination.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct DialPolicy {
|
||||
target: HostWithPort,
|
||||
authorization: HostAuthorization,
|
||||
protocol: crate::network_policy::NetworkProtocol,
|
||||
client: Option<String>,
|
||||
method: Option<String>,
|
||||
}
|
||||
impl DialPolicy {
|
||||
pub(crate) fn new(
|
||||
target: &HostWithPort,
|
||||
authorization: HostAuthorization,
|
||||
request: &crate::network_policy::NetworkPolicyRequest,
|
||||
) -> Self {
|
||||
Self {
|
||||
target: target.clone(),
|
||||
authorization,
|
||||
protocol: request.protocol,
|
||||
client: request.client_addr.clone(),
|
||||
method: request.method.clone(),
|
||||
}
|
||||
}
|
||||
// MITM can drop the DNS root dot while reconstructing the dial authority.
|
||||
fn for_target<'a>(
|
||||
extensions: &'a rama_core::extensions::Extensions,
|
||||
target: &HostWithPort,
|
||||
) -> Option<&'a Self> {
|
||||
extensions.get::<Self>().filter(|policy| {
|
||||
policy.target.port == target.port
|
||||
&& match (&policy.target.host, &target.host) {
|
||||
(Host::Name(a), Host::Name(b)) => a
|
||||
.as_str()
|
||||
.trim_end_matches('.')
|
||||
.eq_ignore_ascii_case(b.as_str().trim_end_matches('.')),
|
||||
(a, b) => a == b,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn authorization(
|
||||
extensions: &rama_core::extensions::Extensions,
|
||||
target: &HostWithPort,
|
||||
) -> HostAuthorization {
|
||||
Self::for_target(extensions, target)
|
||||
.map_or(HostAuthorization::RequireAllowlist, |p| p.authorization)
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects traffic whose host policy has already allowed the request.
|
||||
/// Resolved non-public targets still undergo explicit-deny and local-address checks.
|
||||
/// Apply address-specific restrictions at the actual direct dial, including DNS results.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TargetCheckedTcpConnector {
|
||||
state: Arc<NetworkProxyState>,
|
||||
@@ -57,18 +136,62 @@ where
|
||||
.host_with_port()
|
||||
.ok_or_else(|| OpaqueError::from_display("network target is missing a port"))?;
|
||||
|
||||
connector
|
||||
let policy = DialPolicy::for_target(input.extensions(), &target).cloned();
|
||||
let attempts = Arc::new(AtomicU8::new(0));
|
||||
let result = connector
|
||||
.with_connector(TargetCheckedStreamConnector {
|
||||
state: self.state.clone(),
|
||||
authorization: policy
|
||||
.as_ref()
|
||||
.map_or(HostAuthorization::RequireAllowlist, |policy| {
|
||||
policy.authorization
|
||||
}),
|
||||
attempts: attempts.clone(),
|
||||
target,
|
||||
})
|
||||
.serve(input)
|
||||
.await
|
||||
.await;
|
||||
if result.is_err()
|
||||
&& attempts.load(Ordering::Relaxed) == BLOCKED
|
||||
&& let Some(policy) = policy
|
||||
{
|
||||
let host = crate::policy::normalize_host(&policy.target.host.to_string());
|
||||
emit_block_decision_audit_event(
|
||||
&self.state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
source: NetworkDecisionSource::BaselinePolicy,
|
||||
reason: REASON_POLICY_DENIED,
|
||||
protocol: policy.protocol,
|
||||
server_address: &host,
|
||||
server_port: policy.target.port,
|
||||
method: policy.method.as_deref(),
|
||||
client_addr: policy.client.as_deref(),
|
||||
},
|
||||
);
|
||||
let _ = self
|
||||
.state
|
||||
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
|
||||
host,
|
||||
port: Some(policy.target.port),
|
||||
client: policy.client,
|
||||
method: policy.method,
|
||||
mode: None,
|
||||
protocol: policy.protocol.as_policy_protocol().into(),
|
||||
reason: REASON_POLICY_DENIED.into(),
|
||||
decision: Some(NetworkPolicyDecision::Deny.as_str().into()),
|
||||
source: Some(NetworkDecisionSource::BaselinePolicy.as_str().into()),
|
||||
}))
|
||||
.await;
|
||||
return Err(ResolvedTargetsDenied.into());
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TargetCheckedStreamConnector {
|
||||
authorization: HostAuthorization,
|
||||
attempts: Arc<AtomicU8>,
|
||||
state: Arc<NetworkProxyState>,
|
||||
target: HostWithPort,
|
||||
}
|
||||
@@ -77,7 +200,13 @@ impl TcpStreamConnector for TargetCheckedStreamConnector {
|
||||
type Error = BoxError;
|
||||
|
||||
async fn connect(&self, addr: SocketAddr) -> Result<TcpStream, Self::Error> {
|
||||
if is_non_public_ip(addr.ip()) && !self.allows_non_public_target(addr).await? {
|
||||
if self
|
||||
.state
|
||||
.resolved_target_blocked(&self.target.host.to_string(), addr.ip(), self.authorization)
|
||||
.await?
|
||||
|| (is_non_public_ip(addr.ip()) && !self.allows_non_public_target(addr).await?)
|
||||
{
|
||||
self.attempts.fetch_or(BLOCKED, Ordering::Relaxed);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"network target rejected by policy",
|
||||
@@ -85,6 +214,7 @@ impl TcpStreamConnector for TargetCheckedStreamConnector {
|
||||
.into());
|
||||
}
|
||||
|
||||
self.attempts.fetch_or(DIALED, Ordering::Relaxed);
|
||||
tokio::net::TcpStream::connect(addr)
|
||||
.await
|
||||
.map(TcpStream::from)
|
||||
@@ -112,7 +242,7 @@ impl TargetCheckedStreamConnector {
|
||||
&self.target.host.to_string(),
|
||||
self.target.port,
|
||||
/*allow_local_binding*/ None,
|
||||
HostAuthorization::Approved,
|
||||
self.authorization,
|
||||
)
|
||||
.await
|
||||
.map(|decision| decision == HostBlockDecision::Allowed)
|
||||
@@ -152,6 +282,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::NetworkProxyConfig;
|
||||
use crate::state::network_proxy_state_for_policy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rama_core::extensions::ExtensionsRef;
|
||||
use rama_net::address::HostWithPort;
|
||||
use std::net::Ipv4Addr;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -206,7 +338,6 @@ mod tests {
|
||||
let target = listener.local_addr().expect("local addr");
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
config.set_allowed_domains(vec![target.ip().to_string()]);
|
||||
config.review_domains = vec![target.ip().to_string()];
|
||||
let state = Arc::new(network_proxy_state_for_policy(config));
|
||||
let connector = TargetCheckedTcpConnector::new(Arc::clone(&state));
|
||||
|
||||
@@ -234,7 +365,6 @@ mod tests {
|
||||
let target = listener.local_addr().expect("local addr");
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
config.set_allowed_domains(vec!["localhost".to_string()]);
|
||||
config.review_domains = vec!["localhost".to_string()];
|
||||
let connector =
|
||||
TargetCheckedTcpConnector::new(Arc::new(network_proxy_state_for_policy(config)));
|
||||
|
||||
@@ -248,6 +378,90 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_refresh_cannot_grant_a_missing_dial_approval() {
|
||||
use crate::network_policy::NetworkDecision;
|
||||
use crate::network_policy::NetworkPolicyDecider;
|
||||
use crate::network_policy::NetworkPolicyRequest;
|
||||
use crate::network_policy::NetworkPolicyRequestArgs;
|
||||
use crate::network_policy::NetworkProtocol;
|
||||
use crate::network_policy::evaluate_host_policy_for_dial;
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let target = HostWithPort::new(Host::LOCALHOST_NAME, addr.port());
|
||||
let mut config = NetworkProxyConfig {
|
||||
allow_local_binding: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
config.set_allowed_domains(vec!["localhost".into()]);
|
||||
config.review_domains = vec![addr.ip().to_string(), "::1".into()];
|
||||
let state = Arc::new(network_proxy_state_for_policy(config.clone()));
|
||||
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
||||
host: "localhost".into(),
|
||||
port: addr.port(),
|
||||
protocol: NetworkProtocol::Socks5Tcp,
|
||||
environment_id: None,
|
||||
client_addr: None,
|
||||
method: None,
|
||||
command: None,
|
||||
exec_policy_hint: None,
|
||||
});
|
||||
let admitted_target =
|
||||
HostWithPort::new(Host::Name("LOCALHOST.".parse().unwrap()), addr.port());
|
||||
let admit = async |decider: Option<Arc<dyn NetworkPolicyDecider>>| {
|
||||
let mut req = rama_tcp::client::Request::new(target.clone());
|
||||
let decision = evaluate_host_policy_for_dial(
|
||||
&state,
|
||||
decider.as_ref(),
|
||||
&request,
|
||||
Some((&admitted_target, req.extensions_mut())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(decision, NetworkDecision::Allow);
|
||||
req
|
||||
};
|
||||
let assert_addresses_blocked = async |authorization| {
|
||||
let dial = TargetCheckedStreamConnector {
|
||||
state: state.clone(),
|
||||
target: target.clone(),
|
||||
attempts: Arc::default(),
|
||||
authorization,
|
||||
};
|
||||
for ip in [addr.ip(), Ipv4Addr::LOCALHOST.to_ipv6_mapped().into()] {
|
||||
let error = dial
|
||||
.connect(SocketAddr::new(ip, addr.port()))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.downcast_ref::<io::Error>().unwrap().kind(),
|
||||
io::ErrorKind::PermissionDenied
|
||||
);
|
||||
}
|
||||
};
|
||||
let original = admit(None).await;
|
||||
assert_addresses_blocked(HostAuthorization::RequireAllowlist).await;
|
||||
config.review_domains.push("localhost".into());
|
||||
state
|
||||
.replace_config_state(
|
||||
crate::build_config_state(config, Default::default(), crate::Platform::native())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let approved = admit(Some(Arc::new(|_| async { NetworkDecision::Allow }))).await;
|
||||
let other_port = HostWithPort::new(target.host.clone(), addr.port() ^ 1);
|
||||
assert!(DialPolicy::for_target(approved.extensions(), &other_port).is_none());
|
||||
let connector = TargetCheckedTcpConnector::new(state.clone());
|
||||
assert!(connector.serve(original).await.is_err());
|
||||
assert!(connector.serve(approved).await.is_ok());
|
||||
state
|
||||
.add_denied_domain(&addr.ip().to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_addresses_blocked(HostAuthorization::Approved).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_private_address_does_not_match_allowlisted_hostname() {
|
||||
let host = Host::Name("example.com".parse().expect("valid domain"));
|
||||
|
||||
@@ -20,11 +20,13 @@ pub(crate) enum GlobalWildcard {
|
||||
#[derive(Clone)]
|
||||
pub struct DomainPatternSet {
|
||||
patterns: Arc<[Vec<u8>]>,
|
||||
restricted_ip_literals: Arc<HashSet<IpAddr>>,
|
||||
}
|
||||
|
||||
impl DomainPatternSet {
|
||||
pub(crate) fn new(patterns: &[String], global_wildcard: GlobalWildcard) -> Result<Self> {
|
||||
let mut compiled = Vec::new();
|
||||
let mut restricted_ip_literals = HashSet::new();
|
||||
let mut seen = HashSet::new();
|
||||
for pattern in patterns {
|
||||
if global_wildcard == GlobalWildcard::Reject
|
||||
@@ -73,11 +75,19 @@ impl DomainPatternSet {
|
||||
continue;
|
||||
}
|
||||
ensure!(!candidate.is_empty(), "domain pattern is empty");
|
||||
// Keep exact address equivalence separate from ordinary allowlist
|
||||
// matching. A scoped IP rule must never apply to other interfaces.
|
||||
if !candidate.contains('%')
|
||||
&& let Some(ip) = crate::policy::numeric_host_address(&candidate)
|
||||
{
|
||||
restricted_ip_literals.insert(ip.to_canonical());
|
||||
}
|
||||
compiled.push(candidate.into_bytes());
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
patterns: compiled.into(),
|
||||
restricted_ip_literals: Arc::new(restricted_ip_literals),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,6 +95,37 @@ impl DomainPatternSet {
|
||||
let host = host.as_ref().as_bytes();
|
||||
self.patterns.iter().any(|pattern| matches(pattern, host))
|
||||
}
|
||||
|
||||
/// Conservatively include wildcard patterns that could match a canonical IP spelling.
|
||||
pub(crate) fn may_restrict_ip(&self) -> bool {
|
||||
!self.restricted_ip_literals.is_empty()
|
||||
|| self.patterns.iter().any(|p| {
|
||||
p.iter().any(|b| matches!(b, b'*' | b'?'))
|
||||
&& p.iter()
|
||||
.all(|b| b.is_ascii_hexdigit() || matches!(b, b':' | b'.' | b'*' | b'?'))
|
||||
// Dots occur only in the decimal IPv4 tail, including mapped IPv6.
|
||||
&& p.iter().skip_while(|b| **b != b'.')
|
||||
.all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'*' | b'?'))
|
||||
})
|
||||
}
|
||||
|
||||
/// An IPv4 address and its IPv4-mapped IPv6 form can dial the same peer.
|
||||
/// Check both spellings for restrictive rules, without expanding allowlists.
|
||||
pub(crate) fn restricts_ip(&self, ip: IpAddr) -> bool {
|
||||
if self.patterns.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let ip = ip.to_canonical();
|
||||
if self.restricted_ip_literals.contains(&ip) {
|
||||
return true;
|
||||
}
|
||||
match ip {
|
||||
IpAddr::V4(ip) => {
|
||||
self.is_match(ip.to_string()) || self.is_match(ip.to_ipv6_mapped().to_string())
|
||||
}
|
||||
IpAddr::V6(ip) => self.is_match(ip.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize host fragments for policy matching (trim whitespace, strip ports/brackets, lowercase).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::attribution::BindConnectionAttribution;
|
||||
use crate::config::NetworkMode;
|
||||
use crate::connect_policy::ResolvedTargetsDenied;
|
||||
use crate::connect_policy::TargetCheckedTcpConnector;
|
||||
use crate::connection_lifecycle::CancelOnShutdown;
|
||||
use crate::mitm;
|
||||
@@ -14,7 +15,7 @@ use crate::network_policy::NetworkPolicyRequestArgs;
|
||||
use crate::network_policy::NetworkProtocol;
|
||||
use crate::network_policy::emit_allow_decision_audit_event;
|
||||
use crate::network_policy::emit_block_decision_audit_event;
|
||||
use crate::network_policy::evaluate_host_policy;
|
||||
use crate::network_policy::evaluate_host_policy_for_dial;
|
||||
use crate::policy::normalize_host;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_MITM_HOOK_DENIED;
|
||||
@@ -252,7 +253,12 @@ async fn http_connect_accept(
|
||||
match disconnect
|
||||
.track_http_request(
|
||||
started_at,
|
||||
evaluate_host_policy(&app_state, policy_decider.as_ref(), &request),
|
||||
evaluate_host_policy_for_dial(
|
||||
&app_state,
|
||||
policy_decider.as_ref(),
|
||||
&request,
|
||||
Some((&authority, req.extensions_mut())),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -788,7 +794,12 @@ async fn http_plain_proxy(
|
||||
match disconnect
|
||||
.track_http_request(
|
||||
started_at,
|
||||
evaluate_host_policy(&app_state, policy_decider.as_ref(), &request),
|
||||
evaluate_host_policy_for_dial(
|
||||
&app_state,
|
||||
policy_decider.as_ref(),
|
||||
&request,
|
||||
Some((&authority, req.extensions_mut())),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1019,6 +1030,21 @@ async fn http_plain_proxy(
|
||||
remove_hop_by_hop_request_headers(req.headers_mut());
|
||||
match client.serve(req).await {
|
||||
Ok(resp) => Ok(resp),
|
||||
Err(err) if ResolvedTargetsDenied::caused(&err) => {
|
||||
let reason = crate::reasons::REASON_POLICY_DENIED;
|
||||
Ok(json_blocked(
|
||||
&host,
|
||||
reason,
|
||||
Some(&PolicyDecisionDetails {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
source: NetworkDecisionSource::BaselinePolicy,
|
||||
reason,
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: &host,
|
||||
port,
|
||||
}),
|
||||
))
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("upstream request failed: {err}");
|
||||
Ok(text_response(StatusCode::BAD_GATEWAY, "upstream failure"))
|
||||
@@ -1339,6 +1365,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_resolved_addresses_blocked_reports_a_policy_denial() {
|
||||
let mut config = NetworkProxyConfig {
|
||||
allow_local_binding: Some(true),
|
||||
allow_upstream_proxy: false,
|
||||
..Default::default()
|
||||
};
|
||||
config.set_allowed_domains(vec!["localhost".into()]);
|
||||
config.set_denied_domains(vec!["127.0.0.1".into(), "::1".into()]);
|
||||
let mut state = network_proxy_state_for_policy(config);
|
||||
let (tx, audit) = std::sync::mpsc::channel();
|
||||
state.set_policy_audit_observer(Arc::new(move |event| tx.send(event).unwrap()));
|
||||
let state = Arc::new(state);
|
||||
let mut req = Request::builder()
|
||||
.uri("http://localhost:8080/")
|
||||
.header("host", "localhost:8080")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(state.clone());
|
||||
let response = http_plain_proxy(
|
||||
/*policy_decider*/ None, /*environment_id*/ None, req,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
let decisions: Vec<_> = audit
|
||||
.try_iter()
|
||||
.map(|event| (event.decision, event.host, event.port))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
decisions,
|
||||
[
|
||||
("allow".to_string(), "localhost".to_string(), 8080),
|
||||
("deny".to_string(), "localhost".to_string(), 8080),
|
||||
]
|
||||
);
|
||||
let blocked = state.drain_blocked().await.unwrap();
|
||||
assert_eq!(blocked.len(), 1);
|
||||
assert_eq!(blocked[0].host, "localhost");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_connect_accept_allows_allowlisted_host_in_full_mode() {
|
||||
let policy = {
|
||||
|
||||
@@ -32,6 +32,7 @@ mod socks5;
|
||||
mod state;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod system_dns;
|
||||
mod tcp_upstream;
|
||||
mod upstream;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_proxy_ingress;
|
||||
@@ -136,3 +137,4 @@ pub use state::NetworkProxyConstraints;
|
||||
pub use state::PartialNetworkProxyConfig;
|
||||
pub use state::build_config_state;
|
||||
pub use state::validate_policy_against_constraints;
|
||||
pub use tcp_upstream::TcpUpstreamProxy;
|
||||
|
||||
@@ -125,6 +125,7 @@ impl MitmPolicyContext {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MitmRequestContext {
|
||||
dial_policy: Option<crate::connect_policy::DialPolicy>,
|
||||
policy: MitmPolicyContext,
|
||||
mitm: Arc<MitmState>,
|
||||
upstream: UpstreamClient,
|
||||
@@ -233,6 +234,10 @@ where
|
||||
)
|
||||
};
|
||||
let request_ctx = Arc::new(MitmRequestContext {
|
||||
dial_policy: stream
|
||||
.extensions()
|
||||
.get::<crate::connect_policy::DialPolicy>()
|
||||
.cloned(),
|
||||
policy: MitmPolicyContext {
|
||||
target_host,
|
||||
target_port,
|
||||
@@ -292,6 +297,9 @@ async fn handle_mitm_request(
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(err) if crate::connect_policy::ResolvedTargetsDenied::caused(err.as_ref()) => {
|
||||
blocked_text_response(crate::reasons::REASON_POLICY_DENIED)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("MITM request handling failed: {err}");
|
||||
text_response(StatusCode::BAD_GATEWAY, "mitm upstream error")
|
||||
@@ -385,6 +393,9 @@ async fn forward_request(req: Request, request_ctx: &MitmRequestContext) -> Resu
|
||||
};
|
||||
upstream_req = signed;
|
||||
}
|
||||
if let Some(policy) = &request_ctx.dial_policy {
|
||||
upstream_req.extensions_mut().insert(policy.clone());
|
||||
}
|
||||
let upstream_resp = if request_ctx.policy.scheme == Scheme::HTTP {
|
||||
crate::brokered_tunnel::forward_http_request(upstream_req, &request_ctx.upstream).await?
|
||||
} else {
|
||||
|
||||
@@ -118,6 +118,7 @@ async fn tls_origin_with_hook(
|
||||
max_body_bytes: MITM_MAX_BODY_BYTES,
|
||||
});
|
||||
let context = MitmRequestContext {
|
||||
dial_policy: None,
|
||||
policy: MitmPolicyContext {
|
||||
target_host: "localhost".to_string(),
|
||||
target_port: port,
|
||||
|
||||
@@ -365,6 +365,18 @@ pub(crate) async fn evaluate_host_policy(
|
||||
state: &NetworkProxyState,
|
||||
decider: Option<&Arc<dyn NetworkPolicyDecider>>,
|
||||
request: &NetworkPolicyRequest,
|
||||
) -> Result<NetworkDecision> {
|
||||
evaluate_host_policy_for_dial(state, decider, request, /*dial*/ None).await
|
||||
}
|
||||
|
||||
pub(crate) async fn evaluate_host_policy_for_dial(
|
||||
state: &NetworkProxyState,
|
||||
decider: Option<&Arc<dyn NetworkPolicyDecider>>,
|
||||
request: &NetworkPolicyRequest,
|
||||
dial: Option<(
|
||||
&rama_net::address::HostWithPort,
|
||||
&mut rama_core::extensions::Extensions,
|
||||
)>,
|
||||
) -> Result<NetworkDecision> {
|
||||
let execution_id = state.execution_id();
|
||||
let host_decision = state.host_blocked(&request.host, request.port).await?;
|
||||
@@ -456,6 +468,20 @@ pub(crate) async fn evaluate_host_policy(
|
||||
},
|
||||
);
|
||||
|
||||
if let Some((target, extensions)) = dial
|
||||
&& matches!(decision, NetworkDecision::Allow)
|
||||
{
|
||||
let authorization = if policy_override {
|
||||
HostAuthorization::Approved
|
||||
} else {
|
||||
HostAuthorization::RequireAllowlist
|
||||
};
|
||||
extensions.insert(crate::connect_policy::DialPolicy::new(
|
||||
target,
|
||||
authorization,
|
||||
request,
|
||||
));
|
||||
}
|
||||
Ok(decision)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,26 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret numeric host spellings without consulting DNS. System resolvers
|
||||
/// also accept shortened, integer, octal and hexadecimal IPv4 forms.
|
||||
pub(crate) fn numeric_host_address(host: &str) -> Option<IpAddr> {
|
||||
let host = unscoped_ip_literal(host).unwrap_or(host);
|
||||
if let Ok(ip) = host.parse() {
|
||||
return Some(ip);
|
||||
}
|
||||
if !host.as_bytes().first()?.is_ascii_digit()
|
||||
|| !host
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_hexdigit() || matches!(b, b'.' | b'x' | b'X'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match UrlHost::parse(host).ok()? {
|
||||
UrlHost::Ipv4(ip) => Some(IpAddr::V4(ip)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the host is a loopback hostname or IP literal.
|
||||
pub fn is_loopback_host(host: &Host) -> bool {
|
||||
let host = host.as_str();
|
||||
|
||||
@@ -91,7 +91,7 @@ impl std::fmt::Display for HostBlockReason {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum HostAuthorization {
|
||||
RequireAllowlist,
|
||||
Approved,
|
||||
@@ -287,6 +287,7 @@ pub struct NetworkProxyState {
|
||||
/// Belongs to this proxy; config retains omission for launches on other executors.
|
||||
pub(crate) local_binding_policy: LocalBindingPolicy,
|
||||
pub(crate) proxy_private_ips_via_upstream: bool,
|
||||
pub(crate) tcp_upstream_proxy: Option<crate::TcpUpstreamProxy>,
|
||||
reloader: Arc<dyn ConfigReloader>,
|
||||
blocked_request_observer: Arc<RwLock<Option<Arc<dyn BlockedRequestObserver>>>>,
|
||||
pub(crate) policy_audit_observer: Option<NetworkPolicyAuditObserver>,
|
||||
@@ -328,6 +329,7 @@ impl Clone for NetworkProxyState {
|
||||
state: self.state.clone(),
|
||||
local_binding_policy: self.local_binding_policy,
|
||||
proxy_private_ips_via_upstream: self.proxy_private_ips_via_upstream,
|
||||
tcp_upstream_proxy: self.tcp_upstream_proxy.clone(),
|
||||
reloader: self.reloader.clone(),
|
||||
blocked_request_observer: self.blocked_request_observer.clone(),
|
||||
policy_audit_observer: self.policy_audit_observer.clone(),
|
||||
@@ -350,6 +352,20 @@ impl NetworkProxyState {
|
||||
self.proxy_private_ips_via_upstream = enabled;
|
||||
}
|
||||
|
||||
/// Require the trusted HTTP CONNECT endpoint for opaque SOCKS TCP connections.
|
||||
pub fn set_tcp_upstream_proxy(&mut self, proxy: crate::TcpUpstreamProxy) {
|
||||
self.tcp_upstream_proxy = Some(proxy);
|
||||
}
|
||||
|
||||
/// A remote DNS answer cannot be checked at our direct socket boundary.
|
||||
pub(crate) async fn permits_remote_tcp_dns(&self) -> Result<bool> {
|
||||
self.reload_if_needed().await?;
|
||||
let guard = self.state.read().await;
|
||||
Ok(self.local_binding_policy.resolve(&guard.config)
|
||||
&& !guard.deny_set.may_restrict_ip()
|
||||
&& !guard.review_set.may_restrict_ip())
|
||||
}
|
||||
|
||||
/// Builds runtime state for one executor-local proxy launch.
|
||||
/// The launching executor supplies its own OS; the wire policy cannot override it.
|
||||
pub fn from_remote_launch_config(
|
||||
@@ -446,6 +462,7 @@ impl NetworkProxyState {
|
||||
state: Arc::new(RwLock::new(state)),
|
||||
local_binding_policy,
|
||||
proxy_private_ips_via_upstream: false,
|
||||
tcp_upstream_proxy: None,
|
||||
reloader,
|
||||
blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)),
|
||||
policy_audit_observer: None,
|
||||
@@ -885,12 +902,16 @@ impl NetworkProxyState {
|
||||
// 2) local/private networking is opt-in (defense-in-depth)
|
||||
// 3) managed review selectors precede every ordinary allow rule
|
||||
// 4) DNS requires an allowlist match or an approval
|
||||
if domains_match_host_or_unscoped(&deny_set, host_str) {
|
||||
let numeric_host = crate::policy::numeric_host_address(host_str);
|
||||
if domains_match_host_or_unscoped(&deny_set, host_str)
|
||||
|| numeric_host.is_some_and(|ip| deny_set.restricts_ip(ip))
|
||||
{
|
||||
return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied));
|
||||
}
|
||||
|
||||
let is_authorized = authorization == HostAuthorization::Approved
|
||||
|| (!domains_match_host_or_unscoped(&review_set, host_str)
|
||||
&& !numeric_host.is_some_and(|ip| review_set.restricts_ip(ip))
|
||||
&& domains_match_host_or_unscoped(&allow_set, host_str));
|
||||
if !allow_local_binding {
|
||||
// If the intent is "prevent access to local/internal networks", we must not rely solely
|
||||
@@ -1052,6 +1073,32 @@ impl NetworkProxyState {
|
||||
Ok(guard.config.allow_upstream_proxy)
|
||||
}
|
||||
|
||||
/// The logical host has already passed policy. Fail closed if the actual
|
||||
/// socket needs an IP-specific denial or review the hostname did not receive.
|
||||
pub(crate) async fn resolved_target_blocked(
|
||||
&self,
|
||||
host: &str,
|
||||
ip: IpAddr,
|
||||
authorization: HostAuthorization,
|
||||
) -> Result<bool> {
|
||||
self.reload_if_needed().await?;
|
||||
let guard = self.state.read().await;
|
||||
if guard.deny_set.restricts_ip(ip) {
|
||||
return Ok(true);
|
||||
}
|
||||
if !guard.review_set.restricts_ip(ip) {
|
||||
return Ok(false);
|
||||
}
|
||||
if authorization != HostAuthorization::Approved {
|
||||
return Ok(true);
|
||||
}
|
||||
let host = Host::parse(host)?;
|
||||
let host = host.as_str();
|
||||
Ok(!domains_match_host_or_unscoped(&guard.review_set, host)
|
||||
&& crate::policy::numeric_host_address(host)
|
||||
.is_none_or(|target| target.to_canonical() != ip.to_canonical()))
|
||||
}
|
||||
|
||||
pub async fn allow_local_binding(&self) -> Result<bool> {
|
||||
self.reload_if_needed().await?;
|
||||
let guard = self.state.read().await;
|
||||
@@ -1984,13 +2031,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_blocked_denies_scoped_ipv6_literal_before_local_binding() {
|
||||
async fn host_blocked_denies_equivalent_ip_literals_before_local_binding() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxyConfig {
|
||||
allow_local_binding: Some(true),
|
||||
..network_settings(&["*"], &["fd00::1"])
|
||||
..network_settings(&["*"], &["fd00::1", "127.0.0.1"])
|
||||
});
|
||||
|
||||
for host in ["fd00::1%eth0", "[fd00::1%eth0]", "[fd00::1%25eth0]"] {
|
||||
for host in [
|
||||
"fd00::1%eth0",
|
||||
"[fd00::1%eth0]",
|
||||
"[fd00::1%25eth0]",
|
||||
"::ffff:127.0.0.1",
|
||||
"127.1",
|
||||
"2130706433",
|
||||
"0177.0.0.1",
|
||||
"0x7f000001",
|
||||
] {
|
||||
assert_eq!(
|
||||
state.host_blocked(host, /*port*/ 80).await.unwrap(),
|
||||
HostBlockDecision::Blocked(HostBlockReason::Denied),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::attribution::BindConnectionAttribution;
|
||||
use crate::config::NetworkMode;
|
||||
use crate::connect_policy::DialPolicy;
|
||||
use crate::connect_policy::TargetCheckedTcpConnector;
|
||||
use crate::connection_lifecycle::CancelOnShutdown;
|
||||
use crate::mitm;
|
||||
@@ -13,6 +14,7 @@ use crate::network_policy::NetworkPolicyRequestArgs;
|
||||
use crate::network_policy::NetworkProtocol;
|
||||
use crate::network_policy::emit_block_decision_audit_event;
|
||||
use crate::network_policy::evaluate_host_policy;
|
||||
use crate::network_policy::evaluate_host_policy_for_dial;
|
||||
use crate::policy::normalize_host;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_MITM_REQUIRED;
|
||||
@@ -33,6 +35,7 @@ use rama_core::extensions::ExtensionsRef;
|
||||
use rama_core::graceful::ShutdownGuard;
|
||||
use rama_core::service::BoxService;
|
||||
use rama_core::service::service_fn;
|
||||
use rama_core::stream::rewind::Rewind;
|
||||
use rama_net::address::HostWithPort;
|
||||
use rama_net::client::EstablishedClientConnection;
|
||||
use rama_net::proxy::ProxyRequest;
|
||||
@@ -157,15 +160,13 @@ pub(crate) fn socks5_proxy_service(
|
||||
environment_id: Option<String>,
|
||||
enable_socks5_udp: bool,
|
||||
) -> BoxService<TcpStream, (), BoxError> {
|
||||
let tcp_connector = TargetCheckedTcpConnector::new(state.clone());
|
||||
let policy_tcp_connector = service_fn({
|
||||
let policy_decider = policy_decider.clone();
|
||||
let environment_id = environment_id.clone();
|
||||
move |req: TcpRequest| {
|
||||
let tcp_connector = tcp_connector.clone();
|
||||
let policy_decider = policy_decider.clone();
|
||||
let environment_id = environment_id.clone();
|
||||
async move { handle_socks5_tcp(req, tcp_connector, policy_decider, environment_id).await }
|
||||
async move { handle_socks5_tcp(req, policy_decider, environment_id).await }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -198,8 +199,7 @@ pub(crate) fn socks5_proxy_service(
|
||||
}
|
||||
|
||||
async fn handle_socks5_tcp(
|
||||
req: TcpRequest,
|
||||
tcp_connector: TargetCheckedTcpConnector,
|
||||
mut req: TcpRequest,
|
||||
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
|
||||
environment_id: Option<String>,
|
||||
) -> Result<EstablishedClientConnection<Socks5TcpConnection, TcpRequest>, BoxError> {
|
||||
@@ -330,7 +330,14 @@ async fn handle_socks5_tcp(
|
||||
exec_policy_hint: None,
|
||||
});
|
||||
|
||||
match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await {
|
||||
match evaluate_host_policy_for_dial(
|
||||
&app_state,
|
||||
policy_decider.as_ref(),
|
||||
&request,
|
||||
Some((&target, req.extensions_mut())),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(NetworkDecision::Deny {
|
||||
reason,
|
||||
source,
|
||||
@@ -431,21 +438,25 @@ async fn handle_socks5_tcp(
|
||||
|
||||
if let Some(mitm_state) = mitm_state {
|
||||
let client = client.as_deref().unwrap_or_default();
|
||||
let mut extensions = Extensions::new();
|
||||
if let Some(policy) = req.extensions().get::<DialPolicy>() {
|
||||
extensions.insert(policy.clone());
|
||||
}
|
||||
let conn = match socks_mitm_mode {
|
||||
SocksMitmMode::Disabled => None,
|
||||
SocksMitmMode::Enabled => Some(Socks5TcpConnection::Mitm {
|
||||
target,
|
||||
mode,
|
||||
mitm: mitm_state,
|
||||
extensions: Extensions::new(),
|
||||
extensions,
|
||||
}),
|
||||
SocksMitmMode::DetectProtocol(protocols) => Some(Socks5TcpConnection::DetectProtocol {
|
||||
protocols,
|
||||
target,
|
||||
mode,
|
||||
mitm: mitm_state,
|
||||
state: app_state,
|
||||
extensions: Extensions::new(),
|
||||
state: app_state.clone(),
|
||||
extensions,
|
||||
}),
|
||||
};
|
||||
if let Some(conn) = conn {
|
||||
@@ -458,13 +469,7 @@ async fn handle_socks5_tcp(
|
||||
|
||||
info!("SOCKS upstream dial started (host={host}, port={port})");
|
||||
let connect_started_at = Instant::now();
|
||||
let result = tcp_connector.serve(req).await.map(|connection| {
|
||||
let EstablishedClientConnection { input, conn } = connection;
|
||||
EstablishedClientConnection {
|
||||
input,
|
||||
conn: Socks5TcpConnection::Direct(conn),
|
||||
}
|
||||
});
|
||||
let result = connect_opaque_tcp(app_state, req).await;
|
||||
match &result {
|
||||
Ok(_) => info!(
|
||||
"SOCKS upstream dial established (host={host}, port={port}, elapsed_ms={})",
|
||||
@@ -478,6 +483,31 @@ async fn handle_socks5_tcp(
|
||||
result
|
||||
}
|
||||
|
||||
async fn connect_opaque_tcp(
|
||||
state: Arc<NetworkProxyState>,
|
||||
req: TcpRequest,
|
||||
) -> Result<EstablishedClientConnection<Socks5TcpConnection, TcpRequest>, BoxError> {
|
||||
if let Some(proxy) = &state.tcp_upstream_proxy {
|
||||
let tunnel = proxy
|
||||
.connect(
|
||||
&state,
|
||||
&req.authority,
|
||||
DialPolicy::authorization(req.extensions(), &req.authority),
|
||||
)
|
||||
.await?;
|
||||
return Ok(EstablishedClientConnection {
|
||||
input: req,
|
||||
conn: Socks5TcpConnection::Tunnel(tunnel),
|
||||
});
|
||||
}
|
||||
let EstablishedClientConnection { input, conn } =
|
||||
TargetCheckedTcpConnector::new(state).serve(req).await?;
|
||||
Ok(EstablishedClientConnection {
|
||||
input,
|
||||
conn: Socks5TcpConnection::Direct(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Internal connector output for SOCKS5 TCP. MITM requests do not dial upstream before the
|
||||
/// inner HTTPS request is inspected, so they carry the target metadata instead of a socket.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -490,6 +520,7 @@ enum SocksMitmMode {
|
||||
#[derive(Debug)]
|
||||
enum Socks5TcpConnection {
|
||||
Direct(TcpStream),
|
||||
Tunnel(Rewind<TcpStream>),
|
||||
Mitm {
|
||||
target: HostWithPort,
|
||||
mode: NetworkMode,
|
||||
@@ -514,6 +545,7 @@ impl AsyncRead for Socks5TcpConnection {
|
||||
) -> Poll<io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
Self::Direct(stream) => Pin::new(stream).poll_read(cx, buf),
|
||||
Self::Tunnel(stream) => Pin::new(stream).poll_read(cx, buf),
|
||||
Self::Mitm { .. } | Self::DetectProtocol { .. } => Poll::Ready(Ok(())),
|
||||
}
|
||||
}
|
||||
@@ -527,6 +559,7 @@ impl AsyncWrite for Socks5TcpConnection {
|
||||
) -> Poll<io::Result<usize>> {
|
||||
match self.get_mut() {
|
||||
Self::Direct(stream) => Pin::new(stream).poll_write(cx, buf),
|
||||
Self::Tunnel(stream) => Pin::new(stream).poll_write(cx, buf),
|
||||
Self::Mitm { .. } | Self::DetectProtocol { .. } => Poll::Ready(Ok(buf.len())),
|
||||
}
|
||||
}
|
||||
@@ -534,6 +567,7 @@ impl AsyncWrite for Socks5TcpConnection {
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
Self::Direct(stream) => Pin::new(stream).poll_flush(cx),
|
||||
Self::Tunnel(stream) => Pin::new(stream).poll_flush(cx),
|
||||
Self::Mitm { .. } | Self::DetectProtocol { .. } => Poll::Ready(Ok(())),
|
||||
}
|
||||
}
|
||||
@@ -541,6 +575,7 @@ impl AsyncWrite for Socks5TcpConnection {
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
Self::Direct(stream) => Pin::new(stream).poll_shutdown(cx),
|
||||
Self::Tunnel(stream) => Pin::new(stream).poll_shutdown(cx),
|
||||
Self::Mitm { .. } | Self::DetectProtocol { .. } => Poll::Ready(Ok(())),
|
||||
}
|
||||
}
|
||||
@@ -550,7 +585,7 @@ impl Socket for Socks5TcpConnection {
|
||||
fn local_addr(&self) -> io::Result<SocketAddr> {
|
||||
match self {
|
||||
Self::Direct(stream) => stream.local_addr(),
|
||||
Self::Mitm { .. } | Self::DetectProtocol { .. } => {
|
||||
Self::Tunnel(_) | Self::Mitm { .. } | Self::DetectProtocol { .. } => {
|
||||
Ok(SocketAddr::from(([0, 0, 0, 0], 0)))
|
||||
}
|
||||
}
|
||||
@@ -559,7 +594,7 @@ impl Socket for Socks5TcpConnection {
|
||||
fn peer_addr(&self) -> io::Result<SocketAddr> {
|
||||
match self {
|
||||
Self::Direct(stream) => stream.peer_addr(),
|
||||
Self::Mitm { .. } | Self::DetectProtocol { .. } => {
|
||||
Self::Tunnel(_) | Self::Mitm { .. } | Self::DetectProtocol { .. } => {
|
||||
Ok(SocketAddr::from(([0, 0, 0, 0], 0)))
|
||||
}
|
||||
}
|
||||
@@ -570,6 +605,7 @@ impl ExtensionsRef for Socks5TcpConnection {
|
||||
fn extensions(&self) -> &Extensions {
|
||||
match self {
|
||||
Self::Direct(stream) => stream.extensions(),
|
||||
Self::Tunnel(stream) => stream.extensions(),
|
||||
Self::Mitm { extensions, .. } | Self::DetectProtocol { extensions, .. } => extensions,
|
||||
}
|
||||
}
|
||||
@@ -579,6 +615,7 @@ impl ExtensionsMut for Socks5TcpConnection {
|
||||
fn extensions_mut(&mut self) -> &mut Extensions {
|
||||
match self {
|
||||
Self::Direct(stream) => stream.extensions_mut(),
|
||||
Self::Tunnel(stream) => stream.extensions_mut(),
|
||||
Self::Mitm { extensions, .. } | Self::DetectProtocol { extensions, .. } => extensions,
|
||||
}
|
||||
}
|
||||
@@ -589,13 +626,21 @@ async fn proxy_socks5_tcp(
|
||||
) -> Result<(), BoxError> {
|
||||
let ProxyRequest { mut source, target } = request;
|
||||
match target {
|
||||
Socks5TcpConnection::Direct(target) => StreamForwardService::default()
|
||||
.serve(ProxyRequest { source, target })
|
||||
.await
|
||||
.map_err(Into::into),
|
||||
target @ (Socks5TcpConnection::Direct(_) | Socks5TcpConnection::Tunnel(_)) => {
|
||||
StreamForwardService::default()
|
||||
.serve(ProxyRequest { source, target })
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
Socks5TcpConnection::Mitm {
|
||||
target, mode, mitm, ..
|
||||
target,
|
||||
mode,
|
||||
mitm,
|
||||
extensions,
|
||||
} => {
|
||||
if let Some(policy) = extensions.get::<DialPolicy>() {
|
||||
source.extensions_mut().insert(policy.clone());
|
||||
}
|
||||
source.extensions_mut().insert(ProxyTarget(target));
|
||||
source.extensions_mut().insert(mode);
|
||||
source.extensions_mut().insert(mitm);
|
||||
@@ -609,8 +654,11 @@ async fn proxy_socks5_tcp(
|
||||
mode,
|
||||
mitm,
|
||||
state,
|
||||
..
|
||||
extensions,
|
||||
} => {
|
||||
if let Some(policy) = extensions.get::<DialPolicy>() {
|
||||
source.extensions_mut().insert(policy.clone());
|
||||
}
|
||||
source.extensions_mut().insert(ProxyTarget(target.clone()));
|
||||
source.extensions_mut().insert(mode);
|
||||
source.extensions_mut().insert(mitm);
|
||||
@@ -638,10 +686,11 @@ async fn proxy_socks5_tcp(
|
||||
}
|
||||
info!("SOCKS opaque upstream dial started (target={target})");
|
||||
let connect_started_at = Instant::now();
|
||||
let EstablishedClientConnection { conn: upstream, .. } =
|
||||
TargetCheckedTcpConnector::new(state)
|
||||
.serve(TcpRequest::new(target.clone()))
|
||||
.await?;
|
||||
let EstablishedClientConnection { conn: upstream, .. } = connect_opaque_tcp(
|
||||
state,
|
||||
TcpRequest::new_with_extensions(target.clone(), extensions),
|
||||
)
|
||||
.await?;
|
||||
info!(
|
||||
"SOCKS opaque upstream dial established (target={target}, elapsed_ms={})",
|
||||
connect_started_at.elapsed().as_millis()
|
||||
@@ -927,10 +976,7 @@ mod tests {
|
||||
|
||||
let (result, events) = capture_events(|| async {
|
||||
handle_socks5_tcp(
|
||||
request,
|
||||
TargetCheckedTcpConnector::new(state.clone()),
|
||||
/*policy_decider*/ None,
|
||||
/*environment_id*/ None,
|
||||
request, /*policy_decider*/ None, /*environment_id*/ None,
|
||||
)
|
||||
.await
|
||||
})
|
||||
@@ -971,10 +1017,7 @@ mod tests {
|
||||
request.extensions_mut().insert(state.clone());
|
||||
|
||||
let result = handle_socks5_tcp(
|
||||
request,
|
||||
TargetCheckedTcpConnector::new(state),
|
||||
/*policy_decider*/ None,
|
||||
/*environment_id*/ None,
|
||||
request, /*policy_decider*/ None, /*environment_id*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("limited-mode HTTPS should use MITM");
|
||||
@@ -997,10 +1040,7 @@ mod tests {
|
||||
|
||||
let (result, events) = capture_events(|| async {
|
||||
handle_socks5_tcp(
|
||||
request,
|
||||
TargetCheckedTcpConnector::new(state),
|
||||
/*policy_decider*/ None,
|
||||
/*environment_id*/ None,
|
||||
request, /*policy_decider*/ None, /*environment_id*/ None,
|
||||
)
|
||||
.await
|
||||
})
|
||||
@@ -1048,10 +1088,7 @@ mod tests {
|
||||
request.extensions_mut().insert(state.clone());
|
||||
|
||||
let result = handle_socks5_tcp(
|
||||
request,
|
||||
TargetCheckedTcpConnector::new(state),
|
||||
/*policy_decider*/ None,
|
||||
/*environment_id*/ None,
|
||||
request, /*policy_decider*/ None, /*environment_id*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("brokered TLS should defer MITM until protocol detection");
|
||||
@@ -1076,10 +1113,7 @@ mod tests {
|
||||
request.extensions_mut().insert(state.clone());
|
||||
|
||||
let err = handle_socks5_tcp(
|
||||
request,
|
||||
TargetCheckedTcpConnector::new(state),
|
||||
/*policy_decider*/ None,
|
||||
/*environment_id*/ None,
|
||||
request, /*policy_decider*/ None, /*environment_id*/ None,
|
||||
)
|
||||
.await
|
||||
.expect_err("limited-mode HTTPS requires MITM");
|
||||
@@ -1114,10 +1148,7 @@ mod tests {
|
||||
request.extensions_mut().insert(state.clone());
|
||||
|
||||
let result = handle_socks5_tcp(
|
||||
request,
|
||||
TargetCheckedTcpConnector::new(state),
|
||||
/*policy_decider*/ None,
|
||||
/*environment_id*/ None,
|
||||
request, /*policy_decider*/ None, /*environment_id*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("hooked HTTPS should use MITM");
|
||||
@@ -1149,10 +1180,7 @@ mod tests {
|
||||
request.extensions_mut().insert(state.clone());
|
||||
|
||||
let err = handle_socks5_tcp(
|
||||
request,
|
||||
TargetCheckedTcpConnector::new(state),
|
||||
/*policy_decider*/ None,
|
||||
/*environment_id*/ None,
|
||||
request, /*policy_decider*/ None, /*environment_id*/ None,
|
||||
)
|
||||
.await
|
||||
.expect_err("hooked non-HTTPS SOCKS should require MITM");
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Mandatory next hop for opaque SOCKS TCP on executors provisioned with an HTTP CONNECT proxy.
|
||||
//! Only the executor selects the endpoint. The sandbox supplies the already-authorized target.
|
||||
|
||||
use crate::connect_policy::TargetCheckedTcpConnector;
|
||||
use crate::policy::is_non_public_ip;
|
||||
use crate::policy::is_private_network_ip;
|
||||
use crate::policy::normalize_host;
|
||||
use crate::policy::numeric_host_address;
|
||||
use crate::state::NetworkProxyState;
|
||||
use anyhow::ensure;
|
||||
use rama_core::Service;
|
||||
use rama_core::error::BoxError;
|
||||
use rama_core::extensions::ExtensionsRef;
|
||||
use rama_core::stream::rewind::Rewind;
|
||||
use rama_http::Body;
|
||||
use rama_http::Method;
|
||||
use rama_http::Request;
|
||||
use rama_http::Version;
|
||||
use rama_http::header::HOST;
|
||||
use rama_http::io::upgrade;
|
||||
use rama_http_backend::client::HttpConnector;
|
||||
use rama_net::Protocol;
|
||||
use rama_net::address::HostWithPort;
|
||||
use rama_net::address::ProxyAddress;
|
||||
use rama_net::client::EstablishedClientConnection;
|
||||
use rama_net::stream::ClientSocketInfo;
|
||||
use rama_tcp::TcpStream;
|
||||
use std::pin::Pin;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Trusted plaintext HTTP CONNECT listener, expressed as a hostname or IP and an explicit port.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TcpUpstreamProxy(HostWithPort);
|
||||
|
||||
impl FromStr for TcpUpstreamProxy {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(raw: &str) -> Result<Self, Self::Err> {
|
||||
let endpoint: HostWithPort = raw.parse()?;
|
||||
ensure!(
|
||||
endpoint.port != 0,
|
||||
"TCP upstream proxy port must be nonzero"
|
||||
);
|
||||
Ok(Self(endpoint))
|
||||
}
|
||||
}
|
||||
|
||||
impl TcpUpstreamProxy {
|
||||
pub(crate) async fn connect(
|
||||
&self,
|
||||
state: &Arc<NetworkProxyState>,
|
||||
target: &HostWithPort,
|
||||
authorization: crate::runtime::HostAuthorization,
|
||||
) -> Result<Rewind<TcpStream>, BoxError> {
|
||||
if !state.allow_upstream_proxy().await? {
|
||||
return Err("upstream proxies are disabled by network policy".into());
|
||||
}
|
||||
|
||||
let host = normalize_host(&target.host.to_string());
|
||||
let target_ip = numeric_host_address(&host);
|
||||
let same_port = target.port == self.0.port;
|
||||
if same_port && host == normalize_host(&self.0.host.to_string()) {
|
||||
return Err("cannot tunnel back into the TCP upstream proxy".into());
|
||||
}
|
||||
let authority = if let Some(ip) = target_ip {
|
||||
if state
|
||||
.resolved_target_blocked(&host, ip, authorization)
|
||||
.await?
|
||||
|| (is_non_public_ip(ip)
|
||||
&& !(state.proxy_private_ips_via_upstream && is_private_network_ip(ip)))
|
||||
{
|
||||
return Err("IP destination cannot use the TCP upstream proxy".into());
|
||||
}
|
||||
HostWithPort::new(ip.into(), target.port).to_string()
|
||||
} else {
|
||||
if host == "localhost"
|
||||
|| host.ends_with(".localhost")
|
||||
|| !state.permits_remote_tcp_dns().await?
|
||||
{
|
||||
return Err("destination requires local address enforcement".into());
|
||||
}
|
||||
target.to_string()
|
||||
};
|
||||
let request = Request::builder()
|
||||
.method(Method::CONNECT)
|
||||
.version(Version::HTTP_11)
|
||||
.uri(&authority)
|
||||
.header(HOST, &authority)
|
||||
.extension(ProxyAddress {
|
||||
protocol: Some(Protocol::HTTP),
|
||||
address: self.0.clone(),
|
||||
credential: None,
|
||||
})
|
||||
.body(Body::empty())?;
|
||||
// A fresh request carries only the trusted next hop. Rama dials that endpoint and
|
||||
// retains bytes read past the CONNECT headers in the upgraded stream.
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let connector = HttpConnector::new(TargetCheckedTcpConnector::new(state.clone()));
|
||||
let EstablishedClientConnection { input, conn } = connector.serve(request).await?;
|
||||
// Also reject the proxy's actual IP when provisioning supplied a hostname.
|
||||
if same_port && let Some(ip) = target_ip {
|
||||
let peer = conn
|
||||
.extensions()
|
||||
.get::<ClientSocketInfo>()
|
||||
.ok_or("missing TCP upstream peer address")?
|
||||
.0
|
||||
.peer_addr();
|
||||
if ip.to_canonical() == peer.ip().to_canonical() {
|
||||
return Err("cannot tunnel back into the TCP upstream proxy".into());
|
||||
}
|
||||
}
|
||||
let response = conn.serve(input).await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("TCP upstream refused CONNECT ({})", response.status()).into());
|
||||
}
|
||||
let stream = upgrade::handle_upgrade(response).await?;
|
||||
let parts = stream
|
||||
.downcast::<Pin<Box<TcpStream>>>()
|
||||
.map_err(|_| "unexpected transport for HTTP CONNECT")?;
|
||||
Ok(Rewind::new_buffered(
|
||||
*Pin::into_inner(parts.io),
|
||||
parts.read_buf,
|
||||
))
|
||||
})
|
||||
.await?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tcp_upstream_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,126 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use crate::NetworkMode;
|
||||
use crate::NetworkProxyConfig;
|
||||
use crate::socks5::socks5_proxy_service;
|
||||
use crate::state::network_proxy_state_for_policy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rama_core::Service;
|
||||
use std::io::ErrorKind;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
#[tokio::test]
|
||||
async fn socks_tcp_waits_for_http_connect_and_preserves_the_tunnel() {
|
||||
let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let ingress = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut config = NetworkProxyConfig {
|
||||
mode: NetworkMode::Full,
|
||||
allow_local_binding: Some(true),
|
||||
allow_upstream_proxy: true,
|
||||
..Default::default()
|
||||
};
|
||||
config.set_allowed_domains(vec!["db.invalid".into()]);
|
||||
let mut state = network_proxy_state_for_policy(config);
|
||||
state.set_tcp_upstream_proxy(upstream.local_addr().unwrap().to_string().parse().unwrap());
|
||||
let service = socks5_proxy_service(
|
||||
Arc::new(state),
|
||||
/*policy_decider*/ None,
|
||||
/*environment_id*/ None,
|
||||
/*enable_socks5_udp*/ false,
|
||||
);
|
||||
|
||||
for success in [false, true] {
|
||||
let exercise = async {
|
||||
let mut client = TcpStream::connect(ingress.local_addr().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let (socket, _) = ingress.accept().await.unwrap();
|
||||
let peer = async {
|
||||
let (stream, _) = upstream.accept().await.unwrap();
|
||||
let mut proxy = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
proxy.read_line(&mut line).await.unwrap();
|
||||
assert_eq!(line, "CONNECT db.invalid:5432 HTTP/1.1\r\n");
|
||||
loop {
|
||||
line.clear();
|
||||
assert_ne!(proxy.read_line(&mut line).await.unwrap(), 0);
|
||||
if line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(proxy.buffer().is_empty());
|
||||
// Exercise read-ahead and a non-200 success; no payload may escape on refusal.
|
||||
let response = if success {
|
||||
b"HTTP/1.1 201 Created\r\n\r\nhello".as_slice()
|
||||
} else {
|
||||
b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n"
|
||||
};
|
||||
proxy.get_mut().write_all(response).await.unwrap();
|
||||
let mut received = Vec::new();
|
||||
proxy.read_to_end(&mut received).await.unwrap();
|
||||
assert_eq!(received, if success { b"private".as_slice() } else { &[] });
|
||||
};
|
||||
let caller = async {
|
||||
client.write_all(&[5, 1, 0]).await.unwrap();
|
||||
let mut greeting = [0; 2];
|
||||
client.read_exact(&mut greeting).await.unwrap();
|
||||
assert_eq!(greeting, [5, 0]);
|
||||
// Pipeline the payload with CONNECT, before the upstream has replied.
|
||||
client
|
||||
.write_all(b"\x05\x01\x00\x03\x0adb.invalid\x15\x38private")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut reply = [0; 10];
|
||||
match client.read_exact(&mut reply).await {
|
||||
Ok(_) => assert_eq!(reply[1] == 0, success),
|
||||
// Closing a socket with unread pipelined data can reset it on Windows.
|
||||
Err(err) if !success => assert_eq!(err.kind(), ErrorKind::ConnectionReset),
|
||||
Err(err) => panic!("missing SOCKS reply: {err}"),
|
||||
}
|
||||
if success {
|
||||
client.shutdown().await.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
client.read_to_end(&mut bytes).await.unwrap();
|
||||
assert_eq!(bytes, b"hello");
|
||||
}
|
||||
};
|
||||
let (result, (), ()) = tokio::join!(service.serve(socket.into()), peer, caller);
|
||||
assert_eq!(result.is_ok(), success);
|
||||
};
|
||||
tokio::time::timeout(Duration::from_secs(5), exercise)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_dns_requires_policy_independent_of_the_resolved_ip() {
|
||||
for (allow_local, deny, allow_remote_dns) in [
|
||||
(false, "denied.example", false),
|
||||
(true, "denied.example", true),
|
||||
(true, "0177.0.0.1", false),
|
||||
(true, "10.*", false),
|
||||
(true, "*.de", true),
|
||||
(true, "*.10", false),
|
||||
(true, "[::ffff:192.0.2.1]", false),
|
||||
] {
|
||||
let mut config = NetworkProxyConfig {
|
||||
allow_local_binding: Some(allow_local),
|
||||
..Default::default()
|
||||
};
|
||||
config.set_denied_domains(vec![deny.into()]);
|
||||
let state = network_proxy_state_for_policy(config);
|
||||
assert_eq!(
|
||||
state.permits_remote_tcp_dns().await.unwrap(),
|
||||
allow_remote_dns,
|
||||
"{deny}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user