Files
codex/codex-rs/http-client/src/tls_backend_fallback.rs
T
acrognale-oai 888e02db34 Enforce network policy throughout HTTP and WebSocket requests (#47389)
## Why

Destination restrictions and policy revocation must remain effective during redirects, response body reads, and established WebSocket traffic. Policy denials must also survive error handling so callers do not retry them or report a revoked operation as successful.

## What changed

- Route managed HTTP clients through a shared `RequestBuilder` and policy-aware execution, checking each redirect destination before route resolution and retaining a network permit while consuming response bodies.
- Guard WebSocket connection setup, reads, and writes with revocable permits, including independent wakeups for split readers and writers.
- Preserve `TransportError::Policy` through HTTP, SSE, and realtime error handling, and treat policy denials as non-retryable.
- Keep the supplied network policy in plugin startup HTTP requests and propagate response body failures from backend and plugin requests.

## Testing

Add regression coverage for rejection before connecting, revocation during redirect routing and streamed body reads, split WebSocket revocation, realtime writer error propagation, and revoked plugin upload responses.

GitOrigin-RevId: fba36700444c98a8364c09b4dc5452c4d78a90fb
2026-09-22 23:37:39 +00:00

157 lines
5.0 KiB
Rust

//! Narrow TLS backend fallback for delegated requests that select their route per destination.
//!
//! Native TLS remains the default. A recognized connection-time protocol negotiation failure can
//! select rustls for one HTTPS origin and outbound route without changing other destinations.
use std::collections::HashMap;
use std::collections::HashSet;
use std::error::Error;
use std::sync::Arc;
use std::sync::Mutex;
use crate::OutboundProxyRoute;
use crate::client::TransportClient;
const MAX_CACHED_RUSTLS_DESTINATIONS: usize = 16;
// Schannel maps TLS alert 70 (protocol_version) to SEC_E_UNSUPPORTED_FUNCTION.
const SCHANNEL_PROTOCOL_VERSION_ERROR: i32 = 0x8009_0302_u32 as i32;
#[derive(Clone, Default)]
pub(crate) struct RustlsClientCache {
state: Arc<Mutex<RustlsClientCacheState>>,
}
#[derive(Default)]
struct RustlsClientCacheState {
destinations: HashSet<DestinationRoute>,
clients: HashMap<OutboundProxyRoute, TransportClient>,
}
#[derive(Clone, Hash, PartialEq, Eq)]
struct DestinationRoute {
host: String,
port: u16,
route: OutboundProxyRoute,
}
impl RustlsClientCache {
pub(crate) fn requires_rustls(&self, url: &reqwest::Url, route: &OutboundProxyRoute) -> bool {
let Some(destination) = DestinationRoute::new(url, route) else {
return false;
};
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.destinations
.contains(&destination)
}
pub(crate) fn client_for_route(&self, route: &OutboundProxyRoute) -> Option<TransportClient> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clients
.get(route)
.cloned()
}
pub(crate) fn remember(
&self,
url: &reqwest::Url,
route: &OutboundProxyRoute,
client: TransportClient,
) {
let Some(destination) = DestinationRoute::new(url, route) else {
return;
};
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.destinations.contains(&destination) {
return;
}
if state.destinations.len() >= MAX_CACHED_RUSTLS_DESTINATIONS
&& let Some(destination_to_evict) = state.destinations.iter().next().cloned()
{
state.destinations.remove(&destination_to_evict);
if !state
.destinations
.iter()
.any(|destination| destination.route == destination_to_evict.route)
{
state.clients.remove(&destination_to_evict.route);
}
}
state.clients.entry(route.clone()).or_insert(client);
state.destinations.insert(destination);
}
}
impl DestinationRoute {
fn new(url: &reqwest::Url, route: &OutboundProxyRoute) -> Option<Self> {
if url.scheme() != "https" {
return None;
}
Some(Self {
host: url.host_str()?.to_ascii_lowercase(),
port: url.port_or_known_default()?,
route: route.clone(),
})
}
}
pub(crate) fn should_retry_with_rustls(error: &reqwest::Error) -> bool {
error.is_connect() && !error.is_timeout() && error.source().is_some_and(has_retryable_tls_error)
}
fn has_retryable_tls_error(error: &(dyn Error + 'static)) -> bool {
let mut source = Some(error);
let mut recognized_negotiation_failure = false;
while let Some(error) = source {
let message = error.to_string().to_ascii_lowercase();
if [
"certificate",
"unknown issuer",
"unknown ca",
"untrusted",
"self signed",
"self-signed",
"hostname",
"expired",
"revoked",
]
.iter()
.any(|marker| message.contains(marker))
{
return false;
}
// macOS Secure Transport reports the protocol alert as "bad protocol version".
let is_macos_protocol_version_error = message.contains("bad protocol version");
// Linux OpenSSL reports the peer's "tlsv1 alert protocol version".
let is_linux_protocol_version_error = message.contains("tlsv1 alert protocol version");
// Windows Schannel may expose the protocol alert as a raw or formatted OS error.
let is_schannel_protocol_version_error = error
.downcast_ref::<std::io::Error>()
.and_then(std::io::Error::raw_os_error)
== Some(SCHANNEL_PROTOCOL_VERSION_ERROR)
|| message.contains("(os error -2146893054)")
|| message.contains("0x80090302");
if is_macos_protocol_version_error
|| is_linux_protocol_version_error
|| is_schannel_protocol_version_error
{
recognized_negotiation_failure = true;
}
source = error.source();
}
recognized_negotiation_failure
}
#[cfg(test)]
#[path = "tls_backend_fallback_tests.rs"]
mod tests;