mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
## What changed Add `codex tcp-tunnel` and the `codex-tcp-tunnel` crate to forward loopback TCP connections to an explicit target through a TLS-verified HTTP/3 CONNECT proxy. - Require the proxy origin to match an approved HTTPS origin in a supplied policy file. - Read bearer tokens and optional bounded, non-forwarding `x-` headers from stdin. Support token updates for new connections, `LISTENING` and `AUTH_UPDATED` notifications, and shutdown when the control pipe closes in token-update mode. - Preserve the listener across proxy reconnects without replaying TCP streams, and let accepted streams continue while a proxy drains. ## Testing Add tests for hidden CLI parsing, proxy and target validation, credential renewal, control-pipe closure, and invalid input without secret disclosure. A local HTTP/3 proxy test covers token replacement, transport recovery without stream replay, and graceful draining. GitOrigin-RevId: c6af3025301c61e9fe90940cf5a6df0039465e69
380 lines
15 KiB
Rust
380 lines
15 KiB
Rust
use std::io::Cursor;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use bytes::Buf;
|
|
use bytes::Bytes;
|
|
use http::Response;
|
|
use pretty_assertions::assert_eq;
|
|
use rcgen::CertifiedKey;
|
|
use rcgen::generate_simple_self_signed;
|
|
use rustls::pki_types::PrivatePkcs8KeyDer;
|
|
use tokio::io::AsyncReadExt;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tokio::net::TcpListener;
|
|
use tokio::net::TcpStream;
|
|
use tokio::sync::mpsc;
|
|
use tokio::sync::oneshot;
|
|
use tokio::sync::watch;
|
|
use tokio::time::timeout;
|
|
use url::Url;
|
|
|
|
use super::ProxyConnection;
|
|
use super::ProxyTarget;
|
|
use super::TunnelHeaders;
|
|
use super::control::MAX_CONNECT_HEADER_VALUE_BYTES;
|
|
use super::control::MAX_CONNECT_HEADERS;
|
|
use super::control::MAX_CONNECT_METADATA_BYTES;
|
|
use super::control::MAX_TOKEN_BYTES;
|
|
use super::control::read_connect_headers;
|
|
use super::control_input;
|
|
use super::parse_trusted_origins;
|
|
use super::serve;
|
|
use super::update_auth_tokens;
|
|
|
|
#[test]
|
|
fn proxy_policy_and_target_admission() {
|
|
let origins = parse_trusted_origins("\nhttps://proxy.example.org\n").unwrap();
|
|
assert_eq!(origins, vec!["https://proxy.example.org".to_string()]);
|
|
let proxy = Url::parse("https://proxy.example.org").unwrap();
|
|
for target in ["127.0.0.1:22", "[::1]:2222", "target.example:443"] {
|
|
assert_eq!(
|
|
ProxyTarget::parse(&proxy, &origins, target).unwrap(),
|
|
ProxyTarget {
|
|
host: "proxy.example.org".into(),
|
|
port: 443,
|
|
authority: target.into()
|
|
},
|
|
);
|
|
}
|
|
let ipv6 = "https://[::1]:8443";
|
|
assert_eq!(
|
|
ProxyTarget::parse(
|
|
&Url::parse(ipv6).unwrap(),
|
|
&parse_trusted_origins(ipv6).unwrap(),
|
|
"target.example:22"
|
|
)
|
|
.unwrap(),
|
|
ProxyTarget {
|
|
host: "::1".into(),
|
|
port: 8443,
|
|
authority: "target.example:22".into()
|
|
},
|
|
);
|
|
for origin in [
|
|
"http://proxy.example.org",
|
|
"https://proxy.example.org/path",
|
|
"https://user@proxy.example.org",
|
|
"https://proxy.example.org?query",
|
|
"https://proxy.example.org#fragment",
|
|
] {
|
|
assert!(parse_trusted_origins(origin).is_err(), "{origin}");
|
|
assert!(
|
|
ProxyTarget::parse(&Url::parse(origin).unwrap(), &origins, "127.0.0.1:22").is_err(),
|
|
"{origin}"
|
|
);
|
|
}
|
|
for origin in [
|
|
"",
|
|
"https://proxy.example.org:443",
|
|
"https://proxy.example.org/",
|
|
] {
|
|
assert!(parse_trusted_origins(origin).is_err(), "{origin}");
|
|
}
|
|
for (proxy, target) in [
|
|
("https://evil.example", "127.0.0.1:22"),
|
|
("https://proxy.example.org.attacker.net", "127.0.0.1:22"),
|
|
("https://proxy.example.org", "127.0.0.1:0"),
|
|
("https://proxy.example.org", "target.example"),
|
|
("https://proxy.example.org", "user@target.example:22"),
|
|
("https://proxy.example.org", "target.example:65536"),
|
|
("https://proxy.example.org", "target.example:22/path"),
|
|
] {
|
|
assert!(
|
|
ProxyTarget::parse(&Url::parse(proxy).unwrap(), &origins, target).is_err(),
|
|
"{proxy} {target}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn control_input_acknowledges_bearer_renewal_with_or_without_metadata() -> Result<()> {
|
|
for (input, includes_metadata) in [
|
|
(&b"first-secret\nsecond-secret\n"[..], false),
|
|
(
|
|
&b"[[\"x-test-route\",\"private-value\"]]\nfirst-secret\nsecond-secret\n"[..],
|
|
true,
|
|
),
|
|
] {
|
|
let (metadata, mut tokens) = control_input(Cursor::new(input), includes_metadata)?;
|
|
let metadata = metadata.await??;
|
|
if includes_metadata {
|
|
assert_eq!(metadata["x-test-route"], "private-value");
|
|
assert!(metadata["x-test-route"].is_sensitive());
|
|
} else {
|
|
assert!(metadata.is_empty());
|
|
}
|
|
let initial = tokens.recv().await.unwrap()?.unwrap();
|
|
assert_eq!(initial, "Bearer first-secret");
|
|
assert!(initial.is_sensitive());
|
|
let (updates, latest) = watch::channel(initial);
|
|
let (ready, readiness) = oneshot::channel();
|
|
let mut output = Vec::new();
|
|
ready.send(()).unwrap();
|
|
update_auth_tokens(&mut tokens, updates, readiness, &mut output).await?;
|
|
assert_eq!(*latest.borrow(), "Bearer second-secret");
|
|
assert!(latest.borrow().is_sensitive());
|
|
assert_eq!(output, b"AUTH_UPDATED\n");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn closed_parent_pipe_finishes_even_before_proxy_readiness() -> Result<()> {
|
|
let (_, mut tokens) = control_input(Cursor::new([]), /*connect_headers_stdin*/ false)?;
|
|
let (updates, _) = watch::channel("Bearer initial".parse()?);
|
|
let (_ready, readiness) = oneshot::channel();
|
|
let mut output = Vec::new();
|
|
timeout(
|
|
Duration::from_secs(1),
|
|
update_auth_tokens(&mut tokens, updates, readiness, &mut output),
|
|
)
|
|
.await??;
|
|
assert!(output.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_control_input_stops_credentials_without_exposing_secrets() -> Result<()> {
|
|
let oversized_header = format!(
|
|
"[[\"x-test\",\"private-value{}\"]]\n",
|
|
"x".repeat(MAX_CONNECT_HEADER_VALUE_BYTES)
|
|
);
|
|
let oversized_metadata = vec![b'x'; MAX_CONNECT_METADATA_BYTES + 1];
|
|
let mut oversized_count = serde_json::to_vec(
|
|
&(0..=MAX_CONNECT_HEADERS)
|
|
.map(|index| (format!("x-test-{index}"), "private-value"))
|
|
.collect::<Vec<_>>(),
|
|
)?;
|
|
oversized_count.push(b'\n');
|
|
let invalid_metadata: &[(&str, &[u8])] = &[
|
|
(
|
|
"authorization",
|
|
b"[[\"authorization\",\"private-value\"]]\n",
|
|
),
|
|
("forwarding", b"[[\"x-forwarded-for\",\"private-value\"]]\n"),
|
|
("real IP", b"[[\"x-real-ip\",\"private-value\"]]\n"),
|
|
(
|
|
"duplicate",
|
|
b"[[\"x-test\",\"one\"],[\"X-Test\",\"private-value\"]]\n",
|
|
),
|
|
(
|
|
"invalid value",
|
|
b"[[\"x-test\",\"private-value\\nmore\"]]\n",
|
|
),
|
|
("invalid JSON", b"\"private-value\"\n"),
|
|
("missing newline", b"[[\"x-test\",\"private-value\"]]"),
|
|
("value limit", oversized_header.as_bytes()),
|
|
("metadata limit", &oversized_metadata),
|
|
("header count", &oversized_count),
|
|
];
|
|
for &(case, input) in invalid_metadata {
|
|
let mut input = input.to_vec();
|
|
input.extend_from_slice(b"private-secret\n");
|
|
let (metadata, mut tokens) =
|
|
control_input(Cursor::new(input), /*connect_headers_stdin*/ true)?;
|
|
let error = metadata.await?.expect_err(case);
|
|
assert!(!format!("{error:#}").contains("private-"), "{case}");
|
|
assert!(tokens.recv().await.is_none(), "{case}");
|
|
}
|
|
for (case, input) in [
|
|
("invalid bearer", b" private-secret\0 \n".to_vec()),
|
|
("empty bearer", b"\n".to_vec()),
|
|
("bearer limit", vec![b'x'; MAX_TOKEN_BYTES + 1]),
|
|
] {
|
|
let (metadata, mut tokens) =
|
|
control_input(Cursor::new(input), /*connect_headers_stdin*/ false)?;
|
|
assert!(metadata.await??.is_empty(), "{case}");
|
|
let error = tokens.recv().await.expect(case).expect_err(case);
|
|
assert!(!format!("{error:#}").contains("private-"), "{case}");
|
|
assert!(tokens.recv().await.is_none(), "{case}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn transport_reconnect_keeps_listener_and_does_not_replay_old_tcp_streams() -> Result<()> {
|
|
let CertifiedKey { cert, signing_key } =
|
|
generate_simple_self_signed(vec!["127.0.0.1".to_string()])?;
|
|
let provider = Arc::new(rustls::crypto::ring::default_provider());
|
|
let mut server_tls = rustls::ServerConfig::builder_with_provider(provider.clone())
|
|
.with_safe_default_protocol_versions()?
|
|
.with_no_client_auth()
|
|
.with_single_cert(
|
|
vec![cert.der().clone()],
|
|
PrivatePkcs8KeyDer::from(signing_key.serialize_der()).into(),
|
|
)?;
|
|
server_tls.alpn_protocols = vec![b"h3".to_vec()];
|
|
let server_config = quinn::ServerConfig::with_crypto(Arc::new(
|
|
quinn::crypto::rustls::QuicServerConfig::try_from(server_tls)?,
|
|
));
|
|
let endpoint = quinn::Endpoint::server(server_config, "127.0.0.1:0".parse()?)?;
|
|
let proxy_address = endpoint.local_addr()?;
|
|
|
|
let mut roots = rustls::RootCertStore::empty();
|
|
roots.add(cert.der().clone())?;
|
|
let mut client_tls = rustls::ClientConfig::builder_with_provider(provider)
|
|
.with_safe_default_protocol_versions()?
|
|
.with_root_certificates(roots)
|
|
.with_no_client_auth();
|
|
client_tls.alpn_protocols = vec![b"h3".to_vec()];
|
|
let config = quinn::ClientConfig::new(Arc::new(
|
|
quinn::crypto::rustls::QuicClientConfig::try_from(client_tls)?,
|
|
));
|
|
|
|
let request_count = Arc::new(AtomicUsize::new(0));
|
|
let server_request_count = request_count.clone();
|
|
let (accepted, mut connections) = mpsc::unbounded_channel();
|
|
let (authorizations, mut tokens) = mpsc::unbounded_channel();
|
|
let server = tokio::spawn(async move {
|
|
while let Some(incoming) = endpoint.accept().await {
|
|
let accepted = accepted.clone();
|
|
let request_count = server_request_count.clone();
|
|
let authorizations = authorizations.clone();
|
|
tokio::spawn(async move {
|
|
let quic = incoming.await.unwrap();
|
|
let (shutdown, mut shutdown_requests) = mpsc::channel::<oneshot::Sender<()>>(1);
|
|
accepted.send((quic.clone(), shutdown)).unwrap();
|
|
let mut http = h3::server::builder()
|
|
.enable_extended_connect(true)
|
|
.build(h3_quinn::Connection::new(quic))
|
|
.await
|
|
.unwrap();
|
|
loop {
|
|
let request = tokio::select! {
|
|
Some(finished) = shutdown_requests.recv() => {
|
|
http.shutdown(/*max_requests*/ 0).await.unwrap();
|
|
let _ = finished.send(());
|
|
continue;
|
|
}
|
|
request = http.accept() => match request {
|
|
Ok(Some(request)) => request,
|
|
_ => break,
|
|
},
|
|
};
|
|
let (request, mut stream) = request.resolve_request().await.unwrap();
|
|
authorizations
|
|
.send((
|
|
request.headers()[http::header::AUTHORIZATION].clone(),
|
|
request.headers()["x-test-route"].clone(),
|
|
))
|
|
.unwrap();
|
|
request_count.fetch_add(1, Ordering::SeqCst);
|
|
tokio::spawn(async move {
|
|
if stream
|
|
.send_response(Response::builder().status(200).body(()).unwrap())
|
|
.await
|
|
.is_ok()
|
|
&& stream.send_data(Bytes::from_static(b"ready")).await.is_ok()
|
|
{
|
|
while let Ok(Some(mut data)) = stream.recv_data().await {
|
|
let count = data.remaining();
|
|
if stream.send_data(data.copy_to_bytes(count)).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let address = listener.local_addr()?;
|
|
let target = ProxyTarget {
|
|
host: proxy_address.ip().to_string(),
|
|
port: proxy_address.port(),
|
|
authority: "127.0.0.1:22".to_string(),
|
|
};
|
|
let (updates, auth) = watch::channel("Bearer local-test".parse()?);
|
|
let headers = TunnelHeaders {
|
|
auth,
|
|
connect: read_connect_headers(&mut &b"[[\"x-test-route\",\"custom-route\"]]\n"[..])?,
|
|
};
|
|
let initial = ProxyConnection::connect(&target, &config).await?;
|
|
let client = tokio::spawn(serve(listener, target, config, headers, initial));
|
|
let (first_connection, _) = timeout(Duration::from_secs(5), connections.recv())
|
|
.await?
|
|
.unwrap();
|
|
let mut first = TcpStream::connect(address).await?;
|
|
let mut data = [0; 5];
|
|
timeout(Duration::from_secs(5), first.read_exact(&mut data)).await??;
|
|
assert_eq!(data, *b"ready");
|
|
assert_eq!(
|
|
tokens.recv().await.unwrap(),
|
|
("Bearer local-test".parse()?, "custom-route".parse()?)
|
|
);
|
|
|
|
updates.send_replace("Bearer replacement-test".parse()?);
|
|
let mut refreshed = TcpStream::connect(address).await?;
|
|
timeout(Duration::from_secs(5), refreshed.read_exact(&mut data)).await??;
|
|
assert_eq!(
|
|
tokens.recv().await.unwrap(),
|
|
("Bearer replacement-test".parse()?, "custom-route".parse()?)
|
|
);
|
|
|
|
first_connection.close(/*error_code*/ 0_u8.into(), b"test interruption");
|
|
let old_stream = timeout(Duration::from_secs(5), first.read(&mut data)).await?;
|
|
assert!(matches!(old_stream, Ok(0) | Err(_)));
|
|
let (_, graceful_shutdown) = timeout(Duration::from_secs(10), connections.recv())
|
|
.await?
|
|
.unwrap();
|
|
assert_eq!(request_count.load(Ordering::SeqCst), 2);
|
|
|
|
let connect_ready_listener = move || async move {
|
|
loop {
|
|
let mut candidate = TcpStream::connect(address).await?;
|
|
let mut data = [0; 5];
|
|
if timeout(Duration::from_millis(250), candidate.read_exact(&mut data))
|
|
.await
|
|
.is_ok_and(|result| result.is_ok())
|
|
{
|
|
return Ok::<_, anyhow::Error>((data, candidate));
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
};
|
|
let (recovered, mut persistent) =
|
|
timeout(Duration::from_secs(5), connect_ready_listener()).await??;
|
|
assert_eq!(recovered, *b"ready");
|
|
assert_eq!(
|
|
tokens.recv().await.unwrap(),
|
|
("Bearer replacement-test".parse()?, "custom-route".parse()?)
|
|
);
|
|
assert_eq!(request_count.load(Ordering::SeqCst), 3);
|
|
|
|
let (sent, goaway_sent) = oneshot::channel();
|
|
graceful_shutdown.send(sent).await?;
|
|
goaway_sent.await?;
|
|
persistent.write_all(b"still").await?;
|
|
timeout(Duration::from_secs(5), persistent.read_exact(&mut data)).await??;
|
|
assert_eq!(data, *b"still");
|
|
|
|
let new_request = tokio::spawn(connect_ready_listener());
|
|
timeout(Duration::from_secs(10), connections.recv())
|
|
.await?
|
|
.unwrap();
|
|
let (ready, _) = timeout(Duration::from_secs(5), new_request).await???;
|
|
assert_eq!(ready, *b"ready");
|
|
persistent.write_all(b"alive").await?;
|
|
timeout(Duration::from_secs(5), persistent.read_exact(&mut data)).await??;
|
|
assert_eq!(data, *b"alive");
|
|
client.abort();
|
|
server.abort();
|
|
Ok(())
|
|
}
|