mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
## 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
127 lines
4.9 KiB
Rust
127 lines
4.9 KiB
Rust
#![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}"
|
|
);
|
|
}
|
|
}
|