// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 //! HTTP CONNECT proxy with OPA policy evaluation and process-identity binding. pub(crate) mod destination; mod egress; mod relay; use crate::identity::BinaryIdentityCache; use crate::l7::tls::ProxyTlsState; use crate::opa::{NetworkAction, OpaEngine, PolicyGenerationGuard}; #[cfg(target_os = "linux")] use crate::policy_dns::PolicyEndpointId; use crate::policy_dns::{MappingLookupError, ResolvedEndpointStore}; use crate::policy_local::{POLICY_LOCAL_HOST, PolicyLocalContext}; use crate::upstream_proxy::{self, UpstreamProxyConfig}; use futures::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; use http::StatusCode; use miette::{IntoDiagnostic, Result}; use openshell_core::activity::{ActivitySender, try_record_activity}; use openshell_core::denial::DenialEvent; use openshell_core::endpoint_status::{ EndpointObservationContext, EndpointObservationSender, EndpointResult, }; use openshell_core::net::{ connect_tcp_nodelay_best_effort, is_always_blocked_ip, is_internal_ip, is_link_local_ip, set_tcp_nodelay_best_effort, }; use openshell_core::policy::ProxyPolicy; use openshell_core::provider_credentials::{ProviderCredentialSnapshot, ProviderCredentialState}; use openshell_core::secrets::{self, SecretResolver, rewrite_header_line_checked}; use openshell_isolation_interface::contract::{ BinaryIdentity as ContractBinaryIdentity, BoundaryDuplexStream, MediationTiming, NetworkMediationSource, PendingTcpOpen, ResolveError, TcpOpenDecision, TcpOpenDenial, }; use openshell_ocsf::{ ActionId, ActivityId, BaseEventBuilder, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, HttpResponse, NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, }; #[cfg(target_os = "linux")] use std::mem::size_of; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; use tokio::io::{ AsyncBufReadExt, AsyncRead as TokioAsyncRead, AsyncReadExt, AsyncWrite as TokioAsyncWrite, AsyncWriteExt, }; use tokio::net::TcpListener; #[cfg(any(target_os = "linux", test))] use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio::task::JoinHandle; use tracing::{debug, warn}; type ProxyClient = tokio::io::BufReader; type AcceptedProxyConnection = ( BoundaryDuplexStream, Option>, Option<(SocketAddr, SocketAddr)>, Option, ); struct TransparentOpen { destination: SocketAddr, authorization: Option<(EgressDecision, destination::UpstreamConnector)>, } enum ProxyAcceptError { Listener(std::io::Error), Source(openshell_isolation_interface::contract::BackendError), } use self::destination::{ DestinationDenial, DestinationDenialKind, DestinationRequest, build_pinned_validation_plan, build_validation_plan, validate_destination, }; use self::egress::{ EgressDecision, EgressIntent, EndpointDecision, IdentityUnavailableReason, L7ConfigSnapshot, L7RouteSnapshot, ProcessIdentityEvidence, }; const MAX_HEADER_BYTES: usize = 8192; const MEDIATION_ACCEPT_WINDOW: usize = 32; struct NetworkOpenTimingGuard { timing: MediationTiming, operation: &'static str, } impl Drop for NetworkOpenTimingGuard { fn drop(&mut self) { tracing::debug!( target: "openshell::network_open_timing", operation = self.operation, notification_to_queue_us = self.timing.sandbox_notification_to_queue.as_micros(), queue_wait_us = self.timing.sandbox_queue_wait.as_micros(), supervisor_processing_us = self.timing.supervisor_received_at.elapsed().as_micros(), "mediated network open timing" ); } } const TUNNEL_PROTOCOL_PEEK_BYTES: usize = crate::l7::rest::HTTP2_PRIOR_KNOWLEDGE_PREFACE.len(); #[cfg(not(test))] const TUNNEL_PROTOCOL_PEEK_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(100); #[cfg(test)] const TUNNEL_PROTOCOL_PEEK_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(10); #[cfg(not(test))] const TUNNEL_PROTOCOL_PEEK_POLL: std::time::Duration = std::time::Duration::from_millis(5); #[cfg(test)] const TUNNEL_PROTOCOL_PEEK_POLL: std::time::Duration = std::time::Duration::from_millis(1); const FORWARD_ENCODED_SLASH_REJECTION_DETAIL: &str = "request-target contains an encoded '/' (%2F) which is not allowed on this endpoint"; fn build_connection_error_event( peer_addr: SocketAddr, message: String, ) -> openshell_ocsf::OcsfEvent { NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) .message(message) .build() } fn build_proxy_connection_error_event( peer_addr: Option, transparent_destination: Option, message: String, ) -> openshell_ocsf::OcsfEvent { if let Some(peer_addr) = peer_addr { return build_connection_error_event(peer_addr, message); } if let Some(destination) = transparent_destination { return NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_ip(destination.ip(), destination.port())) .message(message) .build(); } BaseEventBuilder::new(openshell_ocsf::ctx::ctx()) .activity_name("Proxy connection failure") .severity(SeverityId::Low) .status(StatusId::Failure) .message(message) .build() } fn build_mediation_lane_failure_event(message: String) -> openshell_ocsf::OcsfEvent { BaseEventBuilder::new(openshell_ocsf::ctx::ctx()) .activity_name("Network mediation source failure") .severity(SeverityId::High) .status(StatusId::Failure) .message(message) .build() } fn build_credential_endpoint_mismatch_event( method: &str, host: &str, port: u16, policy_name: &str, ) -> openshell_ocsf::OcsfEvent { HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::for_http_method(method)) .http_request(HttpRequest { http_method: method.parse().expect("HTTP method parsing is infallible"), url: None, }) .http_response(HttpResponse { code: 403 }) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::High) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(host, port)) .firewall_rule(policy_name, "credential-binding") .message(format!( "Credential use denied: credential is not authorized for {host}:{port}" )) .status_detail("credential_endpoint_mismatch") .build() } fn emit_credential_endpoint_mismatch(method: &str, host: &str, port: u16, policy_name: &str) { let event = build_credential_endpoint_mismatch_event(method, host, port, policy_name); ocsf_emit!(event); let finding = crate::l7::build_credential_endpoint_mismatch_finding( policy_name, host, None, "Provider credential endpoint binding mismatch; request denied", ); ocsf_emit!(finding); } /// Hostnames injected by compute drivers as `/etc/hosts` aliases for the host /// machine. Traffic to these names is eligible for the trusted-gateway SSRF /// exemption when the resolved IP matches the driver-injected value read from /// `/etc/hosts` at proxy startup. pub(crate) const HOST_GATEWAY_ALIASES: &[&str] = &[ "host.openshell.internal", "host.containers.internal", "host.docker.internal", ]; fn revision_scoped_dynamic_credentials( snapshot: &ProviderCredentialSnapshot, ) -> std::collections::HashMap { snapshot .dynamic_credentials .iter() .map(|(key, credential)| { let scoped_key = key.rsplit_once('\t').map_or_else( || format!("rev:{}\t{key}", snapshot.revision), |(endpoint_selector, provider_credential)| { format!( "{endpoint_selector}\trev:{}\t{provider_credential}", snapshot.revision ) }, ); (scoped_key, credential.clone()) }) .collect() } /// Cloud instance metadata IPs that are NEVER exempted from SSRF blocking, /// even when they coincidentally match a host-gateway alias resolution. /// This list covers the well-known IMDS endpoints across major cloud providers. const CLOUD_METADATA_IPS: &[IpAddr] = &[ // AWS / GCP / Azure instance metadata service IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254)), ]; pub struct ProxyHandle { #[allow(dead_code)] http_addr: Option, join: JoinHandle<()>, exited_rx: Option>, } impl ProxyHandle { /// Start the proxy with OPA engine for policy evaluation. /// /// The proxy uses OPA for network decisions with process-identity binding /// via `/proc/net/tcp`. All connections are evaluated through OPA policy. #[allow(clippy::too_many_arguments)] pub(crate) async fn start_with_bind_addr( policy: &ProxyPolicy, bind_addr: Option, opa_engine: Arc, identity_cache: Arc, entrypoint_pid: Arc, tls_state: Option>, provider_credentials: Option, policy_local_ctx: Option>, denial_tx: Option>, activity_tx: Option, endpoint_observation_tx: Option, engine_ready: tokio::sync::watch::Receiver, upstream_proxy_args: &upstream_proxy::UpstreamProxyArgs, backend_host_gateway: Option, network_mediation_source: Option>, policy_dns_store: Option>, direct_listener_identity: Option, ) -> Result { // Use override bind_addr, fall back to policy http_addr, then default // to loopback:3128. The default allows the proxy to function when no // network namespace is available (e.g. missing CAP_NET_ADMIN) and the // policy doesn't specify an explicit address. let default_addr: SocketAddr = ([127, 0, 0, 1], 3128).into(); let http_addr = bind_addr.or(policy.http_addr).unwrap_or(default_addr); // Only enforce loopback restriction when not using network namespace override if bind_addr.is_none() && !http_addr.ip().is_loopback() { return Err(miette::miette!( "Proxy http_addr must be loopback-only: {http_addr}" )); } let source_backed = network_mediation_source.is_some(); let listener = if source_backed { None } else { Some(TcpListener::bind(http_addr).await.into_diagnostic()?) }; let local_addr = match listener.as_ref() { Some(listener) => listener.local_addr().into_diagnostic()?, None => http_addr, }; { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Listen) .severity(SeverityId::Informational) .status(StatusId::Success) .dst_endpoint(Endpoint::from_ip(local_addr.ip(), local_addr.port())) .message(if source_backed { "Proxy consuming isolation-boundary streams".to_string() } else { format!("Proxy listening on {local_addr}") }) .build(); ocsf_emit!(event); } // Detect the trusted host gateway IP from /etc/hosts before user code // runs. This is read once at startup so later /etc/hosts modifications // by sandbox workloads cannot influence the stored value. let trusted_host_gateway: Arc> = Arc::new(detect_trusted_host_gateway()); let backend_host_gateway = Arc::new(backend_host_gateway); if let Some(ref ip) = *trusted_host_gateway { tracing::info!( %ip, "Trusted host gateway detected from /etc/hosts; \ host-gateway aliases exempt from SSRF always-blocked check" ); } let agent_proposals = policy_local_ctx .as_ref() .map_or_else(Default::default, |ctx| ctx.agent_proposals()); // Corporate egress proxy configured by the operator and delivered on // the supervisor's command line by the compute driver; the // conventional HTTPS_PROXY/NO_PROXY variables the sandbox controls // are ignored here. // // This is an operator-owned security boundary, so a present-but-invalid // value (bad proxy URL, unreadable auth file, malformed credential) is // fatal to proxy startup: failing closed prevents a misconfiguration // from silently degrading to direct dialing or unauthenticated proxy // access. let upstream_proxy: Arc> = Arc::new( UpstreamProxyConfig::from_args(upstream_proxy_args).map_err(|err| { let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(SeverityId::High) .status(StatusId::Failure) .state(openshell_ocsf::StateId::Disabled, "invalid") .message(format!( "Upstream corporate proxy configuration invalid; \ refusing to start: {err}" )) .build(); ocsf_emit!(event); miette::miette!("invalid upstream corporate proxy configuration: {err}") })?, ); if let Some(cfg) = upstream_proxy.as_ref() { let event = openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) .severity(SeverityId::Informational) .status(StatusId::Success) .state(openshell_ocsf::StateId::Enabled, "enabled") .message(format!( "Upstream corporate proxy enabled: {}", cfg.summary() )) .build(); ocsf_emit!(event); } let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); let join = tokio::spawn(async move { // Hold the sender for the lifetime of this task — when the task // exits (panic, abort, or loop break), the sender drops and the // receiver fires, notifying the sandbox that the proxy is gone. let _proxy_exit_guard = exited_tx; // Wait for the OPA engine's symlink resolution reload to complete // before accepting connections. This prevents requests from // observing a generation transition mid-flight, which would cause // the generation guard to reject them with a 403. // // The TCP listener is already bound, so the OS backlog queues // incoming SYN packets during this wait. Once we start accepting, // queued connections drain immediately. let mut engine_ready = engine_ready; match tokio::time::timeout( std::time::Duration::from_secs(15), engine_ready.wait_for(|v| *v), ) .await { Ok(_) => {} Err(_) => { warn!( "Engine readiness signal not received within 15s; \ proceeding with proxy accept loop" ); } } let mut network_accepts = network_mediation_source.as_ref().map(|source| { let accepts = FuturesUnordered::new(); for _ in 0..MEDIATION_ACCEPT_WINDOW { let source = source.clone(); accepts.push(async move { source.accept_tcp().await }.boxed()); } accepts }); // Transparent opens require policy evaluation and destination // validation before the sandbox may complete connect(2). Keep // those potentially expensive operations out of the accept loop: // serial preauthorization turns bursts of DNS-driven TCP opens // into head-of-line blocking even though the source itself can // accept a window of requests concurrently. let (preauthorized_tx, mut preauthorized_rx) = mpsc::channel(MEDIATION_ACCEPT_WINDOW * 2); let mut consecutive_resource_errors: u32 = 0; let mut consecutive_unknown_errors: u32 = 0; loop { let accepted = if let Some(source) = network_mediation_source.as_ref() { let accepts = network_accepts .as_mut() .expect("mediation source has an accept window"); tokio::select! { pending = accepts.next() => { let pending = pending.expect("accept window is never empty"); let source = source.clone(); accepts.push(async move { source.accept_tcp().await }.boxed()); match pending { Ok(connection) => { let tx = preauthorized_tx.clone(); let dns_store = policy_dns_store.clone(); let opa = opa_engine.clone(); let dtx = denial_tx.clone(); let backend_gateway = *backend_host_gateway; let trusted_gateway = *trusted_host_gateway; tokio::spawn(async move { if let Some(connection) = preauthorize_transparent_open( connection, dns_store.as_ref(), &opa, dtx.as_ref(), backend_gateway, trusted_gateway, ) .await { let _ = tx.send(connection).await; } }); continue; } Err(error) => Err(ProxyAcceptError::Source(error)), } } Some(connection) = preauthorized_rx.recv() => Ok(connection), } } else { let listener = listener .as_ref() .expect("listener exists without a mediation source"); listener .accept() .await .map(|(stream, _)| { set_tcp_nodelay_best_effort(&stream); let workload_addr = stream.peer_addr().ok(); let proxy_addr = stream.local_addr().ok(); let stream: BoundaryDuplexStream = Box::new(stream); ( stream, direct_listener_identity.clone().map(Ok), workload_addr.zip(proxy_addr), None, ) }) .map_err(ProxyAcceptError::Listener) }; match accepted { Ok((stream, supplied_identity, socket_addrs, transparent_destination)) => { let peer_addr = socket_addrs.map(|(workload_addr, _)| workload_addr); let transparent_destination_addr = transparent_destination .as_ref() .map(|transparent| transparent.destination); consecutive_resource_errors = 0; consecutive_unknown_errors = 0; let opa = opa_engine.clone(); let cache = identity_cache.clone(); let spid = entrypoint_pid.clone(); let tls = tls_state.clone(); let policy_local = policy_local_ctx.clone(); let proposals = agent_proposals.clone(); let gw = trusted_host_gateway.clone(); let backend_gw = backend_host_gateway.clone(); let up_proxy = upstream_proxy.clone(); let credentials = provider_credentials.clone(); let dns_store = policy_dns_store.clone(); let resolver = provider_credentials .as_ref() .and_then(ProviderCredentialState::resolver); let dynamic_credentials = provider_credentials.as_ref().map(|state| { Arc::new(std::sync::RwLock::new(revision_scoped_dynamic_credentials( &state.snapshot(), ))) }); let dtx = denial_tx.clone(); let atx = activity_tx.clone(); let endpoint_observations = endpoint_observation_tx.clone(); tokio::spawn(async move { #[allow(clippy::large_futures)] if let Err(err) = handle_mediated_connection( tokio::io::BufReader::new(stream), supplied_identity, socket_addrs, transparent_destination, dns_store, opa, cache, spid, tls, policy_local, proposals, backend_gw, gw, up_proxy, credentials, resolver, dynamic_credentials, dtx, atx, endpoint_observations, ) .await { ocsf_emit!(build_proxy_connection_error_event( peer_addr, transparent_destination_addr, format!("Proxy connection error: {err}"), )); } }); } Err(ProxyAcceptError::Source(err)) => { ocsf_emit!(build_mediation_lane_failure_event(format!( "Network-mediation source failed; proxy accept loop exiting: {err}" ))); break; } Err(ProxyAcceptError::Listener(err)) => { let action = classify_accept_error( &err, &mut consecutive_resource_errors, &mut consecutive_unknown_errors, ); ocsf_emit!(build_accept_error_event(local_addr, &err, &action)); match action { AcceptAction::Terminal => break, AcceptAction::Retry { backoff, .. } => { tokio::time::sleep(backoff).await; } } } } } }); Ok(Self { http_addr: (!source_backed).then_some(local_addr), join, exited_rx: Some(exited_rx), }) } #[allow(dead_code)] pub const fn http_addr(&self) -> Option { self.http_addr } pub fn take_exit_receiver(&mut self) -> Option> { self.exited_rx.take() } } async fn preauthorize_transparent_open( connection: PendingTcpOpen, policy_dns_store: Option<&Arc>, opa_engine: &OpaEngine, denial_tx: Option<&mpsc::UnboundedSender>, backend_host_gateway: Option, trusted_host_gateway: Option, ) -> Option { let PendingTcpOpen { stream, binary_identity, destination, socket: _, policy_generation: _, timing, decision: completion, } = connection; let _timing = NetworkOpenTimingGuard { timing, operation: "tcp", }; let host = match transparent_destination_host(destination, policy_dns_store, opa_engine) { Ok(host) => host, Err(error) => { warn!(%destination, %error, "Denied staged transparent connection"); emit_staged_transparent_denial( destination, &binary_identity, &error.to_string(), "transparent_tcp_mapping_denied", ); let _ = completion.send(TcpOpenDecision::Denied(TcpOpenDenial::InvalidDestination)); return None; } }; let mut decision = authorize_supplied_identity( opa_engine, EgressIntent::connect(host.clone(), destination.port()), &binary_identity, ); if let NetworkAction::Deny { reason } = &decision.action { warn!(%destination, %reason, "Denied staged transparent connection"); emit_denial_simple( denial_tx, &host, destination.port(), &decision .binary .as_ref() .map_or_else(|| "-".to_string(), |path| path.display().to_string()), &decision, reason, "connect", ); emit_staged_transparent_denial( destination, &binary_identity, reason, "transparent_tcp_policy_denied", ); let denial = if binary_identity.is_err() { TcpOpenDenial::IdentityUnavailable } else { TcpOpenDenial::PolicyDenied }; let _ = completion.send(TcpOpenDecision::Denied(denial)); return None; } if let Err(denial) = hydrate_destination_plan(&mut decision, backend_host_gateway, trusted_host_gateway) { warn!(%destination, reason = %denial.reason, "Denied staged transparent destination"); emit_staged_transparent_denial( destination, &binary_identity, &denial.reason, "transparent_tcp_destination_denied", ); let _ = completion.send(TcpOpenDecision::Denied(TcpOpenDenial::InvalidDestination)); return None; } if let Some(mapping) = policy_dns_store.and_then(|store| { store .lookup( destination.ip(), destination.port(), opa_engine.current_generation(), std::time::Instant::now(), ) .ok() }) { let Ok(plan) = build_pinned_validation_plan(mapping.pinned_addresses()) else { emit_staged_transparent_denial( destination, &binary_identity, "policy DNS produced an invalid pinned destination", "transparent_tcp_destination_denied", ); let _ = completion.send(TcpOpenDecision::Denied(TcpOpenDenial::InvalidDestination)); return None; }; decision.endpoint.destination = Some(plan); } let plan = decision .endpoint .destination .as_ref() .expect("destination plan hydrated"); let connector = match validate_destination(DestinationRequest { host: &host, port: destination.port(), sandbox_entrypoint_pid: 0, plan, }) .await { Ok(connector) => connector, Err(denial) => { warn!(%destination, reason = %denial.reason, "Denied staged transparent destination"); emit_staged_transparent_denial( destination, &binary_identity, &denial.reason, "transparent_tcp_destination_denied", ); let _ = completion.send(TcpOpenDecision::Denied(TcpOpenDenial::InvalidDestination)); return None; } }; if completion.send(TcpOpenDecision::RelayReady).is_err() { return None; } Some(( stream, Some(binary_identity), None, Some(TransparentOpen { destination, authorization: Some((decision, connector)), }), )) } fn emit_staged_transparent_denial( destination: SocketAddr, identity: &Result, reason: &str, status_detail: &'static str, ) { let (binary, ancestors, cmdline) = identity.as_ref().map_or_else( |_| ("-".to_string(), "-".to_string(), "-".to_string()), |identity| { ( identity.binary_path.display().to_string(), identity .ancestors .iter() .map(|path| path.display().to_string()) .collect::>() .join(" -> "), identity .cmdline_paths .iter() .map(|path| path.display().to_string()) .collect::>() .join(", "), ) }, ); ocsf_emit!( NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_ip(destination.ip(), destination.port())) .actor_process(Process::from_bypass(&binary, "-", &ancestors).with_cmd_line(&cmdline)) .message(format!("Transparent TCP denied before relay: {reason}")) .status_detail(status_detail) .build() ); } fn transparent_destination_host( destination: SocketAddr, policy_dns_store: Option<&Arc>, opa_engine: &OpaEngine, ) -> Result { let Some(store) = policy_dns_store else { return Ok(destination.ip().to_string()); }; match store.lookup( destination.ip(), destination.port(), opa_engine.current_generation(), std::time::Instant::now(), ) { Ok(mapping) => Ok(mapping.record.normalized_name.as_str().to_string()), Err(MappingLookupError::Missing) => Ok(destination.ip().to_string()), Err(error) => Err(miette::miette!( "transparent destination mapping is unavailable: {error}" )), } } impl Drop for ProxyHandle { fn drop(&mut self) { self.join.abort(); } } /// RAII handle for transparent TCP accept loops. #[cfg(target_os = "linux")] pub(crate) struct TransparentTcpHandle { joins: Vec>, } #[cfg(target_os = "linux")] impl TransparentTcpHandle { #[allow(clippy::too_many_arguments)] pub(crate) fn start( listeners: Vec, store: Arc, opa_engine: Arc, identity_cache: Arc, entrypoint_pid: Arc, agent_proposals: openshell_core::proposals::AgentProposals, denial_tx: Option>, activity_tx: Option, upstream_proxy_args: &upstream_proxy::UpstreamProxyArgs, engine_ready: tokio::sync::watch::Receiver, ) -> Result { let upstream_proxy = Arc::new( UpstreamProxyConfig::from_args(upstream_proxy_args) .map_err(|error| miette::miette!(error))?, ); let mut joins = Vec::with_capacity(listeners.len()); for listener in listeners { let store = store.clone(); let engine = opa_engine.clone(); let cache = identity_cache.clone(); let pid = entrypoint_pid.clone(); let proposals = agent_proposals.clone(); let denial_tx = denial_tx.clone(); let activity_tx = activity_tx.clone(); let upstream_proxy = upstream_proxy.clone(); let mut engine_ready = engine_ready.clone(); joins.push(tokio::spawn(async move { if tokio::time::timeout( std::time::Duration::from_secs(15), engine_ready.wait_for(|ready| *ready), ) .await .is_err() { warn!( "Engine readiness signal not received within 15s; proceeding with transparent TCP accept loop" ); } loop { let Ok((stream, peer_addr)) = listener.accept().await else { break; }; set_tcp_nodelay_best_effort(&stream); let store = store.clone(); let engine = engine.clone(); let cache = cache.clone(); let pid = pid.clone(); let proposals = proposals.clone(); let denial_tx = denial_tx.clone(); let activity_tx = activity_tx.clone(); let upstream_proxy = upstream_proxy.clone(); tokio::spawn(async move { if let Err(error) = handle_transparent_tcp_connection( stream, store, engine, cache, pid, proposals, denial_tx, activity_tx, upstream_proxy, ) .await { ocsf_emit!(build_connection_error_event( peer_addr, format!("Transparent TCP connection error: {error}") )); } }); } })); } Ok(Self { joins }) } } #[cfg(target_os = "linux")] impl Drop for TransparentTcpHandle { fn drop(&mut self) { for join in &self.joins { join.abort(); } } } #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] async fn handle_transparent_tcp_connection( mut client: TcpStream, store: Arc, opa_engine: Arc, identity_cache: Arc, entrypoint_pid: Arc, agent_proposals: openshell_core::proposals::AgentProposals, denial_tx: Option>, activity_tx: Option, upstream_proxy: Arc>, ) -> Result<()> { let workload_addr = client.peer_addr().into_diagnostic()?; let original = original_destination(&client).into_diagnostic()?; let current_generation = opa_engine.current_generation(); let mapping = match store.lookup( original.ip(), original.port(), current_generation, std::time::Instant::now(), ) { Ok(mapping) => mapping, Err(error) => { emit_transparent_mapping_denial(workload_addr, original, error); emit_activity(&activity_tx, true, "transparent_tcp_mapping"); return Ok(()); } }; let host = mapping.record.normalized_name.as_str().to_string(); let port = original.port(); let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, original); let intent = EgressIntent::transparent_tcp(host.clone(), port); let engine = opa_engine.clone(); let cache = identity_cache.clone(); let pid = entrypoint_pid.clone(); let decision = tokio::task::spawn_blocking(move || { authorize_egress_intent(connection, &engine, &cache, &pid, intent) }) .await .map_err(|error| miette::miette!("identity resolution task panicked: {error}"))?; if let NetworkAction::Deny { reason } = &decision.action { emit_transparent_policy_denial(&decision, workload_addr, &host, port); emit_denial( &denial_tx, &host, port, decision .binary .as_ref() .map_or("-", |path| path.to_str().unwrap_or("-")), &decision, reason, "transparent-tcp", ); emit_activity(&activity_tx, true, "transparent_tcp_policy"); return Ok(()); } // Authorization may race a policy reload. Re-pin the exact generation // that produced the decision, then reacquire the DNS mapping against that // generation before correlating endpoint identity or constructing a // connector. This prevents combining an old DNS answer with a newer // policy decision (or vice versa). let Ok(generation_guard) = relay::pin_policy_generation(&opa_engine, decision.policy_generation) else { emit_transparent_mapping_denial(workload_addr, original, MappingLookupError::StalePolicy); emit_activity(&activity_tx, true, "transparent_tcp_mapping"); return Ok(()); }; let mapping = match store.lookup( original.ip(), original.port(), decision.policy_generation, std::time::Instant::now(), ) { Ok(mapping) => mapping, Err(error) => { emit_transparent_mapping_denial(workload_addr, original, error); emit_activity(&activity_tx, true, "transparent_tcp_mapping"); return Ok(()); } }; let endpoint_id = decision .endpoint .matched_endpoints .iter() .map(|endpoint| PolicyEndpointId { policy_name: endpoint.policy_name.clone(), endpoint_index: endpoint.endpoint_index, }) .find(|candidate| mapping.endpoint_ids().any(|mapped| mapped == candidate)); let Some(endpoint_id) = endpoint_id else { let reason = "authorized endpoint did not match DNS correlation"; emit_transparent_policy_denial(&decision, workload_addr, &host, port); emit_denial( &denial_tx, &host, port, decision .binary .as_ref() .map_or("-", |path| path.to_str().unwrap_or("-")), &decision, reason, "transparent-tcp", ); emit_activity(&activity_tx, true, "transparent_tcp_policy"); return Ok(()); }; let connector = mapping.connector_for(&endpoint_id).await.map_err(|error| { miette::miette!("transparent TCP pinned destination is invalid: {error}") })?; let mut ctx = relay::http_context( &decision, None, None, None, agent_proposals, // The transparent TCP path carries no PolicyLocalContext, so no // workspace is available here; matches the CONNECT path default when // policy-local context is absent. String::new(), relay::RelaySignals { activity: activity_tx.clone(), endpoint_observation: None, }, ); let middleware_gate = middleware_uninspectable_gate(&opa_engine, &ctx)?; if middleware_gate == crate::l7::middleware::UninspectableTrafficGate::Deny { crate::l7::middleware::emit_middleware_uninspectable(&ctx, "transparent tcp", true); return Ok(()); } if middleware_gate == crate::l7::middleware::UninspectableTrafficGate::BypassWithFinding { crate::l7::middleware::emit_middleware_uninspectable(&ctx, "transparent tcp", false); } let approved_real_ip_candidates = connector.addrs().to_vec(); generation_guard.ensure_current()?; let mut upstream = dial_transparent_upstream(&upstream_proxy, &host, port, &approved_real_ip_candidates) .await .into_diagnostic()?; let upstream_socket_peer = upstream.peer_addr().into_diagnostic()?; let (connected_real_destination, dial_mode) = match upstream.connect_target() { Some(upstream_proxy::ConnectTarget::Ip(ip)) => ( Some(SocketAddr::new(ip, port)), "upstream_proxy_validated_ip", ), Some(upstream_proxy::ConnectTarget::Hostname) => { // Transparent TCP authorization is correlated to the resolver's // validated address set. A hostname-mode CONNECT would make the // corporate proxy resolve again and break that binding. Treat a // future invariant regression as an audited denial, not a panic. emit_transparent_policy_denial(&decision, workload_addr, &host, port); emit_denial( &denial_tx, &host, port, decision .binary .as_ref() .map_or("-", |path| path.to_str().unwrap_or("-")), &decision, "upstream proxy did not preserve the validated IP target", "transparent-tcp", ); emit_activity(&activity_tx, true, "transparent_tcp_destination"); return Ok(()); } None => (Some(upstream_socket_peer), "direct"), }; generation_guard.ensure_current()?; ctx.request_default_port = None; let policy_name = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.as_deref().unwrap_or("-"), NetworkAction::Deny { .. } => "-", }; let binary = decision .binary .as_ref() .map_or_else(|| "-".to_string(), |path| path.display().to_string()); let pid = decision .binary_pid .map_or_else(|| "-".to_string(), |pid| pid.to_string()); ocsf_emit!(build_transparent_tcp_allow_ocsf_event( TransparentTcpAllowAudit { workload: workload_addr, synthetic_destination: original, normalized_domain: &host, approved_real_ip_candidates: &approved_real_ip_candidates, connected_real_destination, upstream_socket_peer, dial_mode, mapping_id: mapping.record.mapping_id, mapping_generation: mapping.record.mapping_generation, mapping_policy_generation: mapping.record.policy_generation, authorization_policy_generation: decision.policy_generation, binary: &binary, pid: &pid, policy_name, } )); emit_activity(&activity_tx, false, "transparent_tcp"); relay::relay_tcp(&mut client, &mut upstream, &generation_guard, &ctx).await } #[cfg(any(target_os = "linux", test))] struct TransparentTcpAllowAudit<'a> { workload: SocketAddr, synthetic_destination: SocketAddr, normalized_domain: &'a str, approved_real_ip_candidates: &'a [SocketAddr], connected_real_destination: Option, upstream_socket_peer: SocketAddr, dial_mode: &'a str, mapping_id: uuid::Uuid, mapping_generation: u64, mapping_policy_generation: u64, authorization_policy_generation: u64, binary: &'a str, pid: &'a str, policy_name: &'a str, } #[cfg(any(target_os = "linux", test))] fn build_transparent_tcp_allow_ocsf_event( audit: TransparentTcpAllowAudit<'_>, ) -> openshell_ocsf::OcsfEvent { let logical_destination = format!( "{}:{}", audit.normalized_domain, audit.synthetic_destination.port() ); let mapping_id = audit.mapping_id.to_string(); let actual_target = audit .connected_real_destination .map_or_else(|| "proxy-resolved".to_string(), |target| target.to_string()); let message = format!( "Transparent TCP mapping_id={mapping_id} synthetic={} real={actual_target}", audit.synthetic_destination, ); let approved_real_ip_candidates = audit .approved_real_ip_candidates .iter() .map(ToString::to_string) .collect::>(); let mut builder = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Allowed) .disposition(DispositionId::Allowed) .severity(SeverityId::Informational) .status(StatusId::Success) .dst_endpoint(Endpoint::from_domain( audit.normalized_domain, audit.synthetic_destination.port(), )) .src_endpoint_addr(audit.workload.ip(), audit.workload.port()) .actor_process(Process::from_bypass(audit.binary, audit.pid, "")) .firewall_rule(audit.policy_name, "opa") .unmapped("matched_policy", audit.policy_name) .unmapped("normalized_domain", audit.normalized_domain) .unmapped("logical_destination", logical_destination) .unmapped( "synthetic_destination", audit.synthetic_destination.to_string(), ) .unmapped( "approved_real_ip_candidates", serde_json::json!(approved_real_ip_candidates), ) .unmapped( "upstream_socket_peer", audit.upstream_socket_peer.to_string(), ) .unmapped("dial_mode", audit.dial_mode) .unmapped("mapping_id", mapping_id) .unmapped("mapping_generation", audit.mapping_generation) .unmapped("policy_generation", audit.mapping_policy_generation) .unmapped("mapping_policy_generation", audit.mapping_policy_generation) .unmapped( "authorization_policy_generation", audit.authorization_policy_generation, ) .message(message) .status_detail("transparent_tcp_allowed"); if let Some(destination) = audit.connected_real_destination { builder = builder.unmapped("connected_real_destination", destination.to_string()); } builder.build() } #[cfg(target_os = "linux")] fn original_destination(stream: &TcpStream) -> std::io::Result { use std::os::fd::AsRawFd; let fd = stream.as_raw_fd(); if stream.local_addr()?.is_ipv4() { #[allow(unsafe_code)] unsafe { let mut address: libc::sockaddr_in = std::mem::zeroed(); let mut length = libc::socklen_t::try_from(size_of::()) .expect("sockaddr_in size fits socklen_t"); if libc::getsockopt( fd, libc::SOL_IP, 80, // SO_ORIGINAL_DST std::ptr::addr_of_mut!(address).cast(), std::ptr::addr_of_mut!(length), ) != 0 { return Err(std::io::Error::last_os_error()); } return Ok(SocketAddr::new( IpAddr::V4(std::net::Ipv4Addr::from( address.sin_addr.s_addr.to_ne_bytes(), )), u16::from_be(address.sin_port), )); } } #[allow(unsafe_code)] unsafe { let mut address: libc::sockaddr_in6 = std::mem::zeroed(); let mut length = libc::socklen_t::try_from(size_of::()) .expect("sockaddr_in6 size fits socklen_t"); if libc::getsockopt( fd, libc::SOL_IPV6, 80, // IP6T_SO_ORIGINAL_DST std::ptr::addr_of_mut!(address).cast(), std::ptr::addr_of_mut!(length), ) != 0 { return Err(std::io::Error::last_os_error()); } Ok(SocketAddr::new( IpAddr::V6(std::net::Ipv6Addr::from(address.sin6_addr.s6_addr)), u16::from_be(address.sin6_port), )) } } #[cfg(target_os = "linux")] fn emit_transparent_mapping_denial( workload: SocketAddr, original: SocketAddr, error: MappingLookupError, ) { let detail = match error { MappingLookupError::Missing => "transparent_tcp_mapping_missing", MappingLookupError::Expired => "transparent_tcp_mapping_expired", MappingLookupError::StalePolicy => "transparent_tcp_mapping_stale_policy", MappingLookupError::PortMismatch => "transparent_tcp_port_mismatch", MappingLookupError::EndpointMismatch | MappingLookupError::InvalidMapping | MappingLookupError::LockPoisoned => "transparent_tcp_destination_denied", }; ocsf_emit!( NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_ip(original.ip(), original.port())) .src_endpoint_addr(workload.ip(), workload.port()) .message(format!("Transparent TCP denied: {error}")) .status_detail(detail) .build() ); } #[cfg(target_os = "linux")] fn emit_transparent_policy_denial( decision: &EgressDecision, workload: SocketAddr, host: &str, port: u16, ) { let status_detail = if matches!(decision.action, NetworkAction::Deny { .. }) { "transparent_tcp_identity_denied" } else { "transparent_tcp_destination_denied" }; let binary = decision .binary .as_ref() .map_or_else(|| "-".to_string(), |path| path.display().to_string()); let pid = decision .binary_pid .map_or_else(|| "-".to_string(), |pid| pid.to_string()); ocsf_emit!( NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(host, port)) .src_endpoint_addr(workload.ip(), workload.port()) .actor_process(Process::from_bypass(&binary, &pid, "-")) .firewall_rule("-", "opa") .message(format!("Transparent TCP denied {host}:{port}")) .status_detail(status_detail) .build() ); } const MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS: u32 = 10; #[derive(Debug, PartialEq)] enum AcceptAction { Terminal, Retry { backoff: std::time::Duration, severity: SeverityId, }, } fn build_accept_error_event( local_addr: SocketAddr, err: &std::io::Error, action: &AcceptAction, ) -> openshell_ocsf::OcsfEvent { let (severity, message) = match action { AcceptAction::Terminal => ( SeverityId::High, format!("Proxy accept loop exiting on terminal error: {err}"), ), AcceptAction::Retry { backoff, severity } => ( *severity, format!( "Proxy accept error (retrying in {}ms): {err}", backoff.as_millis() ), ), }; NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .dst_endpoint(Endpoint::from_ip(local_addr.ip(), local_addr.port())) .severity(severity) .status(StatusId::Failure) .message(message) .build() } fn classify_accept_error( err: &std::io::Error, consecutive_resource_errors: &mut u32, consecutive_unknown_errors: &mut u32, ) -> AcceptAction { #[cfg(not(unix))] let _ = (err, &mut *consecutive_resource_errors); #[cfg(unix)] if matches!( err.raw_os_error(), Some(libc::EBADF | libc::EINVAL | libc::ENOTSOCK) ) { return AcceptAction::Terminal; } #[cfg(unix)] if matches!( err.raw_os_error(), Some( libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM | libc::ECONNABORTED | libc::ECONNRESET | libc::EINTR | libc::ENETDOWN | libc::EPROTO | libc::ENOPROTOOPT | libc::EHOSTDOWN | libc::EHOSTUNREACH | libc::EOPNOTSUPP | libc::ENETUNREACH | libc::ENOSR | libc::ESOCKTNOSUPPORT | libc::EPROTONOSUPPORT | libc::ETIMEDOUT ) ) { *consecutive_unknown_errors = 0; #[cfg(unix)] let is_resource_pressure = matches!( err.raw_os_error(), Some(libc::EMFILE | libc::ENFILE | libc::ENOBUFS | libc::ENOMEM | libc::ENOSR) ); #[cfg(not(unix))] let is_resource_pressure = false; if is_resource_pressure { *consecutive_resource_errors = consecutive_resource_errors.saturating_add(1); let backoff_ms = 100u64 .saturating_mul(1u64 << (*consecutive_resource_errors).min(7).saturating_sub(1)) .min(5_000); return AcceptAction::Retry { backoff: std::time::Duration::from_millis(backoff_ms), severity: SeverityId::Medium, }; } *consecutive_resource_errors = 0; return AcceptAction::Retry { backoff: std::time::Duration::from_millis(100), severity: SeverityId::Low, }; } #[cfg(unix)] #[cfg(target_os = "linux")] if matches!(err.raw_os_error(), Some(libc::ENONET)) { *consecutive_unknown_errors = 0; *consecutive_resource_errors = 0; return AcceptAction::Retry { backoff: std::time::Duration::from_millis(100), severity: SeverityId::Low, }; } *consecutive_unknown_errors = consecutive_unknown_errors.saturating_add(1); if *consecutive_unknown_errors >= MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { return AcceptAction::Terminal; } AcceptAction::Retry { backoff: std::time::Duration::from_millis(100), severity: SeverityId::Low, } } fn emit_activity(tx: &Option, denied: bool, deny_group: &'static str) { if let Some(tx) = tx { let _ = try_record_activity(tx, denied, deny_group); } } fn l7_inspection_active(l7_route: Option<&L7RouteSnapshot>) -> bool { l7_route.is_some_and(|route| !route.configs.is_empty()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TunnelProtocol { Tls, Http1, H2cPriorKnowledge, Unsupported, } fn classify_tunnel_protocol(peek: &[u8]) -> TunnelProtocol { if crate::l7::tls::looks_like_tls(peek) { return TunnelProtocol::Tls; } if crate::l7::rest::looks_like_http(peek) { return TunnelProtocol::Http1; } if crate::l7::rest::looks_like_http2_prior_knowledge(peek) { return TunnelProtocol::H2cPriorKnowledge; } TunnelProtocol::Unsupported } fn could_be_tls_prefix(peek: &[u8]) -> bool { matches!(peek, [0x16] | [0x16, 0x03]) } fn could_be_supported_tunnel_protocol_prefix(peek: &[u8]) -> bool { could_be_tls_prefix(peek) || crate::l7::rest::could_be_http_request_prefix(peek) || crate::l7::rest::could_be_http2_prior_knowledge_prefix(peek) } /// Why tunnel payload inspection is mandatory for a connection, in message /// precedence order: an L7-configured endpoint owns the wording even when a /// fail-closed middleware chain also matches. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InspectionRequirement { None, L7Route, RequiredMiddleware, } fn inspection_requirement( should_inspect_l7: bool, middleware_gate: crate::l7::middleware::UninspectableTrafficGate, ) -> InspectionRequirement { if should_inspect_l7 { InspectionRequirement::L7Route } else if middleware_gate == crate::l7::middleware::UninspectableTrafficGate::Deny { InspectionRequirement::RequiredMiddleware } else { InspectionRequirement::None } } fn unsupported_l7_tunnel_protocol_detail( tunnel_protocol: TunnelProtocol, requirement: InspectionRequirement, ) -> Option<&'static str> { match (tunnel_protocol, requirement) { (_, InspectionRequirement::None) | (TunnelProtocol::Tls | TunnelProtocol::Http1, _) => None, (TunnelProtocol::H2cPriorKnowledge, InspectionRequirement::L7Route) => { Some("HTTP/2 prior-knowledge (h2c) is not supported for L7-inspected endpoints") } (TunnelProtocol::H2cPriorKnowledge, InspectionRequirement::RequiredMiddleware) => { Some("HTTP/2 prior-knowledge (h2c) cannot be inspected by required middleware") } (TunnelProtocol::Unsupported, InspectionRequirement::L7Route) => { Some("Unsupported tunnel protocol for L7-inspected endpoint") } (TunnelProtocol::Unsupported, InspectionRequirement::RequiredMiddleware) => { Some("Unsupported tunnel protocol cannot be inspected by required middleware") } } } /// Gate for traffic that would bypass L7 inspection entirely: query the /// middleware chain matching this destination and process identity, and /// decide whether raw relay is allowed. Uninspectable traffic is denied when /// any matching entry is `fail_closed`; an all-`fail_open` chain passes it /// through with a bypass detection finding. fn middleware_uninspectable_gate( opa_engine: &OpaEngine, ctx: &crate::l7::relay::L7EvalContext, ) -> Result { let input = crate::l7::middleware::middleware_network_input(ctx); let (chain, _generation) = opa_engine.query_middleware_chain_with_generation(&input)?; Ok(crate::l7::middleware::uninspectable_traffic_gate(&chain)) } async fn peek_tunnel_protocol(client: &mut C) -> Result> where C: tokio::io::AsyncBufRead + Unpin, { let deadline = tokio::time::Instant::now() + TUNNEL_PROTOCOL_PEEK_TIMEOUT; loop { let available = client.fill_buf().await.into_diagnostic()?; if available.is_empty() { return Ok(None); } let n = available.len().min(TUNNEL_PROTOCOL_PEEK_BYTES); let peek = &available[..n]; let protocol = classify_tunnel_protocol(peek); if protocol != TunnelProtocol::Unsupported || !could_be_supported_tunnel_protocol_prefix(peek) || n == TUNNEL_PROTOCOL_PEEK_BYTES || tokio::time::Instant::now() >= deadline { return Ok(Some(protocol)); } tokio::time::sleep(TUNNEL_PROTOCOL_PEEK_POLL).await; } } fn emit_connect_activity_if_l4_only( tx: &Option, l7_route: Option<&L7RouteSnapshot>, ) { if !l7_inspection_active(l7_route) { emit_activity(tx, false, "unknown"); } } fn emit_activity_simple(tx: Option<&ActivitySender>, denied: bool, deny_group: &'static str) { if let Some(tx) = tx { let _ = try_record_activity(tx, denied, deny_group); } } fn emit_forward_success_activity(tx: Option<&ActivitySender>, l7_activity_pending: bool) { emit_activity_simple( tx, false, if l7_activity_pending { "l7_policy" } else { "unknown" }, ); } /// Body-aware policy state carried from forward L7 admission to middleware /// execution. The selected route and its captured policy engine stay paired so /// middleware cannot be invoked with only half of the re-evaluation context. struct ForwardL7Reevaluation<'a> { config: &'a crate::l7::L7EndpointConfig, engine: &'a crate::opa::TunnelPolicyEngine, request_info: &'a crate::l7::L7RequestInfo, } /// Executes the middleware portion of the forward HTTP pipeline with an /// explicit transformed-body policy. struct ForwardMiddlewarePipeline<'a> { ctx: &'a crate::l7::relay::L7EvalContext, scheme: &'a str, exchange: &'a crate::l7::middleware::HttpMiddlewareExchange, l7_reevaluation: Option>, } impl ForwardMiddlewarePipeline<'_> { #[allow( clippy::option_if_let_else, reason = "the Some branch must keep a borrowed evaluator alive across the async call" )] async fn apply( &self, request: crate::l7::provider::L7Request, client: &mut C, ) -> Result where C: TokioAsyncRead + TokioAsyncWrite + Unpin + Send, { let validate; let transformed_body_policy = match &self.l7_reevaluation { Some(l7) => { validate = crate::l7::relay::transformed_body_validator( l7.config, l7.engine, self.ctx, l7.request_info, ); openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate) } None => openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, }; self.exchange .apply_request( request, client, self.ctx, self.scheme, transformed_body_policy, ) .await } } /// Emit a denial event to the aggregator channel (if configured). /// Used by `handle_tcp_connection` which owns `Option`. fn emit_denial( tx: &Option>, host: &str, port: u16, binary: &str, decision: &EgressDecision, reason: &str, stage: &str, ) { if let Some(tx) = tx { let _ = tx.send(DenialEvent { host: host.to_string(), port, binary: binary.to_string(), ancestors: decision .ancestors .iter() .map(|p| p.display().to_string()) .collect(), deny_reason: reason.to_string(), denial_stage: stage.to_string(), l7_method: None, l7_path: None, }); } } /// Emit a denial event from a borrowed sender reference. /// Used by `handle_forward_proxy` which borrows `Option<&Sender>`. fn emit_denial_simple( tx: Option<&mpsc::UnboundedSender>, host: &str, port: u16, binary: &str, decision: &EgressDecision, reason: &str, stage: &str, ) { if let Some(tx) = tx { let _ = tx.send(DenialEvent { host: host.to_string(), port, binary: binary.to_string(), ancestors: decision .ancestors .iter() .map(|p| p.display().to_string()) .collect(), deny_reason: reason.to_string(), denial_stage: stage.to_string(), l7_method: None, l7_path: None, }); } } #[allow(clippy::too_many_arguments)] fn build_connect_allow_ocsf_event( peer_addr: SocketAddr, host: &str, port: u16, binary: &str, pid: &str, ancestors: &str, cmdline: &str, policy: &str, l7_inspection: bool, ) -> openshell_ocsf::OcsfEvent { let connect_msg = if l7_inspection { "CONNECT_L7" } else { "CONNECT" }; NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Allowed) .disposition(DispositionId::Allowed) .severity(SeverityId::Informational) .status(StatusId::Success) .dst_endpoint(Endpoint::from_domain(host, port)) .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) .actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline)) .firewall_rule(policy, "opa") .message(format!("{connect_msg} allowed {host}:{port}")) .build() } #[allow(clippy::too_many_arguments)] fn build_forward_allow_ocsf_event( peer_addr: SocketAddr, method: &str, host: &str, port: u16, path: &str, binary: &str, pid: &str, ancestors: &str, cmdline: &str, policy: &str, ) -> openshell_ocsf::OcsfEvent { HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::for_http_method(method)) .action(ActionId::Allowed) .disposition(DispositionId::Allowed) .severity(SeverityId::Informational) .status(StatusId::Success) .http_request(HttpRequest::new( method, OcsfUrl::new("http", host, path, port), )) .dst_endpoint(Endpoint::from_domain(host, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) .actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline)) .firewall_rule(policy, "opa") .message(format!("FORWARD allowed {method} {host}:{port}{path}")) .build() } fn build_forward_parse_error_ocsf_event( peer_addr: Option, method: &str, path: &str, ) -> openshell_ocsf::OcsfEvent { let builder = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::for_http_method(method)) .http_request(HttpRequest { http_method: method.parse().expect("HTTP method parsing is infallible"), url: None, }) .http_response(HttpResponse { code: StatusCode::BAD_REQUEST.as_u16(), }) .severity(SeverityId::Low) .status(StatusId::Failure) .message(format!("FORWARD parse error for {path}")); match peer_addr { Some(peer_addr) => builder .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) .build(), None => builder.build(), } } /// Build the rejection event for an absolute-form request whose scheme is not /// supported by the forward proxy. The request URL is omitted because paths may /// contain credentials; the method and generated response provide the HTTP /// context required by OCSF 1.8. fn build_forward_unsupported_scheme_ocsf_event( method: &str, scheme: &str, host: &str, port: u16, ) -> openshell_ocsf::OcsfEvent { HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::for_http_method(method)) .http_request(HttpRequest { http_method: method.parse().expect("HTTP method parsing is infallible"), url: None, }) .http_response(HttpResponse { code: StatusCode::BAD_REQUEST.as_u16(), }) .action(ActionId::Denied) .disposition(DispositionId::Rejected) .severity(SeverityId::Informational) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(host, port)) .message(format!( "FORWARD rejected: unsupported scheme {scheme} for {host}:{port}" )) .build() } #[allow(clippy::too_many_arguments)] fn build_forward_l7_parse_rejection_ocsf_event( peer_addr: SocketAddr, method: &str, host: &str, port: u16, path: &str, binary: &str, pid: &str, ancestors: &str, cmdline: &str, policy: &str, detail: &str, ) -> openshell_ocsf::OcsfEvent { HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::for_http_method(method)) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) .http_request(HttpRequest::new( method, OcsfUrl::new("http", host, path, port), )) .dst_endpoint(Endpoint::from_domain(host, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) .actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline)) .firewall_rule(policy, "l7") .message(format!( "FORWARD_L7 denied non-canonical request-target for {method} {host}:{port}{path}" )) .status_detail(detail) .build() } #[allow(clippy::too_many_arguments)] fn build_forward_policy_deny_ocsf_event( peer_addr: SocketAddr, method: &str, host: &str, port: u16, path: &str, binary: &str, pid: &str, ancestors: &str, cmdline: &str, reason: &str, ) -> openshell_ocsf::OcsfEvent { HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) .http_request(HttpRequest::new( method, OcsfUrl::new("http", host, path, port), )) .dst_endpoint(Endpoint::from_domain(host, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) .actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline)) .firewall_rule("-", "opa") .message(format!("FORWARD denied {method} {host}:{port}{path}")) .status_detail(reason) .build() } fn destination_denial_detail(kind: DestinationDenialKind) -> &'static str { match kind { DestinationDenialKind::Resolution => "destination resolution failed", DestinationDenialKind::TrustedGateway => "trusted-gateway check failed", DestinationDenialKind::InvalidAllowedIps => "invalid allowed_ips in policy", DestinationDenialKind::AllowedIps => "allowed_ips check failed", DestinationDenialKind::DeclaredEndpoint => "declared endpoint check failed", DestinationDenialKind::InternalAddress => "internal address", } } fn endpoint_result_for_destination_failure(kind: DestinationDenialKind) -> EndpointResult { if kind == DestinationDenialKind::Resolution { EndpointResult::TransportFailed } else { EndpointResult::PolicyDenied } } /// Separates resolver failures from address-policy rejections while preserving /// the existing diagnostic text used by proxy responses and tests. #[derive(Debug)] pub(crate) enum DestinationCheckError { /// Name resolution did not produce an address set to authorize. Resolution(String), /// The resolved address set violated destination policy. Denied(String), } impl DestinationCheckError { #[cfg(test)] fn contains(&self, needle: &str) -> bool { self.to_string().contains(needle) } } impl std::fmt::Display for DestinationCheckError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Resolution(reason) | Self::Denied(reason) => formatter.write_str(reason), } } } #[allow(clippy::too_many_arguments)] fn build_connect_destination_deny_ocsf_event( denial: &DestinationDenial, peer_addr: SocketAddr, host: &str, port: u16, binary: &str, pid: &str, ancestors: &str, cmdline: &str, ) -> openshell_ocsf::OcsfEvent { let detail = destination_denial_detail(denial.kind); let message = if denial.kind == DestinationDenialKind::InternalAddress { format!("CONNECT blocked: internal address {host}:{port}") } else { format!("CONNECT blocked: {detail} for {host}:{port}") }; NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(host, port)) .src_endpoint_addr(peer_addr.ip(), peer_addr.port()) .actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline)) .firewall_rule("-", "ssrf") .message(message) .status_detail(&denial.reason) .build() } #[allow(clippy::too_many_arguments)] fn build_forward_destination_deny_ocsf_event( denial: &DestinationDenial, peer_addr: SocketAddr, method: &str, host: &str, port: u16, path: &str, binary: &str, pid: &str, ancestors: &str, cmdline: &str, policy: &str, ) -> openshell_ocsf::OcsfEvent { let detail = destination_denial_detail(denial.kind); let log_detail = if denial.kind == DestinationDenialKind::InternalAddress { "internal IP without allowed_ips" } else { detail }; HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::for_http_method(method)) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) .http_request(HttpRequest::new( method, OcsfUrl::new("http", host, path, port), )) .dst_endpoint(Endpoint::from_domain(host, port)) .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) .actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline)) .firewall_rule(policy, "ssrf") .message(format!("FORWARD blocked: {log_detail} for {host}:{port}")) .status_detail(&denial.reason) .build() } #[allow(clippy::too_many_arguments)] async fn deny_connect_destination( client: &mut C, denial: &DestinationDenial, peer_addr: SocketAddr, host: &str, port: u16, binary: &str, pid: &str, ancestors: &str, cmdline: &str, decision: &EgressDecision, denial_tx: &Option>, activity_tx: &Option, ) -> Result<()> where C: TokioAsyncWrite + Unpin, { let detail = destination_denial_detail(denial.kind); ocsf_emit!(build_connect_destination_deny_ocsf_event( denial, peer_addr, host, port, binary, pid, ancestors, cmdline, )); emit_denial( denial_tx, host, port, binary, decision, &denial.reason, "ssrf", ); // Preserve the current activity contract. The declared-endpoint branch // historically emits the denial without a separate SSRF activity count. if denial.kind != DestinationDenialKind::DeclaredEndpoint { emit_activity(activity_tx, true, "ssrf"); } respond( client, &build_json_error_response( 403, "Forbidden", "ssrf_denied", &format!("CONNECT {host}:{port} blocked: {detail}"), ), ) .await } #[allow(clippy::too_many_arguments)] async fn deny_forward_destination( client: &mut C, denial: &DestinationDenial, peer_addr: SocketAddr, method: &str, host: &str, port: u16, path: &str, binary: &str, pid: &str, ancestors: &str, cmdline: &str, policy: &str, decision: &EgressDecision, denial_tx: Option<&mpsc::UnboundedSender>, activity_tx: Option<&ActivitySender>, ) -> Result<()> where C: TokioAsyncWrite + Unpin, { let detail = destination_denial_detail(denial.kind); ocsf_emit!(build_forward_destination_deny_ocsf_event( denial, peer_addr, method, host, port, path, binary, pid, ancestors, cmdline, policy, )); emit_denial_simple( denial_tx, host, port, binary, decision, &denial.reason, "ssrf", ); // Preserve the current activity contract. The declared-endpoint branch // historically emits the denial without a separate SSRF activity count. if denial.kind != DestinationDenialKind::DeclaredEndpoint { emit_activity_simple(activity_tx, true, "ssrf"); } respond( client, &build_json_error_response( 403, "Forbidden", "ssrf_denied", &format!("{method} {host}:{port} blocked: {detail}"), ), ) .await } // Many distinct, non-related context parameters are required for a CONNECT // dispatch; bundling them into a struct would just shift the noise into call // sites. #[cfg(test)] #[allow(clippy::too_many_arguments)] async fn handle_tcp_connection( client: TcpStream, opa_engine: Arc, identity_cache: Arc, entrypoint_pid: Arc, tls_state: Option>, policy_local_ctx: Option>, agent_proposals: openshell_core::proposals::AgentProposals, backend_host_gateway: Arc>, trusted_host_gateway: Arc>, upstream_proxy: Arc>, provider_credentials: Option, secret_resolver: Option>, dynamic_credentials: Option< Arc< std::sync::RwLock< std::collections::HashMap, >, >, >, denial_tx: Option>, activity_tx: Option, endpoint_observation_tx: Option, ) -> Result<()> { let socket_addrs = client.peer_addr().ok().zip(client.local_addr().ok()); let stream: BoundaryDuplexStream = Box::new(client); Box::pin(handle_mediated_connection( tokio::io::BufReader::new(stream), None, socket_addrs, None, None, opa_engine, identity_cache, entrypoint_pid, tls_state, policy_local_ctx, agent_proposals, backend_host_gateway, trusted_host_gateway, upstream_proxy, provider_credentials, secret_resolver, dynamic_credentials, denial_tx, activity_tx, endpoint_observation_tx, )) .await } /// Adapt a transparent application stream to the existing CONNECT pipeline. /// The synthetic CONNECT request is supervisor-owned and its successful 200 /// response is consumed before bytes are returned to the workload. fn virtual_connect_stream( workload: BoundaryDuplexStream, authority: String, ) -> BoundaryDuplexStream { let (handler, bridge) = tokio::io::duplex(64 * 1024); let (mut bridge_read, mut bridge_write) = tokio::io::split(bridge); let (mut workload_read, mut workload_write) = tokio::io::split(workload); tokio::spawn(async move { let request = format!("CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n\r\n"); if bridge_write.write_all(request.as_bytes()).await.is_ok() { let _ = tokio::io::copy(&mut workload_read, &mut bridge_write).await; } let _ = bridge_write.shutdown().await; }); tokio::spawn(async move { let mut header = Vec::with_capacity(256); let mut byte = [0_u8; 1]; while header.len() < MAX_HEADER_BYTES { match bridge_read.read(&mut byte).await { Ok(0) | Err(_) => return, Ok(_) => header.push(byte[0]), } if header.ends_with(b"\r\n\r\n") { break; } } if !header.starts_with(b"HTTP/1.1 200 ") && !header.starts_with(b"HTTP/1.0 200 ") { let _ = workload_write.shutdown().await; return; } let _ = tokio::io::copy(&mut bridge_read, &mut workload_write).await; let _ = workload_write.shutdown().await; }); Box::new(handler) } #[allow(clippy::too_many_arguments)] async fn handle_mediated_connection( mut client: ProxyClient, supplied_identity: Option>, socket_addrs: Option<(SocketAddr, SocketAddr)>, transparent_open: Option, policy_dns_store: Option>, opa_engine: Arc, identity_cache: Arc, entrypoint_pid: Arc, tls_state: Option>, policy_local_ctx: Option>, agent_proposals: openshell_core::proposals::AgentProposals, backend_host_gateway: Arc>, trusted_host_gateway: Arc>, upstream_proxy: Arc>, provider_credentials: Option, secret_resolver: Option>, dynamic_credentials: Option< Arc< std::sync::RwLock< std::collections::HashMap, >, >, >, denial_tx: Option>, activity_tx: Option, endpoint_observation_tx: Option, ) -> Result<()> { // Bind observations to the policy/provider inventory active when this // connection was accepted, even if configuration changes while it runs. let endpoint_observation_context = endpoint_observation_tx .as_ref() .and_then(EndpointObservationSender::capture); let (mut preauthorized_decision, prevalidated_connector) = if let Some(transparent) = transparent_open { let destination = transparent.destination; let host = transparent_destination_host(destination, policy_dns_store.as_ref(), &opa_engine)?; let (decision, connector) = transparent .authorization .map_or((None, None), |(decision, connector)| { (Some(decision), Some(connector)) }); let authority = format!("{host}:{}", destination.port()); client = tokio::io::BufReader::new(virtual_connect_stream(client.into_inner(), authority)); (decision, connector) } else { (None, None) }; let mut buf = vec![0u8; MAX_HEADER_BYTES]; let mut used = 0usize; loop { if used == buf.len() { respond( &mut client, b"HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n", ) .await?; return Ok(()); } let n = client.read(&mut buf[used..]).await.into_diagnostic()?; if n == 0 { return Ok(()); } used += n; if buf[..used].windows(4).any(|win| win == b"\r\n\r\n") { break; } } let header_end = buf[..used] .windows(4) .position(|window| window == b"\r\n\r\n") .expect("header terminator was observed") + 4; if crate::l7::rest::validate_http_request_header_block(&buf[..header_end]).is_err() { respond(&mut client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); } let request = std::str::from_utf8(&buf[..header_end]).expect("validated HTTP request headers are UTF-8"); if crate::l7::rest::parse_body_length(request).is_err() { respond(&mut client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); } let mut lines = request.split("\r\n"); let request_line = lines.next().unwrap_or(""); let mut parts = request_line.split_whitespace(); let method = parts.next().unwrap_or(""); let target = parts.next().unwrap_or(""); if method != "CONNECT" { return Box::pin(handle_forward_proxy( method, target, &buf[..], used, &mut client, supplied_identity.as_ref(), socket_addrs, opa_engine, identity_cache, entrypoint_pid, policy_local_ctx, agent_proposals, backend_host_gateway, trusted_host_gateway, provider_credentials, secret_resolver, dynamic_credentials, denial_tx.as_ref(), activity_tx.as_ref(), endpoint_observation_tx, )) .await; } let (raw_host, port) = parse_target(target)?; let host = normalize_host(&raw_host); let (host_lc, raw_host_lc) = (host.to_ascii_lowercase(), raw_host.to_ascii_lowercase()); let workload_addr = socket_addrs.map_or_else( || SocketAddr::from(([0, 0, 0, 0], 0)), |(workload, _)| workload, ); // Evaluate OPA policy with process-identity binding. // Wrapped in spawn_blocking because identity resolution does heavy sync I/O: // /proc scanning + SHA256 hashing of binaries (e.g. node at 124MB). let intent = EgressIntent::connect(host_lc.clone(), port); let mut decision = if let Some(decision) = preauthorized_decision.take() { decision } else if let Some(identity) = supplied_identity.as_ref() { authorize_supplied_identity(&opa_engine, intent, identity) } else if !opa_engine.binary_identity_required() { evaluate_endpoint_only_opa(&opa_engine, intent) } else { let (workload_addr, proxy_addr) = socket_addrs.ok_or_else(|| { miette::miette!("legacy proxy connection is missing socket addresses") })?; let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); let opa_clone = opa_engine.clone(); let cache_clone = identity_cache.clone(); let pid_clone = entrypoint_pid.clone(); tokio::task::spawn_blocking(move || { authorize_egress_intent(connection, &opa_clone, &cache_clone, &pid_clone, intent) }) .await .map_err(|e| miette::miette!("identity resolution task panicked: {e}"))? }; debug!( transport = ?decision.intent.transport, identity = ?decision.identity, "Authorized explicit proxy egress intent" ); // Extract action string and matched policy for logging let (matched_policy, deny_reason) = match &decision.action { NetworkAction::Allow { matched_policy } => (matched_policy.clone(), String::new()), NetworkAction::Deny { reason } => (None, reason.clone()), }; // Build log context fields (shared by deny log below and deferred allow log after L7 check) let binary_str = decision .binary .as_ref() .map_or_else(|| "-".to_string(), |p| p.display().to_string()); let pid_str = decision .binary_pid .map_or_else(|| "-".to_string(), |p| p.to_string()); let ancestors_str = if decision.ancestors.is_empty() { "-".to_string() } else { decision .ancestors .iter() .map(|p| p.display().to_string()) .collect::>() .join(" -> ") }; let cmdline_str = if decision.cmdline_paths.is_empty() { "-".to_string() } else { decision .cmdline_paths .iter() .map(|p| p.display().to_string()) .collect::>() .join(", ") }; let policy_str = matched_policy.as_deref().unwrap_or("-"); // Log denied connections immediately — they never reach L7. // Allowed connections are logged after the L7 config check (below) // so we can distinguish CONNECT (L4-only) from CONNECT_L7 (L7 follows). if matches!(decision.action, NetworkAction::Deny { .. }) { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint_addr(workload_addr.ip(), workload_addr.port()) .actor_process( Process::from_bypass(&binary_str, &pid_str, &ancestors_str) .with_cmd_line(&cmdline_str), ) .firewall_rule("-", "opa") .message(format!("CONNECT denied {host_lc}:{port}")) .status_detail(&deny_reason) .build(); ocsf_emit!(event); emit_denial( &denial_tx, &host_lc, port, &binary_str, &decision, &deny_reason, "connect", ); emit_activity(&activity_tx, true, "connect_policy"); respond( &mut client, &build_json_error_response_with_reason( 403, "Forbidden", "policy_denied", &format!("CONNECT {host_lc}:{port} not permitted by policy"), &deny_reason, ), ) .await?; return Ok(()); } let connect_generation_guard = match relay::pin_policy_generation(&opa_engine, decision.policy_generation) { Ok(guard) => guard, Err(error) => { reject_stale_connect_policy( &mut client, &host_lc, port, activity_tx.as_ref(), error, ) .await?; return Ok(()); } }; // Resolve the route's TLS treatment up front. `query_tls_mode` reads only // the policy decision + host/port (no peeked bytes), so it is valid before // the `200`. The fail-closed refusal that consumes it runs after the SSRF/ // allowed_ips validation below — so an internal-address CONNECT still gets // the SSRF 403 and telemetry in degraded state — but before the upstream // connect and before `200 Connection Established`. hydrate_tls_mode(&mut decision); let effective_tls_skip = decision.endpoint.tls_mode == crate::l7::TlsMode::Skip; let credential_guard = query_endpoint_credential_guard(&opa_engine, &decision, &host_lc, port)?; // Route materialization is safe before destination dialing and lets an // unambiguous MCP authority report local fail-closed outcomes that occur // before the HTTP request path becomes available. hydrate_l7_route(&mut decision); let connect_endpoint_observer = begin_unambiguous_endpoint_observation( decision.endpoint.l7_route.as_ref(), endpoint_observation_tx.as_ref(), endpoint_observation_context.as_ref(), &connect_generation_guard, ); let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); if prevalidated_connector.is_none() { match hydrate_destination_plan(&mut decision, *backend_host_gateway, *trusted_host_gateway) { Ok(()) => {} Err(denial) => { if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(endpoint_result_for_destination_failure(denial.kind)); } deny_connect_destination( &mut client, &denial, workload_addr, &host_lc, port, &binary_str, &pid_str, &ancestors_str, &cmdline_str, &decision, &denial_tx, &activity_tx, ) .await?; return Ok(()); } } } let destination_plan = decision .endpoint .destination .as_ref() .expect("destination plan hydrated"); // Defense-in-depth: resolve DNS and reject connections to internal IPs. let dns_connect_start = std::time::Instant::now(); let connector = if let Some(connector) = prevalidated_connector { connector } else { match validate_destination(DestinationRequest { host: &raw_host, port, sandbox_entrypoint_pid, plan: destination_plan, }) .await { Ok(connector) => connector, Err(denial) => { if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(endpoint_result_for_destination_failure(denial.kind)); } deny_connect_destination( &mut client, &denial, workload_addr, &host_lc, port, &binary_str, &pid_str, &ancestors_str, &cmdline_str, &decision, &denial_tx, &activity_tx, ) .await?; return Ok(()); } } }; // Fail closed AFTER SSRF/allowed_ips validation (an internal-address CONNECT // has already returned the SSRF 403 above) but BEFORE connecting upstream // and BEFORE `200 Connection Established`. A terminating route with no TLS // termination state cannot rewrite credential placeholders; the 503 must be // the first bytes on the socket rather than a post-200 in-tunnel write the // client would misread as a TLS error. No "allowed CONNECT" event is emitted // for this path — that log lives after the `200` below. if refuse_connect_when_tls_unavailable(&mut client, tls_state.is_some(), effective_tls_skip) .await? { if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::TlsFailed); } let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::High) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint_addr(workload_addr.ip(), workload_addr.port()) .actor_process( Process::from_bypass(&binary_str, &pid_str, &ancestors_str) .with_cmd_line(&cmdline_str), ) .firewall_rule(policy_str, "tls") .message(format!( "CONNECT refused for {host_lc}:{port}: {TLS_TERMINATION_UNAVAILABLE_DETAIL}" )) .status_detail(TLS_TERMINATION_UNAVAILABLE_DETAIL) .build(); ocsf_emit!(event); emit_activity_simple(activity_tx.as_ref(), true, "tls_termination_unavailable"); emit_denial( &denial_tx, &host_lc, port, &binary_str, &decision, TLS_TERMINATION_UNAVAILABLE_DETAIL, "connect-tls-termination-unavailable", ); return Ok(()); } if credential_guard.blocks_connect() { const DETAIL: &str = "credentialed endpoint requires L7 inspection; raw tunnel is not explicitly allowed"; if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::PolicyDenied); } let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::High) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint_addr(workload_addr.ip(), workload_addr.port()) .actor_process( Process::from_bypass(&binary_str, &pid_str, &ancestors_str) .with_cmd_line(&cmdline_str), ) .firewall_rule(policy_str, "credentials") .message(format!( "CONNECT refused for {host_lc}:{port}: uninspected credential traffic" )) .status_detail(DETAIL) .build(); ocsf_emit!(event); crate::l7::emit_uninspected_credential_finding( &host_lc, policy_str, if effective_tls_skip { "tls-skip" } else { "l4" }, ); emit_activity_simple(activity_tx.as_ref(), true, "uninspected_credentials"); emit_denial( &denial_tx, &host_lc, port, &binary_str, &decision, DETAIL, "connect-uninspected-credentials", ); respond( &mut client, &build_json_error_response(403, "Forbidden", "uninspected_credentials", DETAIL), ) .await?; return Ok(()); } // CONNECT must use one policy generation from authorization through route // materialization and relay startup. let l7_route = decision.endpoint.l7_route.as_ref(); if let Err(error) = relay::validate_route_generation(l7_route, connect_generation_guard.captured_generation()) { reject_stale_connect_policy(&mut client, &host_lc, port, activity_tx.as_ref(), error) .await?; return Ok(()); } let upstream_result = tokio::select! { result = dial_upstream(&upstream_proxy, &host_lc, &raw_host_lc, port, connector.addrs()) => Some(result), () = connect_generation_guard.wait_until_stale() => None, }; let Some(upstream_result) = upstream_result else { reject_stale_connect_policy( &mut client, &host_lc, port, activity_tx.as_ref(), miette::miette!( "policy changed while CONNECT was dialing upstream \ [captured_generation:{} current_generation:{}]", connect_generation_guard.captured_generation(), connect_generation_guard.current_generation(), ), ) .await?; return Ok(()); }; let mut upstream = match upstream_result { Ok(upstream) => upstream, Err(error) => { if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::TransportFailed); } return Err(error).into_diagnostic(); } }; if let Err(error) = connect_generation_guard.ensure_current() { reject_stale_connect_policy(&mut client, &host_lc, port, activity_tx.as_ref(), error) .await?; return Ok(()); } debug!( "handle_tcp_connection dns_resolve_and_tcp_connect: {}ms host={host_lc}", dns_connect_start.elapsed().as_millis() ); respond(&mut client, b"HTTP/1.1 200 Connection Established\r\n\r\n").await?; let should_inspect_l7 = l7_inspection_active(l7_route); // Log the allowed CONNECT — use CONNECT_L7 when L7 inspection follows, // so log consumers can distinguish L4-only decisions from tunnel lifecycle events. ocsf_emit!(build_connect_allow_ocsf_event( workload_addr, &host_lc, port, &binary_str, &pid_str, &ancestors_str, &cmdline_str, policy_str, should_inspect_l7, )); emit_connect_activity_if_l4_only(&activity_tx, l7_route); // `effective_tls_skip` was resolved before the `200` above (the fail-closed // gate needs it) and drives the raw-tunnel branch below. // Build request-processing context shared by CONNECT and forward HTTP. let workspace = policy_local_ctx .as_ref() .map(|ctx| ctx.workspace()) .unwrap_or_default(); let mut ctx = relay::http_context( &decision, provider_credentials, secret_resolver.clone(), dynamic_credentials.clone(), agent_proposals, workspace, relay::RelaySignals { activity: activity_tx.clone(), endpoint_observation: endpoint_observation_tx, }, ); if effective_tls_skip { // Policy validation rejects fail-closed middleware overlapping // `tls: skip` endpoints; this runtime gate is defense in depth. match middleware_uninspectable_gate(&opa_engine, &ctx)? { crate::l7::middleware::UninspectableTrafficGate::Deny => { crate::l7::middleware::emit_middleware_uninspectable(&ctx, "tls-skip tunnel", true); respond( &mut client, &build_json_error_response( 403, "Forbidden", "middleware_required", "tls: skip tunnel cannot be inspected by required middleware", ), ) .await?; return Ok(()); } crate::l7::middleware::UninspectableTrafficGate::BypassWithFinding => { crate::l7::middleware::emit_middleware_uninspectable( &ctx, "tls-skip tunnel", false, ); } crate::l7::middleware::UninspectableTrafficGate::Unrestricted => {} } // tls: skip — raw tunnel, no termination, no credential injection. debug!( host = %host_lc, port = port, "tls: skip — bypassing TLS auto-detection, raw tunnel" ); let Some(generation_guard) = relay::prepare_raw_relay(l7_route, &opa_engine, &decision) else { return Ok(()); }; relay::relay_tcp(&mut client, &mut upstream, &generation_guard, &ctx).await?; return Ok(()); } // Auto-detect the tunnel payload. L7-configured endpoints must only // enter relays that can enforce their configured protocol; unsupported // bytes fail closed below instead of falling through to raw relay. let Some(tunnel_protocol) = peek_tunnel_protocol(&mut client).await? else { return Ok(()); }; if tunnel_protocol == TunnelProtocol::Tls { // TLS detected — terminate unconditionally. if let Some(ref tls) = tls_state { ctx.request_default_port = Some(443); // Complete the client handshake before observing the upstream // connection. A malformed client handshake provides no evidence // of an endpoint network failure. let mut tls_client = match crate::l7::tls::tls_terminate_client(client, tls, &host_lc).await { Ok(client) => client, Err(error) => { debug!(host = %host_lc, port, "client TLS handshake failed"); return Err(error); } }; let mut tls_upstream = match crate::l7::tls::tls_connect_upstream( upstream, &host_lc, tls.upstream_config(), ) .await { Ok(upstream) => upstream, Err(error) => { if let Some(observer) = connect_endpoint_observer.as_ref() { observer.observe(EndpointResult::TlsFailed); } let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .message("Upstream TLS establishment failed") .build(); ocsf_emit!(event); return Err(error); } }; let tls_result = async { let Some(relay_context) = relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx) else { return Ok(()); }; relay::relay_http_stream(&mut tls_client, &mut tls_upstream, relay_context).await }; if let Err(e) = Box::pin(tls_result).await { if is_benign_relay_error(&e) { debug!( host = %host_lc, port = port, error = %e, "TLS connection closed" ); } else { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .message(format!("TLS relay error: {e}")) .build(); ocsf_emit!(event); } } } else { // Defense in depth; unreachable in normal operation. The pre-200 // fail-closed gate already refuses terminating routes when no TLS // termination state exists, and `tls: skip` routes raw-tunnel // before this peek. Reaching here means a future refactor bypassed // that gate. The `200 Connection Established` was already sent, so // the tunnel is live: any HTTP bytes now would be decoded as a TLS // protocol error, so we fail closed by DROPPING the connection // rather than raw-tunneling (which would forward the client's TLS // stream upstream and leak any `openshell:resolve:env:*` // placeholder verbatim). const DETAIL: &str = "TLS termination unavailable after tunnel establishment; \ closing connection - credential rewrite would be bypassed"; let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::High) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint_addr(workload_addr.ip(), workload_addr.port()) .actor_process( Process::from_bypass(&binary_str, &pid_str, &ancestors_str) .with_cmd_line(&cmdline_str), ) .firewall_rule(policy_str, "tls") .message(format!("CONNECT refused for {host_lc}:{port}: {DETAIL}")) .status_detail(DETAIL) .build(); ocsf_emit!(event); emit_activity_simple(activity_tx.as_ref(), true, "tls_termination_unavailable"); emit_denial( &denial_tx, &host_lc, port, &binary_str, &decision, DETAIL, "connect-tls-termination-unavailable", ); // No HTTP response: the tunnel is already established, so writing // bytes here would corrupt the client's TLS handshake. Drop instead. return Ok(()); } } else if tunnel_protocol == TunnelProtocol::Http1 { // Plaintext HTTP detected. ctx.request_default_port = Some(80); let is_l7_relay = l7_route.is_some_and(|route| !route.configs.is_empty()); let Some(relay_context) = relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx) else { return Ok(()); }; if let Err(e) = relay::relay_http_stream(&mut client, &mut upstream, relay_context).await { if is_benign_relay_error(&e) { if is_l7_relay { debug!(host = %host_lc, port = port, error = %e, "L7 connection closed"); } else { debug!(host = %host_lc, port = port, error = %e, "HTTP relay closed"); } } else { let message = if is_l7_relay { format!("L7 relay error: {e}") } else { format!("HTTP relay error: {e}") }; let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .message(message) .build(); ocsf_emit!(event); } } } else { let middleware_gate = middleware_uninspectable_gate(&opa_engine, &ctx)?; let requirement = inspection_requirement(should_inspect_l7, middleware_gate); if let Some(protocol_detail) = unsupported_l7_tunnel_protocol_detail(tunnel_protocol, requirement) { if requirement == InspectionRequirement::RequiredMiddleware { crate::l7::middleware::emit_middleware_uninspectable(&ctx, protocol_detail, true); } let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint_addr(workload_addr.ip(), workload_addr.port()) .actor_process( Process::from_bypass(&binary_str, &pid_str, &ancestors_str) .with_cmd_line(&cmdline_str), ) .firewall_rule(policy_str, "l7") .message(format!( "CONNECT_L7 blocked unsupported tunnel protocol for {host_lc}:{port}" )) .status_detail(protocol_detail) .build(); ocsf_emit!(event); emit_activity_simple(activity_tx.as_ref(), true, "l7_parse_rejection"); emit_denial( &denial_tx, &host_lc, port, &binary_str, &decision, protocol_detail, "connect-l7-parse-rejection", ); respond( &mut client, &build_json_error_response( 403, "Forbidden", "unsupported_l7_protocol", protocol_detail, ), ) .await?; return Ok(()); } if middleware_gate == crate::l7::middleware::UninspectableTrafficGate::BypassWithFinding { crate::l7::middleware::emit_middleware_uninspectable(&ctx, "non-http tcp", false); } // Neither TLS nor HTTP — raw binary relay. debug!( host = %host_lc, port = port, "Non-TLS non-HTTP traffic detected, raw tunnel" ); let Some(generation_guard) = relay::prepare_raw_relay(l7_route, &opa_engine, &decision) else { return Ok(()); }; relay::relay_tcp(&mut client, &mut upstream, &generation_guard, &ctx).await?; } Ok(()) } /// Resolved process identity for a TCP peer: binary path, PID, ancestor chain, /// cmdline paths, and the TOFU-verified binary hash. /// /// Produced by [`resolve_process_identity`]; consumed by [`authorize_egress_intent`] /// and by the identity-chain regression tests. #[cfg(target_os = "linux")] struct ResolvedIdentity { bin_path: PathBuf, binary_pid: u32, ancestors: Vec, cmdline_paths: Vec, bin_hash: String, } #[cfg(target_os = "linux")] #[derive(Debug, Eq, PartialEq)] struct PolicyIdentityKey { bin_path: PathBuf, ancestors: Vec, cmdline_paths: Vec, bin_hash: String, } #[cfg(target_os = "linux")] impl ResolvedIdentity { fn policy_key(&self) -> PolicyIdentityKey { PolicyIdentityKey { bin_path: self.bin_path.clone(), ancestors: self.ancestors.clone(), cmdline_paths: self.cmdline_paths.clone(), bin_hash: self.bin_hash.clone(), } } } /// Error from [`resolve_process_identity`]. Carries the deny reason and /// whatever partial identity data was resolved before the failure so the /// caller can include it in the [`EgressDecision`] and OCSF event. #[cfg(target_os = "linux")] struct IdentityError { reason: String, binary: Option, binary_pid: Option, ancestors: Vec, } #[cfg(target_os = "linux")] fn resolve_owner_identity( owner_pid: u32, entrypoint_pid: u32, identity_cache: &BinaryIdentityCache, ) -> std::result::Result { let bin_path = crate::procfs::binary_path(owner_pid.cast_signed()).map_err(|e| IdentityError { reason: format!("failed to resolve peer binary for PID {owner_pid}: {e}"), binary: None, binary_pid: Some(owner_pid), ancestors: vec![], })?; let bin_hash = identity_cache .verify_or_cache_process_exe(&bin_path, owner_pid) .map_err(|e| IdentityError { reason: format!("binary integrity check failed: {e}"), binary: Some(bin_path.clone()), binary_pid: Some(owner_pid), ancestors: vec![], })?; let ancestor_identities = collect_ancestor_identities(owner_pid, entrypoint_pid); let ancestors: Vec = ancestor_identities .iter() .map(|(_, path)| path.clone()) .collect(); for (ancestor_pid, ancestor) in &ancestor_identities { identity_cache .verify_or_cache_process_exe(ancestor, *ancestor_pid) .map_err(|e| IdentityError { reason: format!( "ancestor integrity check failed for {}: {e}", ancestor.display() ), binary: Some(bin_path.clone()), binary_pid: Some(owner_pid), ancestors: ancestors.clone(), })?; } let mut exclude = ancestors.clone(); exclude.push(bin_path.clone()); let cmdline_paths = crate::procfs::collect_cmdline_paths(owner_pid, entrypoint_pid, &exclude); Ok(ResolvedIdentity { bin_path, binary_pid: owner_pid, ancestors, cmdline_paths, bin_hash, }) } #[cfg(target_os = "linux")] fn collect_ancestor_identities(start_pid: u32, stop_pid: u32) -> Vec<(u32, PathBuf)> { const MAX_DEPTH: usize = 64; // When the socket owner IS the entrypoint, there are no intermediate // ancestors to verify — the chain is empty by definition. if start_pid == stop_pid { return vec![]; } let mut ancestors = Vec::new(); let mut current = start_pid; for _ in 0..MAX_DEPTH { let parent_pid = match crate::procfs::read_ppid(current) { Some(parent) if parent > 0 && parent != current => parent, _ => break, }; if let Ok(path) = crate::procfs::binary_path(parent_pid.cast_signed()) { ancestors.push((parent_pid, path)); } if parent_pid == stop_pid || parent_pid == 1 { break; } current = parent_pid; } ancestors } /// Resolve the identity of the process owning a TCP peer connection. /// /// Walks `/proc//net/tcp` to find the socket inode, locates /// every owning PID, reads `/proc//exe`, TOFU-verifies each binary hash, /// walks each ancestor chain verifying every ancestor, and collects /// cmdline-derived absolute paths for script detection. /// /// This is the identity-resolution block of [`authorize_egress_intent`] extracted /// into a standalone helper so it can be exercised by Linux-only regression /// tests without a full OPA engine. The key hot-swap invariant under test is /// that display paths are stripped for policy/logging, while integrity hashing /// reads the live executable via `/proc//exe` instead of the replacement /// file that now exists at the display path. #[cfg(target_os = "linux")] fn resolve_process_identity( entrypoint_pid: u32, connection: crate::procfs::WorkloadProxyTcpConnection, identity_cache: &BinaryIdentityCache, ) -> std::result::Result { let socket_owners = crate::procfs::resolve_tcp_peer_socket_owners(entrypoint_pid, connection) .map_err(|e| IdentityError { reason: format!("failed to resolve peer binary: {e}"), binary: None, binary_pid: None, ancestors: vec![], })?; resolve_socket_owner_identities(socket_owners, entrypoint_pid, identity_cache) } #[cfg(target_os = "linux")] fn resolve_socket_owner_identities( socket_owners: crate::procfs::TcpPeerSocketOwners, entrypoint_pid: u32, identity_cache: &BinaryIdentityCache, ) -> std::result::Result { let mut identities = Vec::with_capacity(socket_owners.owners.len()); for owner in &socket_owners.owners { match resolve_owner_identity(owner.pid, entrypoint_pid, identity_cache) { Ok(identity) => identities.push(identity), Err(error) => { // A process can exit after the socket-owner scan. Its fd is // gone, so it cannot affect the identity of surviving owners. // Keep other lookup failures fail-closed. let process = format!("/proc/{}", owner.pid); if matches!( std::fs::metadata(process), Err(ref error) if error.kind() == std::io::ErrorKind::NotFound ) { continue; } return Err(error); } } } let Some(first_identity) = identities.first() else { return Err(IdentityError { reason: format!( "failed to resolve peer binary: no process found owning socket inode {}", socket_owners.inode ), binary: None, binary_pid: None, ancestors: vec![], }); }; let first_key = first_identity.policy_key(); if identities .iter() .skip(1) .any(|identity| identity.policy_key() != first_key) { let mut pids: Vec = identities .iter() .map(|identity| identity.binary_pid) .collect(); pids.sort_unstable(); return Err(IdentityError { reason: format!( "ambiguous shared socket ownership: inode {} is held by PIDs [{}] with different policy identities", socket_owners.inode, pids.iter() .map(u32::to_string) .collect::>() .join(", ") ), binary: None, binary_pid: None, ancestors: vec![], }); } let lowest_pid = identities.iter().map(|identity| identity.binary_pid).min(); let mut identity = identities.swap_remove(0); if let Some(lowest_pid) = lowest_pid { identity.binary_pid = lowest_pid; } Ok(identity) } /// Evaluate OPA policy for a TCP connection with identity binding via /proc/net/tcp. #[cfg(target_os = "linux")] fn authorize_egress_intent( connection: crate::procfs::WorkloadProxyTcpConnection, engine: &OpaEngine, identity_cache: &BinaryIdentityCache, entrypoint_pid: &AtomicU32, intent: EgressIntent, ) -> EgressDecision { use crate::opa::NetworkInput; use std::sync::atomic::Ordering; let deny = |reason: String, identity: ProcessIdentityEvidence, binary: Option, binary_pid: Option, ancestors: Vec, cmdline_paths: Vec| -> EgressDecision { EgressDecision { intent: intent.clone(), action: NetworkAction::Deny { reason }, policy_generation: engine.current_generation(), identity, endpoint: EndpointDecision::default(), binary, binary_pid, ancestors, cmdline_paths, } }; let entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); let Some(proc_net_anchor_pid) = proc_net_anchor_pid(entrypoint_pid) else { return deny( "entrypoint process not yet spawned".into(), ProcessIdentityEvidence::Unavailable(IdentityUnavailableReason::LookupFailed), None, None, vec![], vec![], ); }; let total_start = std::time::Instant::now(); let identity = match resolve_process_identity(proc_net_anchor_pid, connection, identity_cache) { Ok(id) => id, Err(err) => { return deny( err.reason, ProcessIdentityEvidence::Unavailable(IdentityUnavailableReason::LookupFailed), err.binary, err.binary_pid, err.ancestors, vec![], ); } }; let ResolvedIdentity { bin_path, binary_pid, ancestors, cmdline_paths, bin_hash, } = identity; let input = NetworkInput { host: intent.destination.host.clone(), port: intent.destination.port, binary_path: bin_path.clone(), binary_sha256: bin_hash, ancestors: ancestors.clone(), cmdline_paths: cmdline_paths.clone(), }; let result = match engine.authorize_egress(&input) { Ok(authorization) => EgressDecision { intent: intent.clone(), action: authorization.action.clone(), policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(bin_path), binary_pid: Some(binary_pid), ancestors, cmdline_paths, }, Err(e) => deny( format!("policy evaluation error: {e}"), ProcessIdentityEvidence::Available, Some(bin_path), Some(binary_pid), ancestors, cmdline_paths, ), }; debug!( "authorize_egress_intent TOTAL: {}ms host={} port={} transport={:?}", total_start.elapsed().as_millis(), intent.destination.host, intent.destination.port, intent.transport, ); result } #[cfg(target_os = "linux")] fn proc_net_anchor_pid(entrypoint_pid: u32) -> Option { (entrypoint_pid != 0).then_some(entrypoint_pid) } fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> EgressDecision { let input = crate::opa::NetworkInput { host: intent.destination.host.clone(), port: intent.destination.port, binary_path: PathBuf::new(), binary_sha256: String::new(), ancestors: vec![], cmdline_paths: vec![], }; match engine.authorize_egress(&input) { Ok(authorization) => EgressDecision { intent, action: authorization.action.clone(), policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::EndpointOnlyMode, ), endpoint: EndpointDecision::from_authorization(&authorization), binary: None, binary_pid: None, ancestors: vec![], cmdline_paths: vec![], }, Err(e) => EgressDecision { intent, action: NetworkAction::Deny { reason: format!("policy evaluation error: {e}"), }, policy_generation: engine.current_generation(), identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::EndpointOnlyMode, ), endpoint: EndpointDecision::default(), binary: None, binary_pid: None, ancestors: vec![], cmdline_paths: vec![], }, } } /// Evaluate an egress intent using identity already bound to the accepted /// connection by an isolation backend. This is the RFC 0012 path; legacy /// listeners continue to resolve through procfs in `authorize_egress_intent`. fn authorize_supplied_identity( engine: &OpaEngine, intent: EgressIntent, identity: &Result, ) -> EgressDecision { let deny = |reason: String, binary: Option, ancestors: Vec, cmdline_paths: Vec| EgressDecision { intent: intent.clone(), action: NetworkAction::Deny { reason }, policy_generation: engine.current_generation(), identity: ProcessIdentityEvidence::Unavailable(IdentityUnavailableReason::LookupFailed), endpoint: EndpointDecision::default(), binary, binary_pid: None, ancestors, cmdline_paths, }; let identity = match identity { Ok(identity) => identity, Err(error) => { return deny( format!("backend identity resolution failed: {error}"), None, vec![], vec![], ); } }; let Some(digest) = identity.binary_digest else { return deny( "backend identity did not include the required binary digest".to_string(), Some(identity.binary_path.clone()), identity.ancestors.clone(), identity.cmdline_paths.clone(), ); }; let input = crate::opa::NetworkInput { host: intent.destination.host.clone(), port: intent.destination.port, binary_path: identity.binary_path.clone(), binary_sha256: digest.to_string(), ancestors: identity.ancestors.clone(), cmdline_paths: identity.cmdline_paths.clone(), }; match engine.authorize_egress(&input) { Ok(authorization) => EgressDecision { intent, action: authorization.action.clone(), policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(identity.binary_path.clone()), binary_pid: None, ancestors: identity.ancestors.clone(), cmdline_paths: identity.cmdline_paths.clone(), }, Err(error) => deny( format!("policy evaluation error: {error}"), Some(identity.binary_path.clone()), identity.ancestors.clone(), identity.cmdline_paths.clone(), ), } } /// Non-Linux stub: OPA identity binding requires /proc. #[cfg(not(target_os = "linux"))] fn authorize_egress_intent( _connection: crate::procfs::WorkloadProxyTcpConnection, engine: &OpaEngine, _identity_cache: &BinaryIdentityCache, _entrypoint_pid: &AtomicU32, intent: EgressIntent, ) -> EgressDecision { EgressDecision { intent, action: NetworkAction::Deny { reason: "identity binding unavailable on this platform".into(), }, policy_generation: engine.current_generation(), identity: ProcessIdentityEvidence::Unavailable( IdentityUnavailableReason::UnsupportedPlatform, ), endpoint: EndpointDecision::default(), binary: None, binary_pid: None, ancestors: vec![], cmdline_paths: vec![], } } fn emit_l7_tunnel_close_after_policy_change(host: &str, port: u16, error: miette::Report) { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(host, port)) .message(format!( "L7 tunnel closed before inspection because policy changed: {error}" )) .build(); ocsf_emit!(event); } async fn reject_stale_connect_policy( client: &mut C, host: &str, port: u16, activity_tx: Option<&ActivitySender>, error: miette::Report, ) -> Result<()> where C: TokioAsyncWrite + Unpin, { warn!( host, port, error = %error, "CONNECT rejected because policy changed after L4 authorization" ); emit_l7_tunnel_close_after_policy_change(host, port, error); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, &build_json_error_response( 403, "Forbidden", "policy_denied", &format!("CONNECT {host}:{port} not permitted because policy changed"), ), ) .await } /// Query L7 endpoint config from the OPA engine for an allowed egress decision. /// /// Returns `Some(L7EndpointConfig)` if the matched endpoint has L7 config (protocol field), /// `None` for L4-only endpoints. fn hydrate_l7_route(decision: &mut EgressDecision) { let host = decision.intent.destination.host.clone(); let port = decision.intent.destination.port; decision.endpoint.l7_route = query_l7_route_snapshot(decision, &host, port); } fn hydrate_tls_mode(decision: &mut EgressDecision) { let host = decision.intent.destination.host.clone(); let port = decision.intent.destination.port; decision.endpoint.tls_mode = query_tls_mode(decision, &host, port); } fn hydrate_destination_plan( decision: &mut EgressDecision, backend_host_gateway: Option, trusted_host_gateway: Option, ) -> std::result::Result<(), DestinationDenial> { let host = decision.intent.destination.host.clone(); let raw_allowed_ips = query_allowed_ips(decision); let exact_declared_host = decision.endpoint.exact_declared_host; let plan = build_validation_plan( &host, &host.to_ascii_lowercase(), backend_host_gateway, trusted_host_gateway, &raw_allowed_ips, exact_declared_host, )?; decision.endpoint.destination = Some(plan); Ok(()) } fn query_l7_route_snapshot( decision: &EgressDecision, host: &str, port: u16, ) -> Option { // Only query if action is Allow (not Deny) let has_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.is_some(), NetworkAction::Deny { .. } => false, }; if !has_policy { return None; } let configs: Vec<_> = decision .endpoint .policy_configs .iter() .filter_map(crate::l7::parse_l7_config) .map(|config| L7ConfigSnapshot { config }) .collect(); if configs.is_empty() { return None; } debug!( host, port, generation = decision.policy_generation, config_count = configs.len(), "Egress L7 route materialized from authorization snapshot" ); Some(L7RouteSnapshot { configs, l7_policy_generation: decision.policy_generation, }) } fn select_l7_config_for_path<'a>( configs: &'a [L7ConfigSnapshot], path: &str, ) -> Option<&'a L7ConfigSnapshot> { configs .iter() .filter(|snapshot| snapshot.config.matches_path(path)) .max_by_key(|snapshot| snapshot.config.path_specificity()) } /// Begin a pre-path observation only when an authority identifies one tool server endpoint. fn begin_unambiguous_endpoint_observation( route: Option<&L7RouteSnapshot>, sender: Option<&EndpointObservationSender>, context: Option<&EndpointObservationContext>, guard: &PolicyGenerationGuard, ) -> Option { let mut mcp_configs = route?.configs.iter().filter(|snapshot| { snapshot.config.protocol == crate::l7::L7Protocol::Mcp && !snapshot.config.endpoint_id.is_empty() }); let config = &mcp_configs.next()?.config; if mcp_configs.any(|candidate| candidate.config.endpoint_id != config.endpoint_id) { return None; } crate::l7::EndpointObserver::begin_captured(sender, config, context, None, Some(guard)) } /// Query the TLS mode for an endpoint, independent of L7 config. /// /// This extracts `tls: skip` from the endpoint even when no `protocol` is set. fn query_tls_mode(decision: &EgressDecision, _host: &str, _port: u16) -> crate::l7::TlsMode { let has_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.is_some(), NetworkAction::Deny { .. } => false, }; if !has_policy { return crate::l7::TlsMode::Auto; } decision .endpoint .policy_configs .first() .map_or(crate::l7::TlsMode::Auto, crate::l7::parse_tls_mode) } fn query_endpoint_credential_guard( engine: &OpaEngine, decision: &EgressDecision, host: &str, port: u16, ) -> Result { let has_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.is_some(), NetworkAction::Deny { .. } => false, }; if !has_policy { return Ok(crate::l7::EndpointCredentialGuard::default()); } let input = crate::opa::NetworkInput { host: host.to_string(), port, binary_path: decision.binary.clone().unwrap_or_default(), binary_sha256: String::new(), ancestors: decision.ancestors.clone(), cmdline_paths: decision.cmdline_paths.clone(), }; let values = engine.query_endpoint_credential_guards(&input)?; let credentialed: Vec<_> = values .iter() .map(crate::l7::parse_endpoint_credential_guard) .filter(|guard| guard.provider_credentialed) .collect(); if credentialed.is_empty() { return Ok(crate::l7::EndpointCredentialGuard::default()); } Ok(crate::l7::EndpointCredentialGuard { provider_credentialed: true, allow_uninspected_credentials: credentialed .iter() .all(|guard| guard.allow_uninspected_credentials), has_l7_protocol: credentialed.iter().all(|guard| guard.has_l7_protocol), tls: if credentialed .iter() .any(|guard| guard.tls == crate::l7::TlsMode::Skip) { crate::l7::TlsMode::Skip } else { crate::l7::TlsMode::Auto }, }) } /// When the policy endpoint host is a literal IP address, the user has /// explicitly declared intent to allow that destination. Synthesize an /// `allowed_ips` entry so the existing allowlist-validation path is used /// instead of the blanket internal-IP rejection. /// /// Always-blocked addresses (loopback, link-local, unspecified) are skipped /// — synthesizing an `allowed_ips` entry for them would be silently /// un-enforceable at runtime. fn implicit_allowed_ips_for_ip_host(host: &str) -> Vec { let lookup_host = normalize_host_lookup_key(host); if let Ok(ip) = lookup_host.parse::() { if is_always_blocked_ip(ip) { warn!( host, "Policy host is an always-blocked address; \ implicit allowed_ips skipped — SSRF hardening prevents \ traffic to this destination regardless of policy" ); return vec![]; } vec![lookup_host.to_string()] } else { vec![] } } fn normalize_host_lookup_key(host: &str) -> &str { let h = host .strip_prefix('[') .and_then(|trimmed| trimmed.strip_suffix(']')) .unwrap_or(host); h.strip_suffix('.').unwrap_or(h) } /// Returns `true` if `host` is one of the well-known driver-injected aliases /// for the host machine (e.g. `host.openshell.internal`). pub(crate) fn is_host_gateway_alias(host: &str) -> bool { let h = normalize_host_lookup_key(host); HOST_GATEWAY_ALIASES .iter() .any(|alias| alias.eq_ignore_ascii_case(h)) } /// Returns `true` if `ip` is a known cloud instance metadata endpoint that /// must never be exempted from SSRF blocking. /// /// IPv4-mapped IPv6 addresses (e.g. `::ffff:169.254.169.254`) are normalized /// to their embedded IPv4 representation before comparison, so the invariant /// holds regardless of how the address is represented. fn is_cloud_metadata_ip(ip: IpAddr) -> bool { match ip { IpAddr::V4(_) => CLOUD_METADATA_IPS.contains(&ip), IpAddr::V6(v6) => v6 .to_ipv4_mapped() .is_some_and(|v4| CLOUD_METADATA_IPS.contains(&IpAddr::V4(v4))), } } /// Read the proxy's own `/etc/hosts` at startup and return the IP mapped to /// `host.openshell.internal`, if present and safe. /// /// This is called once before user code runs, so the returned value is immune /// to later `/etc/hosts` tampering by sandbox workloads. Returns `None` if no /// entry exists, the entry cannot be parsed, or the mapped IP is a cloud /// metadata address. #[cfg(any(target_os = "linux", test))] pub(crate) fn detect_trusted_host_gateway() -> Option { let contents = std::fs::read_to_string("/etc/hosts").ok()?; let ips = parse_hosts_file_for_host(&contents, "host.openshell.internal"); // Multiple distinct IPs for the alias is unexpected — compute drivers // always inject exactly one. Warn loudly so operators can diagnose the // inconsistency; we still proceed with the first entry rather than // disabling the exemption entirely, because the mismatch guard in // resolve_and_check_trusted_gateway() will reject any runtime resolution // that returns a different IP. if ips.len() > 1 { warn!( ips = ?ips, "host.openshell.internal has {} distinct IPs in /etc/hosts; \ expected exactly one. Using first entry. \ Connections resolving to any other IP will be rejected.", ips.len() ); } let ip = ips.into_iter().next()?; if is_cloud_metadata_ip(ip) { warn!( %ip, "host.openshell.internal resolves to a cloud metadata IP; \ trusted-gateway SSRF exemption disabled" ); return None; } // The exemption exists solely for link-local IPs used by rootless Podman // with pasta. Private RFC 1918 addresses (e.g. Docker bridge 172.17.0.1, // Kubernetes node 192.168.x.x), loopback, unspecified, and all other // non-link-local addresses are never legitimate candidates for the // link-local SSRF exemption — they must fall through to the normal // allowed_ips / resolve_and_reject_internal() enforcement path. if !is_link_local_ip(ip) { warn!( %ip, "host.openshell.internal maps to a non-link-local IP; \ trusted-gateway SSRF exemption disabled" ); return None; } Some(ip) } #[cfg(not(any(target_os = "linux", test)))] pub(crate) fn detect_trusted_host_gateway() -> Option { None } /// Resolve `host:port` and validate that every resolved address matches the /// trusted host gateway IP. /// /// This bypasses the normal SSRF tiers (always-blocked and internal-IP) for /// driver-injected host-gateway aliases, allowing link-local addresses used /// by rootless Podman with pasta without opening up arbitrary link-local or /// cloud metadata access. /// /// Rejects: /// - Any resolved IP that is a cloud metadata address (defense-in-depth) /// - Any resolved IP that does not match `trusted_gw` (prevents /etc/hosts tampering) /// - Control-plane ports (etcd, K8s API, kubelet) regardless of IP async fn resolve_and_check_trusted_gateway( host: &str, port: u16, trusted_gw: IpAddr, entrypoint_pid: u32, ) -> std::result::Result, DestinationCheckError> { if BLOCKED_CONTROL_PLANE_PORTS.contains(&port) { return Err(DestinationCheckError::Denied(format!( "port {port} is a blocked control-plane port, connection rejected" ))); } let addrs = resolve_socket_addrs(host, port, entrypoint_pid) .await .map_err(DestinationCheckError::Resolution)?; if addrs.is_empty() { return Err(DestinationCheckError::Resolution(format!( "DNS resolution returned no addresses for {}", normalize_host_lookup_key(host) ))); } for addr in &addrs { if is_cloud_metadata_ip(addr.ip()) { return Err(DestinationCheckError::Denied(format!( "{host} resolves to cloud metadata address {}, connection rejected", addr.ip() ))); } if addr.ip() != trusted_gw { return Err(DestinationCheckError::Denied(format!( "{host} resolves to {} which does not match trusted host gateway \ {trusted_gw}, connection rejected", addr.ip() ))); } // Defense-in-depth: even if the resolved IP matches trusted_gw, reject // any non-link-local address. detect_trusted_host_gateway() already // enforces this at startup, but we re-check here to guard against any // unanticipated code path that might admit a private or loopback IP. if !is_link_local_ip(addr.ip()) { return Err(DestinationCheckError::Denied(format!( "{host} resolves to non-link-local address {}, \ connection rejected", addr.ip() ))); } } Ok(addrs) } fn resolve_ip_literal(host: &str, port: u16) -> Option> { normalize_host_lookup_key(host) .parse::() .ok() .map(|ip| vec![SocketAddr::new(ip, port)]) } #[cfg(any(target_os = "linux", test))] fn parse_hosts_file_for_host(contents: &str, host: &str) -> Vec { let lookup_host = normalize_host_lookup_key(host); let mut addrs = Vec::new(); for raw_line in contents.lines() { let line = raw_line.split('#').next().unwrap_or("").trim(); if line.is_empty() { continue; } let mut fields = line.split_whitespace(); let Some(ip_str) = fields.next() else { continue; }; let Ok(ip) = ip_str.parse::() else { continue; }; if fields.any(|alias| alias.eq_ignore_ascii_case(lookup_host)) && !addrs.contains(&ip) { addrs.push(ip); } } addrs } #[cfg(any(target_os = "linux", test))] fn resolve_from_hosts_file_contents(contents: &str, host: &str, port: u16) -> Vec { parse_hosts_file_for_host(contents, host) .into_iter() .map(|ip| SocketAddr::new(ip, port)) .collect() } #[cfg(target_os = "linux")] async fn resolve_from_sandbox_hosts( host: &str, port: u16, entrypoint_pid: u32, ) -> Option> { if entrypoint_pid == 0 { return None; } let hosts_path = format!("/proc/{entrypoint_pid}/root/etc/hosts"); let contents = match tokio::fs::read_to_string(&hosts_path).await { Ok(contents) => contents, Err(error) => { debug!( pid = entrypoint_pid, path = %hosts_path, host, "Falling back to DNS; failed to read sandbox hosts file: {error}" ); return None; } }; let addrs = resolve_from_hosts_file_contents(&contents, host, port); if addrs.is_empty() { None } else { Some(addrs) } } // Mirrors the Linux signature so call sites can `.await` uniformly across // platforms; the non-Linux path has nothing to await. #[cfg(not(target_os = "linux"))] #[allow(clippy::unused_async)] async fn resolve_from_sandbox_hosts( _host: &str, _port: u16, _entrypoint_pid: u32, ) -> Option> { None } async fn resolve_socket_addrs( host: &str, port: u16, entrypoint_pid: u32, ) -> std::result::Result, String> { if let Some(addrs) = resolve_ip_literal(host, port) { return Ok(addrs); } if let Some(addrs) = resolve_from_sandbox_hosts(host, port, entrypoint_pid).await { return Ok(addrs); } let dns_host = host .strip_prefix('[') .and_then(|h| h.strip_suffix(']')) .unwrap_or(host); let addrs: Vec = tokio::net::lookup_host((dns_host, port)) .await .map_err(|e| format!("DNS resolution failed for {dns_host}:{port}: {e}"))? .collect(); if addrs.is_empty() { return Err(format!( "DNS resolution returned no addresses for {dns_host}:{port}" )); } Ok(addrs) } fn reject_internal_resolved_addrs( host: &str, addrs: &[SocketAddr], ) -> std::result::Result<(), String> { if addrs.is_empty() { return Err(format!( "DNS resolution returned no addresses for {}", normalize_host_lookup_key(host) )); } for addr in addrs { if is_internal_ip(addr.ip()) { return Err(format!( "{host} resolves to internal address {}, connection rejected", addr.ip() )); } } Ok(()) } fn validate_allowed_ips_for_resolved_addrs( host: &str, port: u16, addrs: &[SocketAddr], allowed_ips: &[ipnet::IpNet], ) -> std::result::Result<(), String> { if addrs.is_empty() { return Err(format!( "DNS resolution returned no addresses for {}", normalize_host_lookup_key(host) )); } // Block control-plane ports regardless of IP match. if BLOCKED_CONTROL_PLANE_PORTS.contains(&port) { return Err(format!( "port {port} is a blocked control-plane port, connection rejected" )); } for addr in addrs { // Always block loopback and link-local if is_always_blocked_ip(addr.ip()) { return Err(format!( "{host} resolves to always-blocked address {}, connection rejected", addr.ip() )); } // Check resolved IP against the allowlist let ip_allowed = allowed_ips.iter().any(|net| net.contains(&addr.ip())); if !ip_allowed { return Err(format!( "{host} resolves to {} which is not in allowed_ips, connection rejected", addr.ip() )); } } Ok(()) } fn validate_declared_endpoint_resolved_addrs( host: &str, port: u16, addrs: &[SocketAddr], ) -> std::result::Result<(), String> { if addrs.is_empty() { return Err(format!( "DNS resolution returned no addresses for {}", normalize_host_lookup_key(host) )); } if BLOCKED_CONTROL_PLANE_PORTS.contains(&port) { return Err(format!( "port {port} is a blocked control-plane port, connection rejected" )); } for addr in addrs { if is_always_blocked_ip(addr.ip()) { return Err(format!( "{host} resolves to always-blocked address {}, connection rejected", addr.ip() )); } } Ok(()) } /// Dial a validated upstream destination for a TLS (CONNECT) tunnel. /// /// Connects directly to the SSRF-checked resolved addresses, or chains /// through the corporate proxy (HTTP CONNECT) when one is configured for /// this destination via the driver-supplied upstream proxy arguments /// and not excluded by the operator `NO_PROXY` list. Policy evaluation and /// SSRF validation must have already succeeded; only the final TCP dial /// changes. Plain-HTTP requests never take this path: they always dial the /// destination directly. /// /// `NO_PROXY` evaluation is port-aware and sees the validated resolved /// addresses: an entry with a `:port` qualifier only bypasses that port, /// and an IP/CIDR entry that matches through resolution limits the direct /// dial to the addresses it contains. /// /// The CONNECT target sent to the corporate proxy is a validated resolved /// address, so the proxy performs no DNS resolution of its own and the /// tunnel stays bound to the answer that passed SSRF and `allowed_ips` /// validation; the hostname still travels inside the tunnel (TLS SNI, /// application `Host`). The operator opt-in `proxy_connect_by_hostname` /// sends the client-requested hostname instead, for proxies whose ACLs /// filter on hostnames, at the cost of re-opening proxy-side resolution. /// /// Both paths return a [`upstream_proxy::PrefixedStream`]: for proxied /// dials it replays any tunneled bytes that arrived in the same read as the /// CONNECT response; for direct dials it is a plain passthrough. async fn dial_upstream( upstream_proxy: &Option, host_lc: &str, raw_host_lc: &str, port: u16, addrs: &[SocketAddr], ) -> std::io::Result { if let Some(cfg) = upstream_proxy.as_ref() { return match cfg.decision(host_lc, port, addrs) { upstream_proxy::ProxyDecision::Proxy(endpoint) => { if cfg.connect_by_hostname() { upstream_proxy::connect_via( endpoint, raw_host_lc, port, upstream_proxy::ConnectTarget::Hostname, ) .await } else { // Try every validated address in order, matching the // fallback the direct path's `TcpStream::connect` does. upstream_proxy::connect_via_validated(endpoint, host_lc, port, addrs).await } } upstream_proxy::ProxyDecision::Direct(direct_addrs) => { Ok(upstream_proxy::PrefixedStream::without_prefix( connect_tcp_nodelay_best_effort(&direct_addrs[..]).await?, )) } }; } Ok(upstream_proxy::PrefixedStream::without_prefix( connect_tcp_nodelay_best_effort(addrs).await?, )) } /// Dial a policy-DNS-correlated transparent TCP destination. /// /// Unlike explicit proxy traffic, transparent TCP must never honor the /// operator hostname-CONNECT compatibility mode: the corporate proxy must /// receive one of the resolver-approved addresses so it cannot perform a /// second, policy-bypassing DNS resolution. #[cfg(target_os = "linux")] async fn dial_transparent_upstream( upstream_proxy: &Option, host_lc: &str, port: u16, addrs: &[SocketAddr], ) -> std::io::Result { if let Some(cfg) = upstream_proxy.as_ref() { return match cfg.decision(host_lc, port, addrs) { upstream_proxy::ProxyDecision::Proxy(endpoint) => { upstream_proxy::connect_via_validated(endpoint, host_lc, port, addrs).await } upstream_proxy::ProxyDecision::Direct(direct_addrs) => { Ok(upstream_proxy::PrefixedStream::without_prefix( connect_tcp_nodelay_best_effort(&direct_addrs[..]).await?, )) } }; } Ok(upstream_proxy::PrefixedStream::without_prefix( connect_tcp_nodelay_best_effort(addrs).await?, )) } /// Resolve a host:port using sandbox `/etc/hosts` first (when available), then /// reject if any resolved address is internal. /// /// Returns the resolved `SocketAddr` list on success. Returns an error string /// if any resolved IP is in an internal range or if DNS resolution fails. async fn resolve_and_reject_internal( host: &str, port: u16, entrypoint_pid: u32, ) -> std::result::Result, DestinationCheckError> { let addrs = resolve_socket_addrs(host, port, entrypoint_pid) .await .map_err(DestinationCheckError::Resolution)?; reject_internal_resolved_addrs(host, &addrs).map_err(DestinationCheckError::Denied)?; Ok(addrs) } /// Resolve a host:port using sandbox `/etc/hosts` first (when available), then /// validate resolved addresses against a CIDR/IP allowlist. /// /// Rejects loopback and link-local unconditionally. For all other resolved /// addresses, checks that each one matches at least one entry in `allowed_ips`. /// Entries can be CIDR notation ("10.0.5.0/24") or exact IPs ("10.0.5.20"). /// /// Returns the resolved `SocketAddr` list on success. async fn resolve_and_check_allowed_ips( host: &str, port: u16, allowed_ips: &[ipnet::IpNet], entrypoint_pid: u32, ) -> std::result::Result, DestinationCheckError> { let addrs = resolve_socket_addrs(host, port, entrypoint_pid) .await .map_err(DestinationCheckError::Resolution)?; validate_allowed_ips_for_resolved_addrs(host, port, &addrs, allowed_ips) .map_err(DestinationCheckError::Denied)?; Ok(addrs) } /// Resolve a host:port that was explicitly declared by hostname in policy. /// /// Exact declared hostnames are the operator's trust signal, so RFC1918 and /// other private ranges are allowed without a duplicated `allowed_ips` entry. /// Loopback, link-local, unspecified, and control-plane ports remain blocked. async fn resolve_and_check_declared_endpoint( host: &str, port: u16, entrypoint_pid: u32, ) -> std::result::Result, DestinationCheckError> { let addrs = resolve_socket_addrs(host, port, entrypoint_pid) .await .map_err(DestinationCheckError::Resolution)?; validate_declared_endpoint_resolved_addrs(host, port, &addrs) .map_err(DestinationCheckError::Denied)?; Ok(addrs) } /// Minimum CIDR prefix length before logging a breadth warning. /// CIDRs broader than /16 (65,536+ addresses) may unintentionally expose /// control-plane services on the same network. const MIN_SAFE_PREFIX_LEN: u8 = 16; /// Ports that are always blocked in `resolve_and_check_allowed_ips`, even /// when the resolved IP matches an `allowed_ips` entry. These ports belong /// to control-plane services that should never be reachable from a sandbox. const BLOCKED_CONTROL_PLANE_PORTS: &[u16] = &[ 2379, // etcd client 2380, // etcd peer 6443, // Kubernetes API server 10250, // kubelet API 10255, // kubelet read-only ]; /// Parse CIDR/IP strings into `IpNet` values, rejecting invalid entries and /// entries that overlap always-blocked ranges (loopback, link-local, /// unspecified). /// /// Returns parsed networks on success, or an error describing which entries /// are invalid or always-blocked. Logs a warning for overly broad CIDRs /// that are not outright blocked. fn parse_allowed_ips(raw: &[String]) -> std::result::Result, String> { use openshell_core::net::is_always_blocked_net; let mut nets = Vec::with_capacity(raw.len()); let mut errors = Vec::new(); for entry in raw { // Try as CIDR first, then as bare IP (convert to /32 or /128) let parsed = entry.parse::().or_else(|_| { entry .parse::() .map(|ip| match ip { IpAddr::V4(v4) => ipnet::IpNet::V4(ipnet::Ipv4Net::from(v4)), IpAddr::V6(v6) => ipnet::IpNet::V6(ipnet::Ipv6Net::from(v6)), }) .map_err(|_| ()) }); match parsed { Ok(n) => { // Reject entries that overlap always-blocked ranges — these // would be silently denied at runtime by is_always_blocked_ip // and cause confusing UX (accepted in policy, never works). if is_always_blocked_net(n) { errors.push(format!( "allowed_ips entry {entry} falls within always-blocked range \ (loopback/link-local/unspecified); remove this entry — \ SSRF hardening prevents traffic to these destinations \ regardless of policy" )); continue; } if n.prefix_len() < MIN_SAFE_PREFIX_LEN { let event = openshell_ocsf::ConfigStateChangeBuilder::new( openshell_ocsf::ctx::ctx(), ) .severity(SeverityId::Medium) .status(StatusId::Success) .state(openshell_ocsf::StateId::Other, "warning") .message(format!( "allowed_ips entry has a very broad CIDR {n} (/{}) < /{MIN_SAFE_PREFIX_LEN}; \ this may expose control-plane services on the same network", n.prefix_len() )) .build(); ocsf_emit!(event); } nets.push(n); } Err(()) => errors.push(format!("invalid CIDR/IP in allowed_ips: {entry}")), } } if errors.is_empty() { Ok(nets) } else { Err(errors.join("; ")) } } /// Read `allowed_ips` from the endpoint configs captured during authorization. fn query_allowed_ips(decision: &EgressDecision) -> Vec { // Only query if action is Allow with a matched policy let has_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.is_some(), NetworkAction::Deny { .. } => false, }; if !has_policy { return vec![]; } decision .endpoint .policy_configs .first() .map(|config| endpoint_config_string_array(config, "allowed_ips")) .unwrap_or_default() } fn endpoint_config_string_array(config: ®orus::Value, key: &str) -> Vec { let regorus::Value::Object(fields) = config else { return Vec::new(); }; let key = regorus::Value::String(key.into()); let Some(regorus::Value::Array(values)) = fields.get(&key) else { return Vec::new(); }; values .iter() .filter_map(|value| match value { regorus::Value::String(value) => Some(value.to_string()), _ => None, }) .collect() } /// Extract the hostname from an absolute-form URI used in plain HTTP proxy requests. /// /// For example, `"http://example.com/path"` yields `"example.com"` and /// `"http://example.com:8080/path"` yields `"example.com"`. Returns `"unknown"` /// if the URI cannot be parsed. #[cfg(test)] fn extract_host_from_uri(uri: &str) -> String { // Absolute-form URIs look like "http://host[:port]/path" // Strip the scheme prefix, then extract the authority (host[:port]) before the first '/'. let after_scheme = uri.find("://").map_or(uri, |i| &uri[i + 3..]); let authority = after_scheme.split('/').next().unwrap_or(after_scheme); // Strip port if present (handle IPv6 bracket notation) let host = if authority.starts_with('[') { // IPv6: [::1]:port authority.find(']').map_or(authority, |i| &authority[..=i]) } else { authority.split(':').next().unwrap_or(authority) }; if host.is_empty() { "unknown".to_string() } else { host.to_string() } } /// Parse an absolute-form proxy request URI into its components. /// /// For example, `"http://10.86.8.223:8000/screenshot/"` yields /// `("http", "10.86.8.223", 8000, "/screenshot/")`. /// /// Handles: /// - Default port 80 for `http`, 443 for `https` /// - IPv6 bracket notation (`[::1]`) /// - Missing path (defaults to `/`) /// - Query strings (preserved in path) fn parse_proxy_uri(uri: &str) -> Result<(String, String, u16, String)> { // Extract scheme let (scheme, rest) = uri .split_once("://") .ok_or_else(|| miette::miette!("Missing scheme in proxy URI: {uri}"))?; let scheme = scheme.to_ascii_lowercase(); // Split authority from the request target. A query may immediately follow // the authority when the absolute URI has no explicit path, so `/` alone // is not a sufficient delimiter. let target_start = if rest.starts_with('[') { // IPv6: [::1]:port/path or [::1]?query let bracket_end = rest .find(']') .ok_or_else(|| miette::miette!("Unclosed IPv6 bracket in URI: {uri}"))?; rest[bracket_end + 1..] .find(['/', '?', '#']) .map(|position| bracket_end + 1 + position) } else { rest.find(['/', '?', '#']) }; let (authority, target) = target_start.map_or((rest, ""), |position| rest.split_at(position)); if target.contains('#') { return Err(miette::miette!( "Fragments are not allowed in proxy URI: {uri}" )); } let path = match target.chars().next() { None => "/".to_string(), Some('/') => target.to_string(), Some('?') => format!("/{target}"), Some('#') => unreachable!("fragments were rejected above"), Some(_) => unreachable!("target begins at a recognized delimiter"), }; // Parse host and port from authority let (host, port) = if authority.starts_with('[') { // IPv6: [::1]:port or [::1] let bracket_end = authority .find(']') .ok_or_else(|| miette::miette!("Unclosed IPv6 bracket: {uri}"))?; let host = &authority[1..bracket_end]; // strip brackets let port_str = &authority[bracket_end + 1..]; let port = if let Some(port_str) = port_str.strip_prefix(':') { port_str .parse::() .map_err(|_| miette::miette!("Invalid port in URI: {uri}"))? } else { match scheme.as_str() { "https" => 443, _ => 80, } }; (host.to_string(), port) } else if let Some((h, p)) = authority.rsplit_once(':') { let port = p .parse::() .map_err(|_| miette::miette!("Invalid port in URI: {uri}"))?; (h.to_string(), port) } else { let port = match scheme.as_str() { "https" => 443, _ => 80, }; (authority.to_string(), port) }; if host.is_empty() { return Err(miette::miette!("Empty host in URI: {uri}")); } Ok((scheme, host, port, path)) } /// Return a query-free, credential-redacted path suitable for forward-proxy /// telemetry. Malformed targets are represented by a fixed sentinel so parse /// errors cannot expose query strings or credential environment-key names. fn forward_telemetry_path(target_uri: &str) -> String { let Ok((_, _, _, target)) = parse_proxy_uri(target_uri) else { return "/[INVALID_REQUEST_TARGET]".to_string(); }; let path = target .split_once('?') .map_or(target.as_str(), |(path, _)| path); secrets::redact_target_for_policy(path) .unwrap_or_else(|_| "/[INVALID_REQUEST_TARGET]".to_string()) } #[cfg(test)] fn endpoint_secret_resolver( provider_credentials: Option<&ProviderCredentialState>, fallback: Option>, host: &str, port: u16, canonical_path: &str, ) -> Option> { endpoint_credentials_for_request(provider_credentials, fallback, host, port, canonical_path) .resolver } struct ForwardEndpointCredentials { body_classifier: Option>, resolver: Option>, revision: Option, } fn endpoint_credentials_for_request( provider_credentials: Option<&ProviderCredentialState>, fallback: Option>, host: &str, port: u16, canonical_path: &str, ) -> ForwardEndpointCredentials { let Some(credentials) = provider_credentials else { return ForwardEndpointCredentials { resolver: fallback, body_classifier: None, revision: None, }; }; let (resolver, body_classifier, revision) = credentials.resolver_and_body_classifier_for_endpoint(host, port, canonical_path); ForwardEndpointCredentials { body_classifier, resolver, revision: Some(revision), } } struct PreparedForwardTarget { canonical_path: String, raw_query: Option, upstream_target: String, telemetry_path: String, } fn prepare_forward_target( target: &str, canonicalize_options: crate::l7::path::CanonicalizeOptions, ) -> Result { let (canonical, raw_query) = crate::l7::path::canonicalize_request_target(target, &canonicalize_options)?; let telemetry_path = secrets::redact_target_for_policy(&canonical.path) .unwrap_or_else(|_| "/[INVALID_REQUEST_TARGET]".to_string()); let upstream_target = raw_query .as_deref() .filter(|query| !query.is_empty()) .map_or_else( || canonical.path.clone(), |query| format!("{}?{query}", canonical.path), ); Ok(PreparedForwardTarget { canonical_path: canonical.path, raw_query, upstream_target, telemetry_path, }) } /// Build the HTTP/1.1 `Host` value for a plain-HTTP absolute-form target. /// /// Forward proxy requests are restricted to `http`, so port 80 is omitted as /// the default port. IPv6 literals regain the brackets removed by /// `parse_proxy_uri` before they are written as an authority. fn canonical_forward_authority(host: &str, port: u16) -> String { let host = host.to_ascii_lowercase(); let host = if host.contains(':') { format!("[{host}]") } else { host }; if port == 80 { host } else { format!("{host}:{port}") } } /// Replace every received `Host` field with the authority selected from the /// absolute-form request-target, preserving any body bytes already read. /// /// RFC 9112 section 3.2.2 requires a proxy to ignore the received `Host` field /// and generate a new value from the absolute request-target. Doing this before /// L7 and middleware processing also keeps every buffered representation tied /// to the same authority used for policy selection. fn canonicalize_forward_host_header(raw: &[u8], authority: &str) -> Result> { let header_end = raw .windows(4) .position(|window| window == b"\r\n\r\n") .ok_or_else(|| miette::miette!("HTTP request headers are missing the CRLF terminator"))? + 4; crate::l7::rest::validate_http_request_header_block(&raw[..header_end])?; let header_block = std::str::from_utf8(&raw[..header_end]) .map_err(|_| miette::miette!("HTTP headers contain invalid UTF-8"))? .strip_suffix("\r\n\r\n") .expect("validated header block has terminator"); let mut lines = header_block.split("\r\n"); let request_line = lines .next() .expect("validated header block contains a request line"); let mut output = Vec::with_capacity(raw.len() + authority.len() + 8); output.extend_from_slice(request_line.as_bytes()); output.extend_from_slice(b"\r\nHost: "); output.extend_from_slice(authority.as_bytes()); output.extend_from_slice(b"\r\n"); for line in lines { let (field_name, _) = line .split_once(':') .expect("validated header field contains colon"); if field_name.eq_ignore_ascii_case("host") { continue; } output.extend_from_slice(line.as_bytes()); output.extend_from_slice(b"\r\n"); } output.extend_from_slice(b"\r\n"); output.extend_from_slice(&raw[header_end..]); Ok(output) } /// Rewrite an absolute-form HTTP proxy request to origin-form for upstream. /// /// Transforms `GET http://host:port/path HTTP/1.1` into `GET /path HTTP/1.1`, /// strips proxy hop-by-hop headers, injects `Connection: close` and `Via`. /// /// Returns the rewritten request bytes (headers + any overflow body bytes). fn rewrite_forward_request( raw: &[u8], used: usize, path: &str, canonical_authority: &str, secret_resolver: Option<&SecretResolver>, ) -> Result, secrets::UnresolvedPlaceholderError> { let header_end = raw[..used] .windows(4) .position(|w| w == b"\r\n\r\n") .map_or(used, |p| p + 4); let websocket_upgrade = crate::l7::rest::request_is_websocket_upgrade(&raw[..header_end]); let upstream_path = match secret_resolver { Some(resolver) => secrets::rewrite_target_for_eval(path, resolver)?.resolved, None => path.to_string(), }; let header_str = String::from_utf8_lossy(&raw[..header_end]); let lines = header_str.split("\r\n").collect::>(); let connection_nominated: std::collections::HashSet = lines .iter() .skip(1) .filter_map(|line| line.split_once(':')) .filter(|(name, _)| name.eq_ignore_ascii_case("connection")) .flat_map(|(_, value)| value.split(',')) .map(|token| token.trim().to_ascii_lowercase()) .filter(|token| !token.is_empty()) .collect(); // Rebuild headers, stripping hop-by-hop and adding proxy headers let mut output = Vec::with_capacity(header_end + 128); let mut has_via = false; for (i, line) in lines.iter().enumerate() { if i == 0 { // Rewrite request line: METHOD absolute-uri HTTP/1.1 → METHOD path HTTP/1.1 let parts: Vec<&str> = line.splitn(3, ' ').collect(); if parts.len() == 3 { output.extend_from_slice(parts[0].as_bytes()); output.push(b' '); output.extend_from_slice(upstream_path.as_bytes()); output.push(b' '); output.extend_from_slice(parts[2].as_bytes()); } else { output.extend_from_slice(line.as_bytes()); } output.extend_from_slice(b"\r\n"); continue; } if line.is_empty() { // End of headers break; } let (field_name, _) = line .split_once(':') .expect("forward request passed strict ingress header validation"); let field_name = field_name.to_ascii_lowercase(); // RFC 9112 section 3.2.2 requires proxies to replace every received // Host field with one generated from the absolute request-target. if field_name == "host" { continue; } // Strip proxy hop-by-hop headers if matches!( field_name.as_str(), "proxy-connection" | "proxy-authorization" | "proxy-authenticate" ) { continue; } if connection_nominated.contains(&field_name) { continue; } // Reconstruct hop-by-hop upgrade fields after processing the originals. if field_name == "connection" || field_name == "upgrade" { continue; } let rewritten_line = match secret_resolver { Some(resolver) => rewrite_header_line_checked(line, resolver)?, None => line.to_string(), }; output.extend_from_slice(rewritten_line.as_bytes()); output.extend_from_slice(b"\r\n"); if field_name == "via" { has_via = true; } } // Generate the only Host field from the absolute request-target authority. output.extend_from_slice(b"Host: "); output.extend_from_slice(canonical_authority.as_bytes()); output.extend_from_slice(b"\r\n"); // Inject missing headers if websocket_upgrade { output.extend_from_slice(b"Connection: Upgrade\r\n"); output.extend_from_slice(b"Upgrade: websocket\r\n"); } else { output.extend_from_slice(b"Connection: close\r\n"); } if !has_via { output.extend_from_slice(b"Via: 1.1 openshell-sandbox\r\n"); } // End of headers output.extend_from_slice(b"\r\n"); let rewritten_header_end = output.len(); // Append only bytes that belong to the first request body. The initial // proxy read can also contain a pipelined follow-on request; forwarding // that as body overflow would bypass its own policy evaluation. if header_end < used { let overflow = &raw[header_end..used]; let body_prefix_len = initial_forward_body_prefix_len(&header_str, overflow); output.extend_from_slice(&overflow[..body_prefix_len]); } // Header resolution only owns headers. Body bytes from the initial read // must reach the same guarded relay as bytes read later, regardless of // whether that relay classifies literal text or explicitly rewrites it. let output_str = String::from_utf8_lossy(&output[..rewritten_header_end]); if output_str.contains(secrets::PLACEHOLDER_PREFIX_PUBLIC) || output_str.contains(secrets::PROVIDER_ALIAS_MARKER_PUBLIC) { return Err(secrets::UnresolvedPlaceholderError::unavailable("header")); } Ok(output) } fn initial_forward_body_prefix_len(header_str: &str, overflow: &[u8]) -> usize { match crate::l7::rest::parse_body_length(header_str) { Ok(crate::l7::provider::BodyLength::None) => 0, Ok(crate::l7::provider::BodyLength::ContentLength(len)) => usize::try_from(len) .unwrap_or(usize::MAX) .min(overflow.len()), Ok(crate::l7::provider::BodyLength::Chunked) => { complete_chunked_body_prefix_len(overflow).unwrap_or(overflow.len()) } // Invalid framing is rejected by the guarded relay before an upstream // body write. Keep the bytes available so that parser sees the same // malformed request instead of blocking while trying to re-read them. Err(_) => overflow.len(), } } /// Return the complete chunked body length when its terminator is already in /// the initial read. `None` means more body bytes are required. fn complete_chunked_body_prefix_len(bytes: &[u8]) -> Option { let mut pos = 0usize; loop { let line_end = bytes[pos..] .windows(2) .position(|window| window == b"\r\n")? + pos; let size_line = std::str::from_utf8(&bytes[pos..line_end]).ok()?; let size = usize::from_str_radix( size_line .split(';') .next() .map(str::trim) .unwrap_or_default(), 16, ) .ok()?; pos = line_end.checked_add(2)?; if size == 0 { loop { let trailer_end = bytes[pos..] .windows(2) .position(|window| window == b"\r\n")? + pos; let empty = trailer_end == pos; pos = trailer_end.checked_add(2)?; if empty { return Some(pos); } } } let chunk_end = pos.checked_add(size)?; let framed_end = chunk_end.checked_add(2)?; if framed_end > bytes.len() || &bytes[chunk_end..framed_end] != b"\r\n" { return None; } pos = framed_end; } } struct ForwardRelayOptions<'a> { body_classifier: Option<&'a secrets::body::BodyCredentialClassifier>, generation_guard: &'a PolicyGenerationGuard, credential_generation: Option>, websocket_extensions: crate::l7::rest::WebSocketExtensionMode, secret_resolver: Option<&'a SecretResolver>, request_body_credential_rewrite: bool, deny_uninspected_credentials: bool, credential_signing: crate::l7::CredentialSigning, signing_service: &'a str, signing_region: &'a str, host: &'a str, port: u16, response_middleware: Option>, endpoint_observer: Option<&'a crate::l7::EndpointObserver>, } struct ForwardResponseMiddleware<'a> { ctx: &'a crate::l7::relay::L7EvalContext, scheme: &'a str, exchange: &'a crate::l7::middleware::HttpMiddlewareExchange, } async fn relay_rewritten_forward_request( method: &str, path: &str, rewritten: Vec, client: &mut C, upstream: &mut U, options: ForwardRelayOptions<'_>, ) -> Result where C: TokioAsyncRead + TokioAsyncWrite + Unpin, U: TokioAsyncRead + TokioAsyncWrite + Unpin, { let header_end = rewritten .windows(4) .position(|w| w == b"\r\n\r\n") .map_or(rewritten.len(), |p| p + 4); let header_str = String::from_utf8_lossy(&rewritten[..header_end]); let body_length = crate::l7::rest::parse_body_length(&header_str)?; let (request_path, query_params) = crate::l7::rest::parse_target_query(path)?; let req = crate::l7::provider::L7Request { action: method.to_string(), target: request_path, query_params, raw_header: rewritten, body_length, }; let response_middleware = options.response_middleware.map(|middleware| { middleware .exchange .response_relay(&req, middleware.ctx, middleware.scheme) }); crate::l7::rest::relay_http_request_with_response_middleware_guarded_observed( &req, client, upstream, crate::l7::rest::RelayRequestOptions { resolver: options.secret_resolver, body_classifier: options.body_classifier, credential_generation: options.credential_generation, generation_guard: Some(options.generation_guard), websocket_extensions: options.websocket_extensions, request_body_credential_rewrite: options.request_body_credential_rewrite, deny_uninspected_credentials: options.deny_uninspected_credentials, credential_signing: options.credential_signing, signing_service: options.signing_service, signing_region: options.signing_region, host: options.host, port: options.port, }, response_middleware, options.endpoint_observer, ) .await } async fn inject_token_grant_for_forward_request( method: &str, upstream_target: &str, forward_request_bytes: Vec, l7_ctx: &crate::l7::relay::L7EvalContext, ) -> Result> { let header_end = forward_request_bytes .windows(4) .position(|w| w == b"\r\n\r\n") .map_or(forward_request_bytes.len(), |p| p + 4); let header_str = std::str::from_utf8(&forward_request_bytes[..header_end]) .into_diagnostic() .map_err(|_| miette::miette!("Forward HTTP headers contain invalid UTF-8"))?; let body_length = crate::l7::rest::parse_body_length(header_str)?; let forward_request_for_token_grant = crate::l7::provider::L7Request { action: method.to_string(), target: upstream_target.to_string(), query_params: std::collections::HashMap::new(), raw_header: forward_request_bytes, body_length, }; crate::l7::token_grant_injection::inject_if_needed(forward_request_for_token_grant, l7_ctx) .await .map(|req| req.raw_header) } /// Handle a plain HTTP forward proxy request (non-CONNECT). /// /// Public IPs are allowed through when the endpoint passes OPA evaluation. /// Private IPs require explicit `allowed_ips` on the endpoint config (SSRF /// override). Rewrites the absolute-form request to origin-form, connects /// upstream, and relays the request/response using the guarded HTTP relay. // Many distinct, non-related context parameters are required for forward proxy // dispatch; bundling them into a struct would just shift the noise into call sites. #[allow(clippy::too_many_arguments)] async fn handle_forward_proxy( method: &str, target_uri: &str, buf: &[u8], used: usize, client: &mut ProxyClient, supplied_identity: Option<&Result>, socket_addrs: Option<(SocketAddr, SocketAddr)>, opa_engine: Arc, identity_cache: Arc, entrypoint_pid: Arc, policy_local_ctx: Option>, agent_proposals: openshell_core::proposals::AgentProposals, backend_host_gateway: Arc>, trusted_host_gateway: Arc>, provider_credentials: Option, secret_resolver: Option>, dynamic_credentials: Option< Arc< std::sync::RwLock< std::collections::HashMap, >, >, >, denial_tx: Option<&mpsc::UnboundedSender>, activity_tx: Option<&ActivitySender>, endpoint_observation_tx: Option, ) -> Result<()> { let endpoint_observation_context = endpoint_observation_tx .as_ref() .and_then(EndpointObservationSender::capture); let mut endpoint_observer = None; let workload_peer_addr = socket_addrs.map(|(workload, _)| workload); // The connection handlers below require a workload address for policy and // accounting. OCSF events must instead use `workload_peer_addr`, because // the unspecified fallback is not an observed network endpoint. let workload_addr = workload_peer_addr.unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0))); let mut telemetry_path = forward_telemetry_path(target_uri); // 1. Parse the absolute-form URI. Every external forward target is // canonicalized below before credential binding, policy-path evaluation, // upstream bytes, or telemetry consume it. let Ok((scheme, host, port, mut path)) = parse_proxy_uri(target_uri) else { ocsf_emit!(build_forward_parse_error_ocsf_event( workload_peer_addr, method, &telemetry_path )); respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; return Ok(()); }; let raw_host = host; let host = normalize_host(&raw_host); let host_lc = host.to_ascii_lowercase(); if host_lc == POLICY_LOCAL_HOST { if scheme != "http" || port != 80 { respond( client, &build_json_error_response( 400, "Bad Request", "invalid_policy_local_scheme", "Use http://policy.local only", ), ) .await?; return Ok(()); } if let Some(ctx) = policy_local_ctx { return crate::policy_local::handle_forward_request( &ctx, method, &path, &buf[..used], client, ) .await; } respond( client, b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 31\r\n\r\npolicy.local is not configured", ) .await?; return Ok(()); } if scheme != "http" { let event = build_forward_unsupported_scheme_ocsf_event(method, &scheme, &host_lc, port); ocsf_emit!(event); if scheme == "https" { respond( client, b"HTTP/1.1 400 Bad Request\r\nContent-Length: 27\r\n\r\nUse CONNECT for HTTPS URLs", ) .await?; } else { respond( client, &build_json_error_response( 400, "Bad Request", "unsupported_proxy_scheme", "Forward proxy requests must use http", ), ) .await?; } return Ok(()); } let canonical_authority = canonical_forward_authority(&host_lc, port); let mut forward_request_bytes = canonicalize_forward_host_header(&buf[..used], &canonical_authority)?; // 2. Evaluate OPA policy (same identity binding as CONNECT) let intent = EgressIntent::forward_http(host_lc.clone(), port); let mut decision = if let Some(identity) = supplied_identity { authorize_supplied_identity(&opa_engine, intent, identity) } else if !opa_engine.binary_identity_required() { evaluate_endpoint_only_opa(&opa_engine, intent) } else { let (workload_addr, proxy_addr) = socket_addrs.ok_or_else(|| { miette::miette!("legacy proxy connection is missing socket addresses") })?; let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); let opa_clone = opa_engine.clone(); let cache_clone = identity_cache.clone(); let pid_clone = entrypoint_pid.clone(); tokio::task::spawn_blocking(move || { authorize_egress_intent(connection, &opa_clone, &cache_clone, &pid_clone, intent) }) .await .map_err(|e| miette::miette!("identity resolution task panicked: {e}"))? }; debug!( transport = ?decision.intent.transport, identity = ?decision.identity, "Authorized explicit proxy egress intent" ); // Build log context let binary_str = decision .binary .as_ref() .map_or_else(|| "-".to_string(), |p| p.display().to_string()); let pid_str = decision .binary_pid .map_or_else(|| "-".to_string(), |p| p.to_string()); let ancestors_str = if decision.ancestors.is_empty() { "-".to_string() } else { decision .ancestors .iter() .map(|p| p.display().to_string()) .collect::>() .join(" -> ") }; let cmdline_str = if decision.cmdline_paths.is_empty() { "-".to_string() } else { decision .cmdline_paths .iter() .map(|p| p.display().to_string()) .collect::>() .join(", ") }; // 4. Only proceed on explicit Allow — reject Deny let matched_policy = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.clone(), NetworkAction::Deny { reason } => { ocsf_emit!(build_forward_policy_deny_ocsf_event( workload_addr, method, &host_lc, port, &telemetry_path, &binary_str, &pid_str, &ancestors_str, &cmdline_str, reason, )); emit_denial_simple( denial_tx, &host_lc, port, &binary_str, &decision, reason, "forward", ); emit_activity_simple(activity_tx, true, "forward_policy"); respond( client, &build_json_error_response( 403, "Forbidden", "policy_denied", &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; return Ok(()); } }; let policy_str = matched_policy.as_deref().unwrap_or("-"); debug!( host = %host_lc, port, binary = %binary_str, binary_pid = %pid_str, matched_policy = %policy_str, policy_generation = decision.policy_generation, current_generation = opa_engine.current_generation(), action = ?decision.action, "Forward proxy L4 policy decision" ); let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); let forward_generation_guard = match relay::pin_policy_generation( &opa_engine, decision.policy_generation, ) { Ok(guard) => guard, Err(e) => { warn!( host = %host_lc, port, policy_generation = decision.policy_generation, current_generation = opa_engine.current_generation(), error = %e, "Forward proxy rejected request because policy generation changed after L4 decision" ); emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, &build_json_error_response( 403, "Forbidden", "policy_denied", &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; return Ok(()); } }; let mut websocket_extensions = crate::l7::rest::WebSocketExtensionMode::Preserve; let mut forward_tunnel_engine: Option = None; // L7 endpoint config and evaluated request info, carried past the L7 // block so a middleware-transformed body can be re-evaluated against the // same policy inputs before it is forwarded. let mut forward_l7_reeval: Option<(crate::l7::L7EndpointConfig, crate::l7::L7RequestInfo)> = None; let mut forward_upgrade_config: Option = None; let mut forward_upgrade_target = String::new(); let mut forward_upgrade_query_params = std::collections::HashMap::new(); let mut forward_websocket_request = crate::l7::rest::request_is_websocket_upgrade(&forward_request_bytes); let mut request_body_credential_rewrite = false; let mut deny_uninspected_credentials = false; let mut l7_activity_pending = false; // 4b. If the endpoint has L7 config, evaluate the request against // L7 policy. The forward proxy handles exactly one request per // connection, so a single evaluation suffices. The shared HTTP relay // strips hop-by-hop `Connection` headers and drops the upstream after // the response instead of asking the upstream to close it. hydrate_l7_route(&mut decision); let canonicalize_options = crate::l7::path::CanonicalizeOptions { allow_encoded_slash: decision.endpoint.l7_route.as_ref().is_some_and(|route| { route .configs .iter() .any(|snapshot| snapshot.config.allow_encoded_slash) }), ..Default::default() }; let prepared_target = match prepare_forward_target(&path, canonicalize_options) { Ok(prepared) => prepared, Err(error) => { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .message(format!( "FORWARD rejecting non-canonical request-target: {error}" )) .build(); ocsf_emit!(event); emit_activity_simple(activity_tx, true, "forward_parse_rejection"); respond( client, &build_json_error_response( 400, "Bad Request", "invalid_request_target", "request-target must be canonical", ), ) .await?; return Ok(()); } }; path = prepared_target.canonical_path; telemetry_path = prepared_target.telemetry_path; let upstream_target = prepared_target.upstream_target; let query_params = prepared_target .raw_query .as_deref() .map_or_else(std::collections::HashMap::new, |query| { crate::l7::rest::parse_query_params(query).unwrap_or_default() }); let workspace = policy_local_ctx .as_ref() .map(|ctx| ctx.workspace()) .unwrap_or_default(); let mut l7_ctx = relay::http_context( &decision, provider_credentials, secret_resolver.clone(), dynamic_credentials.clone(), agent_proposals, workspace, relay::RelaySignals { activity: activity_tx.cloned(), endpoint_observation: endpoint_observation_tx, }, ); l7_ctx.request_default_port = match scheme.as_str() { "http" => Some(80), "https" => Some(443), _ => None, }; if let Some(route) = decision .endpoint .l7_route .as_ref() .filter(|route| !route.configs.is_empty()) { if route.l7_policy_generation != forward_generation_guard.captured_generation() { warn!( host = %host_lc, port, policy_generation = decision.policy_generation, l4_guard_generation = forward_generation_guard.captured_generation(), l7_policy_generation = route.l7_policy_generation, current_generation = opa_engine.current_generation(), "Forward proxy rejected request because L7 route lookup used a different policy generation" ); emit_l7_tunnel_close_after_policy_change( &host_lc, port, miette::miette!( "policy changed before forward L7 evaluation [expected_generation:{} current_generation:{}]", forward_generation_guard.captured_generation(), route.l7_policy_generation, ), ); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, &build_json_error_response( 403, "Forbidden", "policy_denied", &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; return Ok(()); } let tunnel_engine = match relay::pin_l7_evaluator(&opa_engine, route.l7_policy_generation) { Ok(engine) => engine, Err(e) => { warn!( host = %host_lc, port, l7_policy_generation = route.l7_policy_generation, current_generation = opa_engine.current_generation(), error = %e, "Forward proxy rejected request because L7 tunnel engine could not be cloned" ); emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, &build_json_error_response( 403, "Forbidden", "policy_denied", &format!( "{method} {host_lc}:{port}{telemetry_path} not permitted by policy" ), ), ) .await?; return Ok(()); } }; let Ok(redacted_path) = secrets::redact_target_for_policy(&path) else { respond( client, &build_json_error_response( 400, "Bad Request", "invalid_credential_placeholder", "request-target contains an invalid credential placeholder", ), ) .await?; return Ok(()); }; let Some(l7_config) = select_l7_config_for_path(&route.configs, &redacted_path) else { emit_activity_simple(activity_tx, true, "l7_policy"); respond( client, &build_json_error_response( 403, "Forbidden", "policy_denied", &format!( "{method} {host_lc}:{port}{telemetry_path} did not match an L7 endpoint path" ), ), ) .await?; return Ok(()); }; endpoint_observer = crate::l7::EndpointObserver::begin_captured( l7_ctx.endpoint_observation_tx.as_ref(), &l7_config.config, endpoint_observation_context.as_ref(), None, Some(&forward_generation_guard), ); // `canonicalize_options` was built before the matching config was // known, so `allow_encoded_slash` was taken permissively across every // config on this route. Re-check it against the config that actually // matched: the opt-in is per-endpoint, and one endpoint enabling it // must not loosen parsing for the others. Rejecting here yields the // same response the parser would have produced had the option been // scoped correctly from the start. if !l7_config.config.allow_encoded_slash && crate::l7::path::canonical_path_has_encoded_slash(&path) { ocsf_emit!(build_forward_l7_parse_rejection_ocsf_event( workload_addr, method, &host_lc, port, &telemetry_path, &binary_str, &pid_str, &ancestors_str, &cmdline_str, policy_str, FORWARD_ENCODED_SLASH_REJECTION_DETAIL, )); emit_activity_simple(activity_tx, true, "forward_parse_rejection"); respond( client, &build_json_error_response( 400, "Bad Request", "invalid_request_target", "request-target must be canonical", ), ) .await?; return Ok(()); } if crate::l7::rest::request_is_h2c_upgrade(&forward_request_bytes) { let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) .action(ActionId::Denied) .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) .http_request(HttpRequest::new( method, OcsfUrl::new("http", &host_lc, &telemetry_path, port), )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(workload_addr.ip(), workload_addr.port())) .actor_process( Process::from_bypass(&binary_str, &pid_str, &ancestors_str) .with_cmd_line(&cmdline_str), ) .firewall_rule(policy_str, "l7") .message(format!( "FORWARD_L7 denied unsupported h2c upgrade for {method} {host_lc}:{port}{telemetry_path}" )) .status_detail(crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL) .build(); ocsf_emit!(event); emit_activity_simple(activity_tx, true, "l7_parse_rejection"); emit_denial_simple( denial_tx, &host_lc, port, &binary_str, &decision, crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL, "forward-l7-parse-rejection", ); respond( client, &build_json_error_response( 403, "Forbidden", "unsupported_l7_protocol", crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL, ), ) .await?; return Ok(()); } forward_websocket_request = crate::l7::rest::request_is_websocket_upgrade(&forward_request_bytes); websocket_extensions = crate::l7::relay::websocket_extension_mode(&l7_config.config, false); request_body_credential_rewrite = l7_config.config.protocol == crate::l7::L7Protocol::Rest && l7_config.config.request_body_credential_rewrite; deny_uninspected_credentials = l7_config .config .deny_uninspected_body_credentials(secret_resolver.is_some()); forward_upgrade_config = Some(l7_config.config.clone()); forward_upgrade_target = path.clone(); forward_upgrade_query_params = query_params.clone(); let graphql = if l7_config.config.protocol == crate::l7::L7Protocol::Graphql { let header_end = forward_request_bytes .windows(4) .position(|w| w == b"\r\n\r\n") .map_or(forward_request_bytes.len(), |p| p + 4); let header_str = std::str::from_utf8(&forward_request_bytes[..header_end]) .map_err(|_| miette::miette!("Forward GraphQL headers contain invalid UTF-8"))?; let body_length = crate::l7::rest::parse_body_length(header_str)?; let mut graphql_request = crate::l7::provider::L7Request { action: method.to_string(), target: path.clone(), query_params: query_params.clone(), raw_header: forward_request_bytes, body_length, }; let info = match crate::l7::graphql::inspect_graphql_request( client, &mut graphql_request, l7_config.config.graphql_max_body_bytes, ) .await { Ok(info) => info, Err(e) => { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .message(format!("FORWARD_GRAPHQL_L7 request rejected: {e}")) .build(); ocsf_emit!(event); emit_activity_simple(activity_tx, true, "l7_parse_rejection"); respond( client, &build_json_error_response( 400, "Bad Request", "invalid_graphql_request", &format!("GraphQL request rejected before policy evaluation: {e}"), ), ) .await?; return Ok(()); } }; forward_request_bytes = graphql_request.raw_header; Some(info) } else { None }; let jsonrpc = if l7_config.config.protocol.is_jsonrpc_family() { let header_end = forward_request_bytes .windows(4) .position(|w| w == b"\r\n\r\n") .map_or(forward_request_bytes.len(), |p| p + 4); let header_str = std::str::from_utf8(&forward_request_bytes[..header_end]) .map_err(|_| miette::miette!("Forward JSON-RPC headers contain invalid UTF-8"))?; let body_length = crate::l7::rest::parse_body_length(header_str)?; let mut jsonrpc_request = crate::l7::provider::L7Request { action: method.to_string(), target: path.clone(), query_params: query_params.clone(), raw_header: forward_request_bytes, body_length, }; let info = if crate::l7::jsonrpc::jsonrpc_receive_stream_request(&jsonrpc_request) { crate::l7::jsonrpc::JsonRpcRequestInfo::receive_stream() } else { let body = match crate::l7::http::read_body_for_inspection( client, &mut jsonrpc_request, l7_config.config.json_rpc_max_body_bytes, ) .await { Ok(body) => body, Err(e) => { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .severity(SeverityId::Medium) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .message(format!("FORWARD_JSONRPC_L7 request rejected: {e}")) .build(); ocsf_emit!(event); emit_activity_simple(activity_tx, true, "l7_parse_rejection"); respond( client, &build_json_error_response( 400, "Bad Request", "invalid_jsonrpc_request", &format!("JSON-RPC request rejected before policy evaluation: {e}"), ), ) .await?; return Ok(()); } }; crate::l7::jsonrpc::parse_jsonrpc_body_with_options( &body, crate::l7::jsonrpc::JsonRpcInspectionOptions::for_config(&l7_config.config), ) }; // Forward HTTP shares the MCP transport gate with CONNECT before // method authorization. Borrow the buffered request so checking // the version does not copy the inspected body. if !crate::l7::relay::enforce_mcp_protocol_version( &l7_config.config, &jsonrpc_request, &info, client, &l7_ctx, &telemetry_path, endpoint_observer.as_ref(), ) .await? { return Ok(()); } forward_request_bytes = jsonrpc_request.raw_header; Some(info) } else { None }; let request_info = crate::l7::L7RequestInfo { action: method.to_string(), target: redacted_path, query_params, graphql, jsonrpc, }; let hard_deny_reason = crate::l7::relay::l7_request_hard_deny_reason(l7_config.config.protocol, &request_info); let force_deny = hard_deny_reason.is_some(); let (allowed, reason) = hard_deny_reason.map_or_else( || { crate::l7::relay::evaluate_l7_request(&tunnel_engine, &l7_ctx, &request_info) .unwrap_or_else(|e| { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .message(format!("L7 eval failed, denying request: {e}")) .build(); ocsf_emit!(event); (false, format!("L7 evaluation error: {e}")) }) }, |reason| (false, reason), ); let decision_str = match (allowed, l7_config.config.enforcement) { (_, _) if force_deny => "deny", (true, _) => "allow", (false, crate::l7::EnforcementMode::Audit) => "audit", (false, crate::l7::EnforcementMode::Enforce) => "deny", }; { let (action_id, disposition_id, severity) = match decision_str { "deny" => (ActionId::Denied, DispositionId::Blocked, SeverityId::Medium), "allow" | "audit" => ( ActionId::Allowed, DispositionId::Allowed, SeverityId::Informational, ), _ => ( ActionId::Other, DispositionId::Other, SeverityId::Informational, ), }; let engine_type = match l7_config.config.protocol { crate::l7::L7Protocol::Graphql => "l7-graphql", crate::l7::L7Protocol::JsonRpc => "l7-jsonrpc", crate::l7::L7Protocol::Mcp => "l7-mcp", _ => "l7", }; let log_message = request_info.jsonrpc.as_ref().map_or_else( || { let message_prefix = if l7_config.config.protocol == crate::l7::L7Protocol::Graphql { "FORWARD_GRAPHQL_L7" } else { "FORWARD_L7" }; format!( "{message_prefix} {decision_str} {method} {host_lc}:{port}{telemetry_path} reason={reason}" ) }, |jsonrpc_info| { let endpoint = format!("{host_lc}:{port}{telemetry_path}"); crate::l7::relay::jsonrpc_log_message( decision_str, method, &endpoint, jsonrpc_info, tunnel_engine.captured_generation(), &reason, ) }, ); let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) .action(action_id) .disposition(disposition_id) .severity(severity) .http_request(HttpRequest::new( method, OcsfUrl::new("http", &host_lc, &telemetry_path, port), )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(workload_addr.ip(), workload_addr.port())) .actor_process( Process::from_bypass(&binary_str, &pid_str, &ancestors_str) .with_cmd_line(&cmdline_str), ) .firewall_rule(policy_str, engine_type) .message(log_message) .build(); ocsf_emit!(event); } let effectively_denied = force_deny || (!allowed && l7_config.config.enforcement == crate::l7::EnforcementMode::Enforce); if effectively_denied { emit_activity_simple(activity_tx, true, "l7_policy"); emit_denial_simple( denial_tx, &host_lc, port, &binary_str, &decision, &reason, "forward-l7-deny", ); respond( client, &build_json_error_response( 403, "Forbidden", "policy_denied", &format!( "{method} {host_lc}:{port}{telemetry_path} denied by L7 policy: {reason}" ), ), ) .await?; return Ok(()); } l7_activity_pending = true; forward_tunnel_engine = Some(tunnel_engine); forward_l7_reeval = Some((l7_config.config.clone(), request_info)); } // 5. DNS resolution + SSRF defence (mirrors the CONNECT path logic). // - If the host is a driver-injected host-gateway alias: bypass SSRF // tiers and validate only against the trusted gateway IP. // - If allowed_ips is set: validate resolved IPs against the allowlist // (this is the SSRF override for private IP destinations). // - If the endpoint is an exact declared hostname: allow private IPs, // but still reject always-blocked addresses and control-plane ports. // - Otherwise: reject internal IPs, allow public IPs through. // When the policy host is already a literal IP address, treat it as // implicitly allowed — the user explicitly declared the destination. match hydrate_destination_plan(&mut decision, *backend_host_gateway, *trusted_host_gateway) { Ok(()) => {} Err(denial) => { deny_forward_destination( client, &denial, workload_addr, method, &host_lc, port, &telemetry_path, &binary_str, &pid_str, &ancestors_str, &cmdline_str, policy_str, &decision, denial_tx, activity_tx, ) .await?; return Ok(()); } } let destination_plan = decision .endpoint .destination .as_ref() .expect("destination plan hydrated"); let connector = match validate_destination(DestinationRequest { host: &raw_host, port, sandbox_entrypoint_pid, plan: destination_plan, }) .await { Ok(connector) => connector, Err(denial) => { deny_forward_destination( client, &denial, workload_addr, method, &host_lc, port, &telemetry_path, &binary_str, &pid_str, &ancestors_str, &cmdline_str, policy_str, &decision, denial_tx, activity_tx, ) .await?; return Ok(()); } }; if let Err(e) = forward_generation_guard.ensure_current() { warn!( host = %host_lc, port, captured_generation = forward_generation_guard.captured_generation(), current_generation = forward_generation_guard.current_generation(), error = %e, "Forward proxy rejected request because policy changed before upstream connect" ); emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); emit_activity_simple(activity_tx, true, "policy_stale"); respond( client, &build_json_error_response( 403, "Forbidden", "policy_denied", &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; return Ok(()); } let middleware_path = path.split_once('?').map_or(path.as_str(), |(path, _)| path); let middleware_input = crate::opa::NetworkInput { host: host_lc.clone(), port, binary_path: decision.binary.clone().unwrap_or_default(), binary_sha256: String::new(), ancestors: decision.ancestors.clone(), cmdline_paths: decision.cmdline_paths.clone(), }; let (chain, generation) = opa_engine.query_middleware_chain_with_generation(&middleware_input)?; if generation != forward_generation_guard.captured_generation() { emit_l7_tunnel_close_after_policy_change( &host_lc, port, miette::miette!( "policy changed before forward middleware evaluation [expected_generation:{} current_generation:{}]", forward_generation_guard.captured_generation(), generation, ), ); respond( client, &build_json_error_response( 403, "Forbidden", "policy_denied", &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; return Ok(()); } let request_id = uuid::Uuid::new_v4().to_string(); let websocket_chain = forward_websocket_request.then(|| chain.clone()); let response_selection = if chain.is_empty() { None } else { let middleware_runner = opa_engine.middleware_runner()?; let request = crate::l7::rest::request_from_buffered_http( method, middleware_path, &upstream_target, forward_request_bytes, )?; let l7_reevaluation = match (forward_l7_reeval.as_ref(), forward_tunnel_engine.as_ref()) { (Some((config, request_info)), Some(engine)) => Some(ForwardL7Reevaluation { config, engine, request_info, }), _ => None, }; let middleware_exchange = crate::l7::middleware::HttpMiddlewareExchange::new( request_id.clone(), chain, middleware_runner, forward_generation_guard.clone(), ); let pipeline = ForwardMiddlewarePipeline { ctx: &l7_ctx, scheme: &scheme, exchange: &middleware_exchange, l7_reevaluation, }; forward_request_bytes = match pipeline.apply(request, client).await? { crate::l7::middleware::MiddlewareApplyResult::Allowed(request) => request.raw_header, crate::l7::middleware::MiddlewareApplyResult::Denied { denial, .. } => { emit_activity_simple(activity_tx, true, "middleware"); let response = denial.as_ref().map_or_else( || build_middleware_failure_response(&l7_ctx.policy_name), |denial| build_middleware_deny_response(&l7_ctx.policy_name, denial), ); respond(client, &response).await?; return Ok(()); } crate::l7::middleware::MiddlewareApplyResult::AdmissionExhausted => { emit_activity_simple(activity_tx, true, "middleware"); let response = build_middleware_unavailable_response(&l7_ctx.policy_name); respond(client, &response).await?; return Ok(()); } }; Some(middleware_exchange) }; let mut middleware_session = if let Some(chain) = websocket_chain.as_deref() { let request = crate::l7::rest::request_from_buffered_http( method, middleware_path, &upstream_target, forward_request_bytes.clone(), )?; let middleware_runner = opa_engine.middleware_runner()?; let preflight = crate::l7::relay::websocket_middleware_preflight( &request, chain, &middleware_runner, &l7_ctx, "ws", ) .await; let preflight = match preflight { Ok(preflight) => preflight, Err(error) => { warn!(error = %error, "Plaintext WebSocket middleware preflight failed"); respond( client, &build_json_error_response( 502, "Bad Gateway", "middleware_failed", "WebSocket middleware preflight failed", ), ) .await?; return Ok(()); } }; crate::l7::middleware::emit_websocket_preflight_events(&l7_ctx, &preflight); if preflight.terminal_reason.is_some() { let response = preflight.denial.as_ref().map_or_else( || build_middleware_failure_response(&l7_ctx.policy_name), |denial| build_middleware_deny_response(&l7_ctx.policy_name, denial), ); respond(client, &response).await?; return Ok(()); } preflight.session } else { None }; if middleware_session.is_some() { websocket_extensions = crate::l7::rest::WebSocketExtensionMode::PermessageDeflate; } forward_request_bytes = match inject_token_grant_for_forward_request( method, &upstream_target, forward_request_bytes, &l7_ctx, ) .await { Ok(bytes) => bytes, Err(e) => { warn!( dst_host = %host_lc, dst_port = port, error = %e, "token grant failed in forward proxy" ); if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) .await; } respond( client, &build_json_error_response( 502, "Bad Gateway", "token_grant_failed", "dynamic token grant failed", ), ) .await?; return Ok(()); } }; // Static credentials are intentionally acquired only after every // asynchronous admission step. Holding an endpoint-scoped resolver across // middleware or token-grant awaits would let a revoked generation reach // the upstream. let endpoint_credentials = endpoint_credentials_for_request( l7_ctx.provider_credentials.as_ref(), l7_ctx.secret_resolver.clone(), &host_lc, port, &path, ); let secret_resolver = endpoint_credentials.resolver; let credential_generation = match ( l7_ctx.provider_credentials.as_ref(), endpoint_credentials.revision, ) { (Some(state), Some(revision)) => Some(crate::l7::rest::CredentialGenerationGuard::new( state, revision, )), _ => None, }; if let Some(guard) = credential_generation { guard.ensure_current()?; } // 9. Rewrite request and forward to upstream let rewritten = match rewrite_forward_request( &forward_request_bytes, forward_request_bytes.len(), &upstream_target, &canonical_authority, secret_resolver.as_deref(), ) { Ok(bytes) => bytes, Err(e) => { warn!( dst_host = %host_lc, dst_port = port, error = %e, "credential injection failed in forward proxy" ); if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) .await; } if e.is_endpoint_mismatch() { if let Some(observer) = endpoint_observer.as_ref() { observer.observe_credential_failure(true); } emit_credential_endpoint_mismatch(method, &host_lc, port, policy_str); respond( client, &build_json_error_response( 403, "Forbidden", "credential_endpoint_mismatch", "credential is not authorized for this request endpoint", ), ) .await?; client.shutdown().await.into_diagnostic()?; let mut discard = [0_u8; 1024]; let _ = tokio::time::timeout(std::time::Duration::from_secs(1), async { loop { match client.read(&mut discard).await { Ok(0) | Err(_) => break, Ok(_) => {} } } }) .await; } else { respond( client, &build_json_error_response( 500, "Internal Server Error", "credential_injection_failed", "unresolved credential placeholder in request", ), ) .await?; } return Ok(()); } }; // Middleware and credential rewriting can change request headers. Check // the final origin-form bytes after hop-by-hop sanitization, before an // upstream connection exists, so forwarding preserves the MCP decision. let rewritten = match forward_l7_reeval.as_ref() { Some((config, _)) if config.protocol == crate::l7::L7Protocol::Mcp => { let request = crate::l7::rest::request_from_buffered_http( method, middleware_path, &upstream_target, rewritten, )?; if !crate::l7::relay::enforce_final_mcp_protocol_version( config, &request, client, &l7_ctx, &telemetry_path, endpoint_observer.as_ref(), ) .await? { if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) .await; } return Ok(()); } request.raw_header } _ => rewritten, }; if let Err(e) = forward_generation_guard.ensure_current() { warn!( host = %host_lc, port, captured_generation = forward_generation_guard.captured_generation(), current_generation = forward_generation_guard.current_generation(), error = %e, "Forward proxy rejected request because policy changed before relay" ); emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) .await; } respond( client, &build_json_error_response( 403, "Forbidden", "policy_denied", &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; return Ok(()); } // Plain-HTTP requests dial the destination directly: only TLS (CONNECT) // tunnels chain through the corporate proxy, since plain-HTTP forwarding // would need absolute-form requests rather than a CONNECT tunnel. Dial // only after every local authorization and transformation step so a // rejected WebSocket preflight cannot contact the destination. let dial_result = connector.connect().await; let mut upstream = match dial_result { Ok(s) => s, Err(e) => { let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .severity(SeverityId::Low) .status(StatusId::Failure) .http_request(HttpRequest::new( method, OcsfUrl::new("http", &host_lc, &path, port), )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(workload_addr.ip(), workload_addr.port())) .actor_process( Process::from_bypass(&binary_str, &pid_str, &ancestors_str) .with_cmd_line(&cmdline_str), ) .message(format!( "FORWARD upstream connect failed for {host_lc}:{port}: {e}" )) .build(); ocsf_emit!(event); if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::UpstreamFailure) .await; } respond( client, &build_json_error_response( 502, "Bad Gateway", "upstream_unreachable", &format!("connection to {host_lc}:{port} failed"), ), ) .await?; return Ok(()); } }; if let Err(e) = forward_generation_guard.ensure_current() { warn!( host = %host_lc, port, captured_generation = forward_generation_guard.captured_generation(), current_generation = forward_generation_guard.current_generation(), error = %e, "Forward proxy rejected request because policy changed during upstream connect" ); emit_l7_tunnel_close_after_policy_change(&host_lc, port, e); if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) .await; } respond( client, &build_json_error_response( 403, "Forbidden", "policy_denied", &format!("{method} {host_lc}:{port}{path} not permitted by policy"), ), ) .await?; return Ok(()); } let credential_signing = forward_upgrade_config .as_ref() .map_or(crate::l7::CredentialSigning::None, |config| { config.credential_signing }); let signing_service = forward_upgrade_config .as_ref() .map_or("", |config| config.signing_service.as_str()); let signing_region = forward_upgrade_config .as_ref() .map_or("", |config| config.signing_region.as_str()); let outcome_result = relay_rewritten_forward_request( method, &upstream_target, rewritten, client, &mut upstream, ForwardRelayOptions { generation_guard: &forward_generation_guard, credential_generation, websocket_extensions, secret_resolver: secret_resolver.as_deref(), body_classifier: endpoint_credentials.body_classifier.as_deref(), request_body_credential_rewrite, deny_uninspected_credentials, credential_signing, signing_service, signing_region, host: &host_lc, port, response_middleware: response_selection.as_ref().map(|exchange| { ForwardResponseMiddleware { ctx: &l7_ctx, scheme: &scheme, exchange, } }), endpoint_observer: endpoint_observer.as_ref(), }, ) .await; let outcome_result = match outcome_result { Err(report) => { if let Some(error) = report.downcast_ref::() { if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) .await; } let _ = upstream.shutdown().await; crate::l7::relay::reject_body_credential(client, *error).await?; return Ok(()); } if let Some(error) = report.downcast_ref::() { if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) .await; } crate::l7::relay::reject_credential_resolution(client, &l7_ctx, method, error) .await?; return Ok(()); } Err(report) } outcome => outcome, }; let outcome = crate::l7::relay::finalize_websocket_pre_upgrade( &mut middleware_session, &forward_generation_guard, &host_lc, port, policy_str, outcome_result, ) .await?; // The request has now survived middleware, token grant, credential // rewriting, generation checks, and the HTTP relay. Only now record the // final allowed outcome. ocsf_emit!(build_forward_allow_ocsf_event( workload_addr, method, &host_lc, port, &telemetry_path, &binary_str, &pid_str, &ancestors_str, &cmdline_str, policy_str, )); emit_forward_success_activity(activity_tx, l7_activity_pending); match outcome { crate::l7::provider::RelayOutcome::Reusable | crate::l7::provider::RelayOutcome::Consumed => { if let Some(session) = middleware_session.take() { session .end(openshell_core::proto::MiddlewareSessionEndReason::UpstreamFailure) .await; } } crate::l7::provider::RelayOutcome::Upgraded { overflow, websocket_permessage_deflate, websocket_subprotocol, } => { let mut upgrade_options = if let (Some(config), Some(engine)) = ( forward_upgrade_config.as_ref(), forward_tunnel_engine.as_ref(), ) { crate::l7::relay::upgrade_options( config, &l7_ctx, forward_websocket_request, &forward_upgrade_target, &forward_upgrade_query_params, Some(engine), ) } else { crate::l7::relay::UpgradeRelayOptions { websocket_request: forward_websocket_request, ctx: Some(&l7_ctx), policy_name: l7_ctx.policy_name.clone(), ..Default::default() } }; upgrade_options.generation_guard = Some(&forward_generation_guard); upgrade_options.assembly_budget = Some(opa_engine.websocket_assembly_budget()); upgrade_options.websocket.permessage_deflate = websocket_permessage_deflate; upgrade_options.middleware_session = middleware_session.take(); upgrade_options.selected_subprotocol = websocket_subprotocol; crate::l7::relay::handle_upgrade( client, &mut upstream, overflow, &host_lc, port, upgrade_options, ) .await?; } } Ok(()) } /// Parse a CONNECT authority into `(host, port)`. /// /// IPv6 literals use the RFC 3986 bracketed form (`[::1]:443`); the returned /// host is bracket-free, matching what DNS resolution, SSRF validation, and /// `NO_PROXY` matching expect. Host content is otherwise passed through /// unvalidated — policy and resolution decide what it means. fn parse_target(target: &str) -> Result<(String, u16)> { let (host, port_str) = if let Some(rest) = target.strip_prefix('[') { let (host, after) = rest .split_once(']') .ok_or_else(|| miette::miette!("CONNECT target has unclosed '[': {target}"))?; let port_str = after .strip_prefix(':') .ok_or_else(|| miette::miette!("CONNECT target missing port: {target}"))?; (host, port_str) } else { target .split_once(':') .ok_or_else(|| miette::miette!("CONNECT target missing port: {target}"))? }; let port: u16 = port_str .parse() .map_err(|_| miette::miette!("Invalid port in CONNECT target: {target}"))?; Ok((host.to_string(), port)) } fn normalize_host(raw_host: &str) -> &str { raw_host.strip_suffix('.').unwrap_or(raw_host) } async fn respond(client: &mut (impl TokioAsyncWrite + Unpin), bytes: &[u8]) -> Result<()> { client.write_all(bytes).await.into_diagnostic()?; client.flush().await.into_diagnostic()?; Ok(()) } /// Build an HTTP error response with a JSON body. /// /// Returns bytes ready to write to the client socket. The body is a JSON /// object with `error` and `detail` fields, matching the format used by the /// L7 deny path in `l7/rest.rs`. fn build_json_error_response(status: u16, status_text: &str, error: &str, detail: &str) -> Vec { let body = serde_json::json!({ "error": error, "detail": detail, }); let body_str = body.to_string(); format!( "HTTP/1.1 {status} {status_text}\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ Connection: close\r\n\ \r\n\ {}", body_str.len(), body_str, ) .into_bytes() } fn build_json_error_response_with_reason( status: u16, status_text: &str, error: &str, detail: &str, reason: &str, ) -> Vec { let mut body = serde_json::json!({ "error": error, "detail": detail, }); if !reason.is_empty() { body["reason"] = serde_json::json!(reason); } let body_str = body.to_string(); format!( "HTTP/1.1 {status} {status_text}\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ Connection: close\r\n\ \r\n\ {}", body_str.len(), body_str, ) .into_bytes() } fn build_middleware_deny_response( policy_name: &str, denial: &openshell_supervisor_middleware::MiddlewareDenial, ) -> Vec { let mut body = serde_json::Map::new(); body.insert("error".to_string(), serde_json::json!("middleware_denied")); body.insert( "detail".to_string(), serde_json::json!("Request rejected by configured middleware"), ); body.insert("policy".to_string(), serde_json::json!(policy_name)); body.insert( "middleware".to_string(), serde_json::json!(denial.config_name), ); if let Some(reason_code) = &denial.reason_code { body.insert("reason_code".to_string(), serde_json::json!(reason_code)); } let body_str = serde_json::Value::Object(body).to_string(); format!( "HTTP/1.1 403 Forbidden\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ Connection: close\r\n\ \r\n\ {}", body_str.len(), body_str, ) .into_bytes() } fn build_middleware_failure_response(policy_name: &str) -> Vec { build_middleware_platform_response(policy_name, "403 Forbidden") } fn build_middleware_unavailable_response(policy_name: &str) -> Vec { build_middleware_platform_response(policy_name, "503 Service Unavailable") } fn build_middleware_platform_response(policy_name: &str, status: &str) -> Vec { let body = serde_json::json!({ "error": "middleware_failed", "detail": "Request could not be processed by configured middleware", "policy": policy_name, }); let body_str = body.to_string(); format!( "HTTP/1.1 {status}\r\n\ Content-Type: application/json\r\n\ Content-Length: {}\r\n\ Connection: close\r\n\ \r\n\ {}", body_str.len(), body_str, ) .into_bytes() } /// Detail shared by the fail-closed 503 body, the OCSF denial event, and the /// denial notification when a terminating CONNECT route has no TLS termination /// state available. const TLS_TERMINATION_UNAVAILABLE_DETAIL: &str = "TLS termination unavailable (CA initialization failed); \ refusing to tunnel — credential rewrite would be bypassed"; /// Fail-closed gate evaluated BEFORE `200 Connection Established`. /// /// A CONNECT route that terminates TLS (`tls: skip` is exempt) relies on the /// ephemeral CA to rewrite credential placeholders. When no TLS termination /// state exists — the CA failed to generate or write at startup (`run.rs`); /// `mode != Proxy` never starts this handler — the proxy cannot rewrite those /// placeholders, so raw-tunneling would forward the client's TLS stream /// straight upstream and leak any `openshell:resolve:env:*` placeholder /// verbatim. /// /// The refusal is written here, as the first bytes on the socket, because a /// CONNECT client only sends its TLS `ClientHello` after reading the `200`. /// Refusing after the `200` would land the 503 inside the established tunnel, /// where the client decodes it as a TLS protocol error rather than a readable /// HTTP status (the flaw this replaces). Returns `true` when the connection was /// refused (the caller must stop) and `false` when the caller should proceed to /// establish the tunnel. async fn refuse_connect_when_tls_unavailable( client: &mut C, tls_state_present: bool, effective_tls_skip: bool, ) -> Result where C: TokioAsyncWrite + Unpin, { if tls_state_present || effective_tls_skip { return Ok(false); } respond( client, &build_json_error_response( 503, "Service Unavailable", "tls_termination_unavailable", TLS_TERMINATION_UNAVAILABLE_DETAIL, ), ) .await?; Ok(true) } /// Check if a miette error represents a benign connection close. /// /// TLS handshake EOF, missing `close_notify`, connection resets, and broken /// pipes are all normal lifecycle events for proxied connections — not worth /// a WARN that interrupts the user's terminal. fn is_benign_relay_error(err: &miette::Report) -> bool { const BENIGN: &[&str] = &[ "close_notify", "tls handshake eof", "connection reset", "broken pipe", "unexpected eof", ]; let msg = err.to_string().to_ascii_lowercase(); BENIGN.iter().any(|pat| msg.contains(pat)) } #[cfg(test)] #[allow( clippy::needless_raw_string_hashes, clippy::iter_on_single_items, clippy::needless_continue, reason = "Test code: test fixtures and explicit control-flow markers are idiomatic in tests." )] mod tests { use super::*; use openshell_core::proposals::AgentProposals; use std::collections::HashMap as TestHashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; #[test] fn supplied_identity_preserves_authorized_endpoint_metadata() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), r#" network_policies: inspected: name: inspected endpoints: - host: api.example.com port: 443 protocol: rest enforcement: enforce request_body_credential_rewrite: true allowed_ips: ["192.0.2.0/24"] rules: - allow: { method: GET, path: /allowed } binaries: - path: /usr/bin/python3 filesystem_policy: include_workdir: true read_only: [] read_write: [] landlock: compatibility: best_effort process: run_as_user: sandbox run_as_group: sandbox "#, ) .expect("load policy"); let identity = Ok(ContractBinaryIdentity { binary_path: PathBuf::from("/usr/bin/python3"), binary_digest: Some("00".repeat(32).parse().expect("digest")), ancestors: Vec::new(), cmdline_paths: Vec::new(), }); let mut decision = authorize_supplied_identity( &engine, EgressIntent::connect("api.example.com".to_string(), 443), &identity, ); assert_eq!(query_allowed_ips(&decision), ["192.0.2.0/24"]); hydrate_l7_route(&mut decision); let route = decision .endpoint .l7_route .expect("supplied identity must retain L7 metadata"); assert_eq!(route.configs.len(), 1); assert!(route.configs[0].config.request_body_credential_rewrite); } #[tokio::test] async fn staged_transparent_open_waits_for_l4_policy() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), r#" network_policies: allowed: name: allowed endpoints: - host: 203.0.113.7 port: 443 - host: 169.254.169.254 port: 80 binaries: - path: /usr/bin/curl filesystem_policy: include_workdir: true read_only: [] read_write: [] landlock: compatibility: best_effort process: run_as_user: sandbox run_as_group: sandbox "#, ) .unwrap(); let identity = || { Ok(ContractBinaryIdentity { binary_path: PathBuf::from("/usr/bin/curl"), binary_digest: Some("00".repeat(32).parse().unwrap()), ancestors: Vec::new(), cmdline_paths: Vec::new(), }) }; let pending = |destination: &str| { let (stream, _peer) = tokio::io::duplex(64); let (decision, completion) = tokio::sync::oneshot::channel(); ( PendingTcpOpen { stream: Box::new(stream), binary_identity: identity(), destination: destination.parse().unwrap(), socket: openshell_isolation_interface::contract::NetworkSocketMetadata { socket_cookie: 7, nonblocking: false, process_generation: 1, }, policy_generation: engine.current_generation(), timing: MediationTiming::default(), decision, }, completion, ) }; let (allowed, allowed_result) = pending("203.0.113.7:443"); assert!( preauthorize_transparent_open(allowed, None, &engine, None, None, None) .await .is_some() ); assert_eq!(allowed_result.await.unwrap(), TcpOpenDecision::RelayReady); let (unsafe_destination, unsafe_result) = pending("169.254.169.254:80"); assert!( preauthorize_transparent_open(unsafe_destination, None, &engine, None, None, None) .await .is_none() ); assert_eq!( unsafe_result.await.unwrap(), TcpOpenDecision::Denied(TcpOpenDenial::InvalidDestination) ); let (denied, denied_result) = pending("203.0.113.8:443"); let (denial_tx, mut denial_rx) = mpsc::unbounded_channel(); assert!( preauthorize_transparent_open(denied, None, &engine, Some(&denial_tx), None, None) .await .is_none() ); assert_eq!( denied_result.await.unwrap(), TcpOpenDecision::Denied(TcpOpenDenial::PolicyDenied) ); let denial = denial_rx .try_recv() .expect("transparent denial is reported"); assert_eq!(denial.host, "203.0.113.8"); assert_eq!(denial.port, 443); assert_eq!(denial.denial_stage, "connect"); } struct FailedMediationSource; #[tokio::test] async fn virtual_connect_is_portless_and_hides_the_synthetic_handshake() { let (workload, mut workload_peer) = tokio::io::duplex(1024); let mut handler = virtual_connect_stream(Box::new(workload), "api.example.com:443".into()); workload_peer.write_all(b"client-tls").await.unwrap(); let mut request = vec![0_u8; 128]; let length = handler.read(&mut request).await.unwrap(); let request = &request[..length]; assert!(request.starts_with(b"CONNECT api.example.com:443 HTTP/1.1\r\n")); handler .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\nserver-tls") .await .unwrap(); let mut response = [0_u8; 10]; workload_peer.read_exact(&mut response).await.unwrap(); assert_eq!(&response, b"server-tls"); } #[async_trait::async_trait] impl NetworkMediationSource for FailedMediationSource { async fn accept_tcp( &self, ) -> std::result::Result< PendingTcpOpen, openshell_isolation_interface::contract::BackendError, > { Err( openshell_isolation_interface::contract::BackendError::Unavailable( "test source unavailable".to_string(), ), ) } async fn accept_dns( &self, ) -> std::result::Result< openshell_isolation_interface::contract::PendingDnsQuery, openshell_isolation_interface::contract::BackendError, > { Err( openshell_isolation_interface::contract::BackendError::Unavailable( "test source unavailable".to_string(), ), ) } } struct DenyWebSocketPreflight; #[tonic::async_trait] impl openshell_core::middleware::SupervisorMiddlewareEndpoint for DenyWebSocketPreflight { async fn describe( &self, _request: tonic::Request, ) -> std::result::Result< tonic::Response, tonic::Status, > { Ok(tonic::Response::new( openshell_core::proto::MiddlewareManifest { name: "test/deny-websocket".into(), service_version: "test".into(), bindings: vec![openshell_core::proto::MiddlewareBinding { operation: openshell_core::proto::SupervisorMiddlewareOperation::WebsocketMessage as i32, phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 1024, request_timeout: Some(prost_types::Duration { seconds: 1, nanos: 0, }), }], expected_audience: String::new(), extension: Some(openshell_core::extension_protocol::extension_metadata( openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, "openshell/test-middleware", "test", [], )), }, )) } async fn validate_config( &self, _request: tonic::Request, ) -> std::result::Result< tonic::Response, tonic::Status, > { Ok(tonic::Response::new( openshell_core::proto::ValidateConfigResponse { valid: true, reason: String::new(), }, )) } async fn evaluate_http_request( &self, _request: tonic::Request, ) -> std::result::Result< tonic::Response, tonic::Status, > { Err(tonic::Status::unimplemented("WebSocket-only test service")) } async fn open_websocket_session( &self, mut receiver: mpsc::Receiver, ) -> std::result::Result { let (responses, response_stream) = mpsc::channel(1); tokio::spawn(async move { while let Some(event) = receiver.recv().await { if matches!( event.event, Some(openshell_core::proto::web_socket_session_event::Event::Preflight(_)) ) { let _ = responses .send(Ok(openshell_core::proto::WebSocketSessionEventResult { result: Some( openshell_core::proto::web_socket_session_event_result::Result::PreflightDecision( openshell_core::proto::WebSocketPreflightDecision { action: openshell_core::proto::WebSocketPreflightAction::Deny as i32, reason: "test denial".into(), reason_code: "test_denial".into(), ..Default::default() }, ), ), })) .await; break; } } }); Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new( response_stream, ))) } } struct BlockingForwardMiddleware { entered: Arc, release: Arc, } struct ForwardResponseHeadersMiddleware { expected_path: String, forbidden_path_fragment: String, block: bool, } #[tonic::async_trait] impl openshell_core::middleware::InProcessMiddleware for ForwardResponseHeadersMiddleware { async fn describe(&self) -> openshell_core::proto::MiddlewareManifest { openshell_core::proto::MiddlewareManifest { name: "test/forward-response".into(), service_version: "test".into(), bindings: vec![openshell_core::proto::MiddlewareBinding { operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpResponse as i32, phase: openshell_core::proto::SupervisorMiddlewarePhase::PreReturn as i32, max_payload_bytes: 8192, request_timeout: None, }], expected_audience: String::new(), extension: Some(openshell_core::extension_protocol::extension_metadata( openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, "openshell/test-forward-response", "test", [], )), } } async fn validate_config( &self, _middleware_name: &str, _config: &prost_types::Struct, ) -> Result<()> { Ok(()) } async fn evaluate_http_request( &self, _request: openshell_core::middleware::HttpRequestView<'_>, ) -> Result { Ok(openshell_core::proto::HttpRequestResult { decision: openshell_core::proto::Decision::Allow as i32, ..Default::default() }) } async fn open_http_response_pre_return( &self, mut requests: mpsc::Receiver, ) -> std::result::Result { let (sender, receiver) = mpsc::channel(2); let expected_path = self.expected_path.clone(); let forbidden_path_fragment = self.forbidden_path_fragment.clone(); let block = self.block; tokio::spawn(async move { while let Some(event) = requests.recv().await { match event.event { Some(openshell_core::proto::http_response_event::Event::Preflight( preflight, )) => { let target = preflight.target.expect("response target"); assert_eq!(target.path, expected_path); assert!(!target.path.contains(&forbidden_path_fragment)); let action = if block { openshell_core::proto::http_response_preflight_result::Action::BlockDelivery( openshell_core::proto::HttpResponseBlockDelivery {}, ) } else { openshell_core::proto::http_response_preflight_result::Action::Inspect( openshell_core::proto::HttpResponsePreflightInspect { body_mode: openshell_core::proto::HttpResponseBodyMode::HeadersOnly as i32, header_mutations: vec![openshell_core::proto::HeaderMutation { operation: Some( openshell_core::proto::header_mutation::Operation::Write( openshell_core::proto::WriteHeader { name: "x-forward-response-test".into(), value: "selected".into(), on_existing: openshell_core::proto::ExistingHeaderAction::Overwrite as i32, }, ), ), }], }, ) }; let result = openshell_core::proto::HttpResponseEventResult { result: Some( openshell_core::proto::http_response_event_result::Result::PreflightResult( openshell_core::proto::HttpResponsePreflightResult { action: Some(action), reason_code: if block { "query_guard".into() } else { String::new() }, ..Default::default() }, ), ), }; if sender.send(Ok(result)).await.is_err() { break; } } Some(openshell_core::proto::http_response_event::Event::SessionEnd(_)) | None => break, Some(_) => panic!("headers-only response received an unexpected event"), } } }); Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new( receiver, ))) } } #[tonic::async_trait] impl openshell_core::middleware::InProcessMiddleware for BlockingForwardMiddleware { async fn describe(&self) -> openshell_core::proto::MiddlewareManifest { openshell_core::proto::MiddlewareManifest { name: "test/blocking-forward".into(), service_version: "test".into(), bindings: vec![openshell_core::proto::MiddlewareBinding { operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpRequest as i32, phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 8192, request_timeout: None, }], expected_audience: String::new(), extension: Some(openshell_core::extension_protocol::extension_metadata( openshell_core::extension_protocol::ExtensionFamily::SupervisorMiddleware, "openshell/test-middleware", "test", [], )), } } async fn validate_config( &self, _middleware_name: &str, _config: &prost_types::Struct, ) -> Result<()> { Ok(()) } async fn evaluate_http_request( &self, _request: openshell_core::middleware::HttpRequestView<'_>, ) -> Result { self.entered.notify_one(); self.release.notified().await; Ok(openshell_core::proto::HttpRequestResult { decision: openshell_core::proto::Decision::Allow as i32, ..Default::default() }) } } fn non_loopback_test_ipv4() -> Option { let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; socket.connect("192.0.2.1:9").ok()?; match socket.local_addr().ok()?.ip() { IpAddr::V4(ip) if !ip.is_loopback() && !ip.is_unspecified() => Some(ip), _ => None, } } fn test_backend_identity(executable: &std::path::Path) -> ContractBinaryIdentity { ContractBinaryIdentity { binary_path: executable.to_path_buf(), binary_digest: Some("00".repeat(32).parse().expect("test digest")), ancestors: Vec::new(), cmdline_paths: Vec::new(), } } async fn drive_raw_request_through_handler(raw: Vec) -> Vec { let policy = include_str!("../data/sandbox-policy.rego"); let data = r#" network_middlewares: guard: middleware: openshell/regex endpoints: include: ["api.example.com"] network_policies: {} "#; let engine = Arc::new(OpaEngine::from_strings(policy, data).expect("load policy")); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let client = tokio::spawn(async move { let mut socket = TcpStream::connect(address).await.unwrap(); socket.write_all(&raw).await.unwrap(); let mut response = Vec::new(); socket.read_to_end(&mut response).await.unwrap(); response }); let (server, _) = listener.accept().await.unwrap(); Box::pin(handle_tcp_connection( server, engine, Arc::new(BinaryIdentityCache::new()), Arc::new(AtomicU32::new(std::process::id())), None, None, AgentProposals::default(), Arc::new(None), Arc::new(None), Arc::new(None), None, None, None, None, None, None, )) .await .expect("malformed request should be handled"); client.await.unwrap() } #[tokio::test] async fn terminal_mediation_source_failure_stops_proxy() { let policy = include_str!("../data/sandbox-policy.rego"); let engine = Arc::new( OpaEngine::from_strings_with_binary_identity_required( policy, "network_policies: {}", true, ) .expect("engine"), ); let (_ready_tx, ready_rx) = tokio::sync::watch::channel(true); let mut handle = ProxyHandle::start_with_bind_addr( &ProxyPolicy { http_addr: None }, Some(([127, 0, 0, 1], 3128).into()), engine, Arc::new(BinaryIdentityCache::new()), Arc::new(AtomicU32::new(1)), None, None, None, None, None, None, ready_rx, &upstream_proxy::UpstreamProxyArgs::default(), None, Some(Arc::new(FailedMediationSource)), None, None, ) .await .expect("proxy starts before source accept"); let exited = handle .take_exit_receiver() .expect("proxy exposes its exit receiver"); tokio::time::timeout(std::time::Duration::from_secs(1), exited) .await .expect("source failure must stop the proxy") .expect_err("proxy task drops the exit sender"); assert!(handle.join.is_finished()); } #[tokio::test] async fn malformed_forward_headers_are_rejected_before_route_or_middleware_dispatch() { for host in ["api.example.com", "unmatched.example.com"] { let raw = format!( "GET http://{host}/ HTTP/1.1\r\nHost: {host}\r\nX-Guard: before\0after\r\n\r\n" ) .into_bytes(); let response = Box::pin(drive_raw_request_through_handler(raw)).await; assert!( response.starts_with(b"HTTP/1.1 400 Bad Request"), "malformed request for {host} must fail at ingress" ); } } #[tokio::test] async fn plaintext_mcp_forwarding_preserves_initialization_and_selected_revision() { if !cfg!(target_os = "linux") { eprintln!("skipping: handler identity binding requires /proc (Linux)"); return; } let Some(upstream_ip) = non_loopback_test_ipv4() else { eprintln!("skipping: no routable non-loopback IPv4 test address"); return; }; for (body, version_header) in [ ( r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#, "", ), ( r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#, "MCP-Protocol-Version: 2025-11-25\r\n", ), ] { let upstream_listener = TcpListener::bind((upstream_ip, 0)) .await .expect("bind MCP upstream listener"); let upstream_port = upstream_listener.local_addr().unwrap().port(); let executable = std::env::current_exe().expect("current executable"); let supplied_identity: Result<_, ResolveError> = Ok(test_backend_identity(&executable)); let data = format!( r#" network_middlewares: inspect: middleware: openshell/regex on_error: fail_closed endpoints: include: ["{upstream_ip}"] network_policies: mcp-upstream: name: mcp-upstream endpoints: - host: "{upstream_ip}" port: {upstream_port} path: /mcp protocol: mcp enforcement: enforce rules: - allow: method: initialize - allow: method: tools/list binaries: - {{ path: "{executable}" }} "#, executable = executable.display(), ); let engine = Arc::new( OpaEngine::from_strings(include_str!("../data/sandbox-policy.rego"), &data) .expect("load MCP policy"), ); let registry = openshell_supervisor_middleware::MiddlewareRegistry::connect_services( openshell_supervisor_middleware_builtins::services(), Vec::new(), ) .await .expect("connect built-in middleware"); engine .replace_middleware_registry(registry) .expect("install built-in middleware"); let upstream = tokio::spawn(async move { let (mut socket, _) = upstream_listener.accept().await.unwrap(); let mut request = Vec::new(); let mut chunk = [0; 2048]; loop { let count = socket.read(&mut chunk).await.unwrap(); assert_ne!(count, 0, "MCP request closed before its body completed"); request.extend_from_slice(&chunk[..count]); if let Some(header_end) = request .windows(4) .position(|window| window == b"\r\n\r\n") .map(|end| end + 4) && request.len() >= header_end + body.len() { assert_eq!(&request[header_end..], body.as_bytes()); break; } } socket .write_all( b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", ) .await .unwrap(); String::from_utf8(request).expect("UTF-8 MCP request") }); let proxy_listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind proxy listener"); let proxy_address = proxy_listener.local_addr().unwrap(); let target = format!("http://{upstream_ip}:{upstream_port}/mcp"); let request = format!( "POST {target} HTTP/1.1\r\nHost: {upstream_ip}:{upstream_port}\r\nContent-Type: application/json\r\n{version_header}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len(), ); let client = tokio::spawn(async move { let mut socket = TcpStream::connect(proxy_address).await.unwrap(); let mut response = Vec::new(); socket.read_to_end(&mut response).await.unwrap(); response }); let (proxy_connection, _) = proxy_listener.accept().await.unwrap(); let socket_addrs = proxy_connection .peer_addr() .ok() .zip(proxy_connection.local_addr().ok()); let mut proxy_connection: ProxyClient = tokio::io::BufReader::new(Box::new(proxy_connection)); Box::pin(tokio::time::timeout( std::time::Duration::from_secs(30), Box::pin(handle_forward_proxy( "POST", &target, request.as_bytes(), request.len(), &mut proxy_connection, Some(&supplied_identity), socket_addrs, engine, Arc::new(BinaryIdentityCache::new()), Arc::new(AtomicU32::new(std::process::id())), None, AgentProposals::default(), Arc::new(None), Arc::new(None), None, None, None, None, None, None, )), )) .await .expect("MCP forwarding should complete") .expect("handle valid MCP request"); drop(proxy_connection); let response = client.await.expect("join MCP client"); assert!( response.starts_with(b"HTTP/1.1 200 OK"), "unexpected MCP proxy response: {}", String::from_utf8_lossy(&response) ); let forwarded = upstream.await.expect("join MCP upstream"); assert!(forwarded.starts_with("POST /mcp HTTP/1.1\r\n")); if version_header.is_empty() { assert!( !forwarded .to_ascii_lowercase() .contains("mcp-protocol-version:") ); } else { assert!(forwarded.contains(version_header)); } } } #[tokio::test] async fn plaintext_websocket_preflight_denial_does_not_connect_upstream() { if !cfg!(target_os = "linux") { eprintln!("skipping: handler identity binding requires /proc (Linux)"); return; } let Some(upstream_ip) = non_loopback_test_ipv4() else { eprintln!("skipping: no routable non-loopback IPv4 test address"); return; }; let upstream_listener = TcpListener::bind((upstream_ip, 0)) .await .expect("bind upstream listener"); let upstream_port = upstream_listener.local_addr().unwrap().port(); let executable = std::env::current_exe().expect("current executable"); let supplied_identity: Result<_, ResolveError> = Ok(test_backend_identity(&executable)); let data = format!( r#" network_middlewares: deny-upgrade: middleware: test/deny-websocket on_error: fail_closed endpoints: include: ["{upstream_ip}"] network_policies: allow-upstream: name: allow-upstream endpoints: - host: "{upstream_ip}" port: {upstream_port} binaries: - {{ path: "{executable}" }} "#, executable = executable.display(), ); let engine = Arc::new( OpaEngine::from_strings(include_str!("../data/sandbox-policy.rego"), &data) .expect("load policy"), ); let registry = openshell_supervisor_middleware::MiddlewareRegistry::connect_services( vec![openshell_supervisor_middleware::in_process_endpoint( Arc::new(DenyWebSocketPreflight), )], Vec::new(), ) .await .expect("connect test middleware"); engine .replace_middleware_registry(registry) .expect("install test middleware"); let proxy_listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind proxy listener"); let proxy_address = proxy_listener.local_addr().unwrap(); let target = format!("http://{upstream_ip}:{upstream_port}/ws"); let request = format!( "GET {target} HTTP/1.1\r\nHost: {upstream_ip}:{upstream_port}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" ); let client = tokio::spawn(async move { let mut socket = TcpStream::connect(proxy_address) .await .expect("connect proxy"); let mut response = Vec::new(); socket .read_to_end(&mut response) .await .expect("read proxy response"); response }); let (proxy_connection, _) = proxy_listener.accept().await.unwrap(); let socket_addrs = proxy_connection .peer_addr() .ok() .zip(proxy_connection.local_addr().ok()); let stream: BoundaryDuplexStream = Box::new(proxy_connection); let mut proxy_connection = tokio::io::BufReader::new(stream); tokio::time::timeout( std::time::Duration::from_secs(30), Box::pin(handle_forward_proxy( "GET", &target, request.as_bytes(), request.len(), &mut proxy_connection, Some(&supplied_identity), socket_addrs, engine, Arc::new(BinaryIdentityCache::new()), Arc::new(AtomicU32::new(std::process::id())), None, AgentProposals::default(), Arc::new(None), Arc::new(None), None, None, None, None, None, None, )), ) .await .expect("denied preflight must complete without an upstream response") .expect("handle denied plaintext WebSocket upgrade"); drop(proxy_connection); let response = String::from_utf8(client.await.unwrap()).expect("UTF-8 response"); assert!( response.contains("\"error\":\"middleware_denied\""), "preflight must deny the upgrade: {response}" ); assert!( tokio::time::timeout( std::time::Duration::from_millis(100), upstream_listener.accept() ) .await .is_err(), "denied preflight must not establish an upstream connection" ); } #[tokio::test] async fn plaintext_websocket_middleware_inspects_compressed_ws_messages() { if !cfg!(target_os = "linux") { eprintln!("skipping: handler identity binding requires /proc (Linux)"); return; } let Some(upstream_ip) = non_loopback_test_ipv4() else { eprintln!("skipping: no routable non-loopback IPv4 test address"); return; }; let upstream_listener = TcpListener::bind((upstream_ip, 0)) .await .expect("bind upstream listener"); let upstream_port = upstream_listener.local_addr().unwrap().port(); let executable = std::env::current_exe().expect("current executable"); let supplied_identity: Result<_, ResolveError> = Ok(test_backend_identity(&executable)); let data = format!( r#" network_middlewares: redact: middleware: openshell/regex on_error: fail_closed endpoints: include: ["{upstream_ip}"] network_policies: allow-upstream: name: allow-upstream endpoints: - host: "{upstream_ip}" port: {upstream_port} binaries: - {{ path: "{executable}" }} "#, executable = executable.display(), ); let engine = Arc::new( OpaEngine::from_strings(include_str!("../data/sandbox-policy.rego"), &data) .expect("load policy"), ); let registry = openshell_supervisor_middleware::MiddlewareRegistry::connect_services( openshell_supervisor_middleware_builtins::services(), Vec::new(), ) .await .expect("connect built-in middleware"); engine .replace_middleware_registry(registry) .expect("install built-in middleware"); let upstream = tokio::spawn(async move { let (mut socket, _) = upstream_listener.accept().await.unwrap(); let request = read_http_headers_unbounded(&mut socket).await; let request = String::from_utf8_lossy(&request); assert!(request.starts_with("GET /ws HTTP/1.1\r\n")); assert!(request.contains( "Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover\r\n" )); socket .write_all( b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\nSec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover\r\n\r\n", ) .await .unwrap(); let frame = crate::l7::websocket::read_frame_for_test(&mut socket).await; crate::l7::websocket::decode_compressed_masked_text_frame_for_test(&frame) }); let proxy_listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind proxy listener"); let proxy_address = proxy_listener.local_addr().unwrap(); let target = format!("http://{upstream_ip}:{upstream_port}/ws"); let request = format!( "GET {target} HTTP/1.1\r\nHost: {upstream_ip}:{upstream_port}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover\r\n\r\n" ); let client = tokio::spawn(async move { let mut socket = TcpStream::connect(proxy_address) .await .expect("connect proxy"); let response = read_http_headers_unbounded(&mut socket).await; assert!( String::from_utf8_lossy(&response).contains("101 Switching Protocols"), "unexpected WebSocket proxy response: {}", String::from_utf8_lossy(&response) ); socket .write_all( &crate::l7::websocket::compressed_masked_text_frame_for_test( br#"{"token":"sk-1234567890abcdef"}"#, ), ) .await .unwrap(); }); let (proxy_connection, _) = proxy_listener.accept().await.unwrap(); let socket_addrs = proxy_connection .peer_addr() .ok() .zip(proxy_connection.local_addr().ok()); let stream: BoundaryDuplexStream = Box::new(proxy_connection); let mut proxy_connection = tokio::io::BufReader::new(stream); let handler = tokio::spawn(async move { Box::pin(handle_forward_proxy( "GET", &target, request.as_bytes(), request.len(), &mut proxy_connection, Some(&supplied_identity), socket_addrs, engine, Arc::new(BinaryIdentityCache::new()), Arc::new(AtomicU32::new(std::process::id())), None, AgentProposals::default(), Arc::new(None), Arc::new(None), None, None, None, None, None, None, )) .await }); let scenario = tokio::time::timeout(std::time::Duration::from_mins(1), async { client.await.expect("join plaintext WebSocket client"); assert_eq!( upstream.await.expect("join plaintext WebSocket upstream"), r#"{"token":"[REDACTED]"}"# ); }) .await; if handler.is_finished() { handler .await .expect("join plaintext WebSocket handler") .expect("handle compressed plaintext WebSocket upgrade"); } else { handler.abort(); let _ = handler.await; } scenario.expect("compressed plaintext WebSocket scenario should complete"); } #[tokio::test] async fn malformed_request_lines_are_rejected_before_connect_or_forward_dispatch() { for host in ["api.example.com", "unmatched.example.com"] { for request_line in [ format!("GET http://{host}/ HTTP/1.1 extra"), format!("CONNECT {host}:443 HTTP/1.1 extra"), ] { let raw = format!("{request_line}\r\nHost: {host}\r\n\r\n").into_bytes(); let response = Box::pin(drive_raw_request_through_handler(raw)).await; assert!( response.starts_with(b"HTTP/1.1 400 Bad Request"), "malformed request for {host} must fail before dispatch" ); } } } #[tokio::test] async fn unsupported_forward_scheme_is_rejected_before_policy_or_middleware_dispatch() { let raw = b"GET ftp://api.example.com/resource HTTP/1.1\r\nHost: api.example.com\r\n\r\n" .to_vec(); let response = Box::pin(drive_raw_request_through_handler(raw)).await; assert!(response.starts_with(b"HTTP/1.1 400 Bad Request")); assert!( response .windows(b"unsupported_proxy_scheme".len()) .any(|window| window == b"unsupported_proxy_scheme") ); } #[tokio::test] async fn policy_local_preserves_specific_non_http_scheme_error() { for scheme in ["https", "ftp"] { let raw = format!( "GET {scheme}://policy.local/resource HTTP/1.1\r\nHost: policy.local\r\n\r\n" ) .into_bytes(); let response = Box::pin(drive_raw_request_through_handler(raw)).await; assert!(response.starts_with(b"HTTP/1.1 400 Bad Request")); assert!( response .windows(b"invalid_policy_local_scheme".len()) .any(|window| window == b"invalid_policy_local_scheme") ); } } #[tokio::test] async fn dial_upstream_preserves_trailing_dot_in_hostname_connect() { // Fake upstream proxy: capture the request line, then 200. let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let proxy_addr = listener.local_addr().unwrap(); let handle = tokio::spawn(async move { let (mut sock, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 1024_usize]; let n = sock.read(&mut buf).await.unwrap(); sock.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") .await .unwrap(); String::from_utf8_lossy(&buf[..n]).into_owned() }); // Operator config: proxy set + connect-by-hostname opt-in. let cfg = UpstreamProxyConfig::from_args(&upstream_proxy::UpstreamProxyArgs { https_proxy: Some(format!("http://{proxy_addr}")), proxy_connect_by_hostname: true, ..Default::default() }) .unwrap(); // host_lc = normalized (undotted), raw_host_lc = absolute (dotted). let stream = dial_upstream( &cfg, "api.example.com", "api.example.com.", 443, &[], // addrs unused in the hostname branch ) .await .unwrap(); drop(stream); let request = handle.await.unwrap(); assert!( request.starts_with("CONNECT api.example.com.:443 HTTP/1.1\r\n"), "proxy must receive the absolute FQDN: {request}" ); } #[test] fn accept_errors_include_the_listening_endpoint() { use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; let addr = "127.0.0.1:3128".parse().unwrap(); let error = std::io::Error::other("accept failed"); for action in [ AcceptAction::Terminal, AcceptAction::Retry { backoff: std::time::Duration::from_millis(250), severity: SeverityId::Low, }, ] { let event = build_accept_error_event(addr, &error, &action); let json = event.to_json().unwrap(); validate_required_fields(&json, &load_class_schema("network_activity")); assert_eq!(json["dst_endpoint"]["ip"], "127.0.0.1"); assert_eq!(json["dst_endpoint"]["port"], 3128); assert!(json["message"].as_str().unwrap().contains("accept failed")); } } #[test] fn connection_errors_include_the_known_peer() { use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; let peer: SocketAddr = "127.0.0.1:54321".parse().unwrap(); let schema = load_class_schema("network_activity"); let json = build_connection_error_event(peer, "Proxy connection error".to_string()) .to_json() .unwrap(); assert_eq!(json["class_uid"], 4001); assert_eq!(json["src_endpoint"]["ip"], "127.0.0.1"); assert_eq!(json["src_endpoint"]["port"], 54321); validate_required_fields(&json, &schema); } #[test] fn forward_parse_errors_include_http_context_and_peer() { use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; let peer: SocketAddr = "127.0.0.1:54321".parse().unwrap(); let json = build_forward_parse_error_ocsf_event(Some(peer), "GET", "/[INVALID_REQUEST_TARGET]") .to_json() .unwrap(); assert_eq!(json["class_uid"], 4002); assert_eq!(json["activity_name"], "Get"); assert_eq!(json["http_request"]["http_method"], "GET"); assert!(json["http_request"].get("url").is_none()); assert_eq!(json["http_response"]["code"], 400); assert_eq!(json["src_endpoint"]["ip"], "127.0.0.1"); assert_eq!(json["src_endpoint"]["port"], 54321); validate_required_fields(&json, &load_class_schema("http_activity")); } #[test] fn forward_parse_errors_without_a_peer_do_not_fabricate_an_endpoint() { let json = build_forward_parse_error_ocsf_event(None, "GET", "/[INVALID_REQUEST_TARGET]") .to_json() .unwrap(); assert_eq!(json["class_uid"], 4002); assert!(json.get("src_endpoint").is_none()); assert!(json.get("dst_endpoint").is_none()); } #[test] fn endpointless_proxy_failures_are_base_events() { for event in [ build_proxy_connection_error_event(None, None, "connection failed".to_string()), build_mediation_lane_failure_event("source failed".to_string()), ] { let json = event.to_json().unwrap(); assert_eq!(json["class_uid"], 0); assert_ne!(json["activity_name"], "Stop"); assert_eq!(json["status"], "Failure"); } } #[test] fn middleware_failure_response_uses_platform_text_without_policy_guidance() { let response = build_middleware_failure_response("api-policy"); let response = String::from_utf8(response).expect("UTF-8 error response"); let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response"); let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); assert_eq!(body["error"], "middleware_failed"); assert_eq!( body["detail"], "Request could not be processed by configured middleware" ); assert_eq!(body["policy"], "api-policy"); assert!(body.get("rule_missing").is_none()); assert!(body.get("next_steps").is_none()); assert!(body.get("agent_guidance").is_none()); } #[test] fn middleware_unavailable_response_is_complete_and_has_no_retry_hint() { let response = build_middleware_unavailable_response("api-policy"); let response = String::from_utf8(response).expect("UTF-8 error response"); assert!(response.starts_with("HTTP/1.1 503 Service Unavailable\r\n")); assert!(!response.to_ascii_lowercase().contains("retry-after")); let (headers, body) = response.split_once("\r\n\r\n").expect("HTTP response"); let content_length = headers .lines() .find_map(|line| { line.strip_prefix("Content-Length: ") .and_then(|value| value.parse::().ok()) }) .expect("Content-Length"); assert_eq!(content_length, body.len()); let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); assert_eq!(body["error"], "middleware_failed"); assert_eq!(body["policy"], "api-policy"); assert!(body.get("middleware").is_none()); assert!(body.get("reason_code").is_none()); } #[test] fn policy_deny_response_includes_reason() { let response = build_json_error_response_with_reason( 403, "Forbidden", "policy_denied", "CONNECT api.example.com:443 not permitted by policy", "binary '/usr/bin/node' not allowed in policy 'allow_api' (ancestors: [/usr/local/bin/claude])", ); let response = String::from_utf8(response).expect("UTF-8 error response"); assert!(response.starts_with("HTTP/1.1 403 Forbidden")); let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response"); let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); assert_eq!(body["error"], "policy_denied"); assert_eq!( body["detail"], "CONNECT api.example.com:443 not permitted by policy" ); assert_eq!( body["reason"], "binary '/usr/bin/node' not allowed in policy 'allow_api' (ancestors: [/usr/local/bin/claude])" ); } #[test] fn policy_deny_response_omits_empty_reason() { let response = build_json_error_response_with_reason( 403, "Forbidden", "policy_denied", "CONNECT api.example.com:443 not permitted by policy", "", ); let response = String::from_utf8(response).expect("UTF-8 error response"); let (_, body) = response.split_once("\r\n\r\n").expect("HTTP response"); let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); assert_eq!(body["error"], "policy_denied"); assert!(body.get("reason").is_none()); } #[test] fn forward_policy_denial_ocsf_includes_validation_rationale() { let reason = "policy validation failed; fail-closed quarantine is active; candidate version 7 rejected: conflicting tls metadata"; let event = build_forward_policy_deny_ocsf_event( "127.0.0.1:45123".parse().unwrap(), "GET", "api.example.com", 80, "/v1/models", "/usr/bin/curl", "42", "/usr/bin/bash", "curl http://api.example.com/v1/models", reason, ); let json = event.to_json().unwrap(); assert_eq!(json["status_detail"], reason); assert_eq!(json["action"], "Denied"); assert_eq!(json["disposition"], "Blocked"); } #[test] fn forward_l7_parse_rejection_ocsf_includes_denial_context() { let event = build_forward_l7_parse_rejection_ocsf_event( "127.0.0.1:45123".parse().unwrap(), "GET", "api.example.com", 80, "/admin/x%2Fy", "/usr/bin/curl", "42", "/usr/bin/bash", "curl http://api.example.com/admin/x%2Fy", "allow_api", FORWARD_ENCODED_SLASH_REJECTION_DETAIL, ); let json = event.to_json().unwrap(); assert_eq!(json["class_name"], "HTTP Activity"); assert_eq!(json["activity_name"], "Get"); assert_eq!(json["action"], "Denied"); assert_eq!(json["disposition"], "Blocked"); assert_eq!(json["severity"], "Medium"); assert_eq!(json["status"], "Failure"); assert_eq!(json["http_request"]["http_method"], "GET"); assert_eq!(json["http_request"]["url"]["path"], "/admin/x%2Fy"); assert_eq!(json["dst_endpoint"]["domain"], "api.example.com"); assert_eq!(json["dst_endpoint"]["port"], 80); assert_eq!(json["actor"]["process"]["name"], "/usr/bin/curl"); assert_eq!(json["firewall_rule"]["name"], "allow_api"); assert_eq!(json["firewall_rule"]["type"], "l7"); assert_eq!( json["status_detail"], FORWARD_ENCODED_SLASH_REJECTION_DETAIL ); } #[test] fn transparent_tcp_allow_ocsf_exposes_correlated_dns_and_dial_chain() { let mapping_id = uuid::Uuid::new_v4(); let event = build_transparent_tcp_allow_ocsf_event(TransparentTcpAllowAudit { workload: "127.0.0.1:45123".parse().unwrap(), synthetic_destination: "198.18.0.7:6379".parse().unwrap(), normalized_domain: "redis.openshell.demo", approved_real_ip_candidates: &[ "172.18.0.4:6379".parse().unwrap(), "172.18.0.5:6379".parse().unwrap(), ], connected_real_destination: Some("172.18.0.5:6379".parse().unwrap()), upstream_socket_peer: "172.18.0.5:6379".parse().unwrap(), dial_mode: "direct", mapping_id, mapping_generation: 4, mapping_policy_generation: 7, authorization_policy_generation: 7, binary: "/sandbox/.venv/bin/python3", pid: "42", policy_name: "redis", }); let json = event.to_json().unwrap(); assert_eq!(json["actor"]["process"]["pid"], 42); assert_eq!( json["actor"]["process"]["name"], "/sandbox/.venv/bin/python3" ); assert!(json["actor"]["process"].get("parent_process").is_none()); assert_eq!(json["dst_endpoint"]["domain"], "redis.openshell.demo"); assert_eq!(json["firewall_rule"]["name"], "redis"); assert_eq!(json["unmapped"]["synthetic_destination"], "198.18.0.7:6379"); assert_eq!( json["unmapped"]["connected_real_destination"], "172.18.0.5:6379" ); assert_eq!( json["unmapped"]["approved_real_ip_candidates"], serde_json::json!(["172.18.0.4:6379", "172.18.0.5:6379"]) ); assert_eq!(json["unmapped"]["mapping_id"], mapping_id.to_string()); assert_eq!(json["unmapped"]["mapping_generation"], 4); assert_eq!(json["unmapped"]["policy_generation"], 7); assert_eq!(json["unmapped"]["mapping_policy_generation"], 7); assert_eq!(json["unmapped"]["matched_policy"], "redis"); assert_eq!(json["unmapped"]["dial_mode"], "direct"); let shorthand = event.format_shorthand(); assert!(shorthand.contains("/sandbox/.venv/bin/python3(42)")); assert!(shorthand.contains("redis.openshell.demo:6379")); assert!(shorthand.contains("synthetic=198.18.0.7:6379")); assert!(shorthand.contains("real=172.18.0.5:6379")); assert!(shorthand.contains(&format!("mapping_id={mapping_id}"))); } #[test] fn transparent_tcp_proxy_audit_reports_validated_connect_target() { let event = build_transparent_tcp_allow_ocsf_event(TransparentTcpAllowAudit { workload: "127.0.0.1:45123".parse().unwrap(), synthetic_destination: "198.18.0.7:6379".parse().unwrap(), normalized_domain: "redis.openshell.demo", approved_real_ip_candidates: &["172.18.0.4:6379".parse().unwrap()], connected_real_destination: Some("172.18.0.4:6379".parse().unwrap()), upstream_socket_peer: "192.0.2.20:3128".parse().unwrap(), dial_mode: "upstream_proxy_validated_ip", mapping_id: uuid::Uuid::new_v4(), mapping_generation: 4, mapping_policy_generation: 7, authorization_policy_generation: 7, binary: "/usr/bin/redis-cli", pid: "43", policy_name: "redis", }); let json = event.to_json().unwrap(); assert_eq!( json["unmapped"]["connected_real_destination"], "172.18.0.4:6379" ); assert_eq!(json["unmapped"]["upstream_socket_peer"], "192.0.2.20:3128"); assert_eq!(json["unmapped"]["dial_mode"], "upstream_proxy_validated_ip"); assert!(event.format_shorthand().contains("real=172.18.0.4:6379")); } #[cfg(target_os = "linux")] #[tokio::test] async fn transparent_tcp_ignores_proxy_hostname_mode_and_connects_to_validated_ip() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let proxy_addr = listener.local_addr().unwrap(); let (request_tx, request_rx) = tokio::sync::oneshot::channel(); let proxy = tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut request = Vec::new(); loop { let mut byte = [0_u8; 1]; stream.read_exact(&mut byte).await.unwrap(); request.push(byte[0]); if request.ends_with(b"\r\n\r\n") { break; } } request_tx.send(request).unwrap(); stream .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") .await .unwrap(); }); let config = UpstreamProxyConfig::from_args(&upstream_proxy::UpstreamProxyArgs { https_proxy: Some(format!("http://{proxy_addr}")), proxy_connect_by_hostname: true, ..Default::default() }) .unwrap() .unwrap(); let approved = "203.0.113.27:6379".parse().unwrap(); let stream = dial_transparent_upstream(&Some(config), "redis.openshell.demo", 6379, &[approved]) .await .unwrap(); let request = String::from_utf8(request_rx.await.unwrap()).unwrap(); assert!(request.starts_with("CONNECT 203.0.113.27:6379 HTTP/1.1\r\n")); assert!(!request.contains("CONNECT redis.openshell.demo:6379")); assert!(matches!( stream.connect_target(), Some(upstream_proxy::ConnectTarget::Ip(ip)) if ip == approved.ip() )); proxy.await.unwrap(); } #[test] fn forward_ocsf_events_omit_queries_and_credential_key_names() { let peer = "127.0.0.1:45123".parse().unwrap(); let path = forward_telemetry_path( "http://api.example.com/v1/openshell:resolve:env:API_TOKEN?token=real-secret", ); assert_eq!(path, "/v1/[CREDENTIAL]"); let allowed = build_forward_allow_ocsf_event( peer, "GET", "api.example.com", 80, &path, "/usr/bin/curl", "42", "/usr/bin/bash", "curl", "allow_api", ) .to_json() .unwrap(); let denied = build_forward_policy_deny_ocsf_event( peer, "GET", "api.example.com", 80, &path, "/usr/bin/curl", "42", "/usr/bin/bash", "curl", "policy denied", ) .to_json() .unwrap(); for event in [&allowed, &denied] { assert_eq!(event["http_request"]["url"]["path"], "/v1/[CREDENTIAL]"); let serialized = event.to_string(); assert!(!serialized.contains("API_TOKEN"), "{serialized}"); assert!(!serialized.contains("real-secret"), "{serialized}"); assert!(!serialized.contains("?token="), "{serialized}"); } let target = "http://api.example.com?token=real-secret"; let (_, host, port, path) = parse_proxy_uri(target).expect("absolute URI without a path"); assert_eq!(host, "api.example.com"); assert_eq!(path, "/?token=real-secret"); let no_path_query = build_forward_allow_ocsf_event( peer, "GET", &host, port, &forward_telemetry_path(target), "/usr/bin/curl", "42", "/usr/bin/bash", "curl", "allow_api", ) .to_json() .unwrap(); assert_eq!(no_path_query["dst_endpoint"]["domain"], "api.example.com"); assert_eq!(no_path_query["http_request"]["url"]["path"], "/"); let serialized = no_path_query.to_string(); assert!(!serialized.contains("real-secret"), "{serialized}"); assert!(!serialized.contains("?token="), "{serialized}"); let malformed = build_forward_parse_error_ocsf_event( Some("127.0.0.1:12345".parse().unwrap()), "GET", &forward_telemetry_path( "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", ), ) .to_json() .unwrap(); assert_eq!( malformed["message"], "FORWARD parse error for /[INVALID_REQUEST_TARGET]" ); let serialized = malformed.to_string(); assert!(!serialized.contains("API_TOKEN"), "{serialized}"); assert!(!serialized.contains("real-secret"), "{serialized}"); } #[test] fn endpoint_only_opa_allows_declared_endpoint_without_process_identity() { let policy = include_str!("../data/sandbox-policy.rego"); let data = r#" version: 1 network_policies: test_l7: name: test_l7 endpoints: - host: host.k3d.internal port: 56123 protocol: rest enforcement: enforce rules: - allow: method: GET path: /allowed binaries: - path: /usr/bin/curl "#; let engine = OpaEngine::from_strings_with_binary_identity_required(policy, data, false) .expect("relaxed engine"); let decision = evaluate_endpoint_only_opa( &engine, EgressIntent::connect("host.k3d.internal".to_string(), 56123), ); assert_eq!( decision.action, NetworkAction::Allow { matched_policy: Some("test_l7".to_string()), } ); assert!(decision.binary.is_none()); assert!(decision.ancestors.is_empty()); let denied = evaluate_endpoint_only_opa( &engine, EgressIntent::connect("api.example.com".to_string(), 443), ); assert!( matches!(denied.action, NetworkAction::Deny { .. }), "endpoint-only mode must still deny undeclared endpoints" ); } fn websocket_l7_config( protocol: crate::l7::L7Protocol, websocket_credential_rewrite: bool, ) -> crate::l7::L7EndpointConfig { crate::l7::L7EndpointConfig { endpoint_id: String::new(), policy_hash: String::new(), protocol, path: "/**".to_string(), tls: crate::l7::TlsMode::Auto, enforcement: crate::l7::EnforcementMode::Enforce, graphql_max_body_bytes: crate::l7::graphql::DEFAULT_MAX_BODY_BYTES, json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, mcp_strict_tool_names: true, mcp_versions: Vec::new(), allow_encoded_slash: false, websocket_credential_rewrite, request_body_credential_rewrite: false, allow_uninspected_credentials: false, provider_credentialed: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: String::new(), signing_region: String::new(), } } #[test] fn tunnel_protocol_classification_detects_supported_protocols() { assert_eq!( classify_tunnel_protocol(&[0x16, 0x03, 0x03, 0x00]), TunnelProtocol::Tls ); assert_eq!( classify_tunnel_protocol(b"GET / HTTP/1.1\r\n"), TunnelProtocol::Http1 ); assert_eq!( classify_tunnel_protocol(&crate::l7::rest::HTTP2_PRIOR_KNOWLEDGE_PREFACE[..8]), TunnelProtocol::H2cPriorKnowledge ); assert_eq!( classify_tunnel_protocol(b"SSH-2.0-OpenSSH\r\n"), TunnelProtocol::Unsupported ); } #[test] fn tunnel_protocol_prefix_detection_waits_for_partial_supported_prefixes() { assert!(could_be_supported_tunnel_protocol_prefix(&[0x16])); assert!(could_be_supported_tunnel_protocol_prefix(b"GE")); assert!(could_be_supported_tunnel_protocol_prefix(b"PRI * H")); assert!(!could_be_supported_tunnel_protocol_prefix(b"SSH")); } #[tokio::test] async fn h2c_prior_knowledge_is_blocked_for_l7_tunnel() { use crate::l7::middleware::UninspectableTrafficGate; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let mut client = TcpStream::connect(addr).await.unwrap(); let (mut server, _) = listener.accept().await.unwrap(); client .write_all(crate::l7::rest::HTTP2_PRIOR_KNOWLEDGE_PREFACE) .await .unwrap(); let protocol = peek_tunnel_protocol(&mut tokio::io::BufReader::new(&mut server)) .await .expect("peek should succeed") .expect("client sent bytes"); assert_eq!(protocol, TunnelProtocol::H2cPriorKnowledge); assert_eq!( unsupported_l7_tunnel_protocol_detail(protocol, InspectionRequirement::L7Route), Some("HTTP/2 prior-knowledge (h2c) is not supported for L7-inspected endpoints") ); // A fail-closed middleware chain makes inspection mandatory even for // an L4-only endpoint. assert_eq!( unsupported_l7_tunnel_protocol_detail( protocol, InspectionRequirement::RequiredMiddleware ), Some("HTTP/2 prior-knowledge (h2c) cannot be inspected by required middleware") ); assert_eq!( unsupported_l7_tunnel_protocol_detail( TunnelProtocol::Unsupported, InspectionRequirement::RequiredMiddleware ), Some("Unsupported tunnel protocol cannot be inspected by required middleware") ); assert_eq!( unsupported_l7_tunnel_protocol_detail(protocol, InspectionRequirement::None), None ); // The L7 route owns the wording even when middleware also requires // inspection; the middleware requirement applies only without a route. assert_eq!( inspection_requirement(true, UninspectableTrafficGate::Deny), InspectionRequirement::L7Route ); assert_eq!( inspection_requirement(false, UninspectableTrafficGate::Deny), InspectionRequirement::RequiredMiddleware ); assert_eq!( inspection_requirement(false, UninspectableTrafficGate::BypassWithFinding), InspectionRequirement::None ); } #[tokio::test] async fn h2c_upgrade_request_on_l7_relay_is_denied_without_upstream_write() { let data = r#" network_policies: rest_api: name: rest_api endpoints: - host: h2c.example.test port: 80 path: "/allowed" protocol: rest enforcement: enforce rules: - allow: method: GET path: "/allowed" binaries: - { path: /usr/bin/node } "#; let (config, tunnel_engine, ctx) = forward_websocket_policy_parts(data, "h2c.example.test", 80, "/allowed", "rest_api"); let (mut app, mut proxy_client) = tokio::io::duplex(8192); let (mut proxy_upstream, mut upstream) = tokio::io::duplex(8192); let request = b"GET /allowed HTTP/1.1\r\n\ Host: h2c.example.test\r\n\ Upgrade: h2c\r\n\ Connection: keep-alive, Upgrade\r\n\ HTTP2-Settings: AAMAAABkAAQAAP__\r\n\r\n"; app.write_all(request).await.unwrap(); app.shutdown().await.unwrap(); crate::l7::relay::relay_with_inspection( &config, tunnel_engine, &mut proxy_client, &mut proxy_upstream, &ctx, ) .await .expect("h2c upgrade should be handled as a policy denial"); drop(proxy_client); drop(proxy_upstream); let mut response = Vec::new(); app.read_to_end(&mut response).await.unwrap(); let response = String::from_utf8(response).unwrap(); assert!( response.starts_with("HTTP/1.1 403 Forbidden\r\n"), "expected h2c upgrade to be denied, got: {response}" ); assert!( response.contains(crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL), "denial should explain unsupported h2c upgrade, got: {response}" ); let mut leaked = Vec::new(); upstream.read_to_end(&mut leaked).await.unwrap(); assert!( leaked.is_empty(), "h2c upgrade request must not be written to upstream" ); } #[test] fn revision_scoped_dynamic_credentials_preserves_endpoint_selector_and_adds_revision() { let mut dynamic_credentials = std::collections::HashMap::new(); dynamic_credentials.insert( "api.example.test\t443\t/v1/**\tprovider:access_token".to_string(), openshell_core::proto::ProviderProfileCredential { name: "access_token".to_string(), ..Default::default() }, ); let snapshot = ProviderCredentialSnapshot { installation_id: String::new(), revision: 42, child_env: std::collections::HashMap::new(), dynamic_credentials, }; let scoped = revision_scoped_dynamic_credentials(&snapshot); assert!( scoped.contains_key("api.example.test\t443\t/v1/**\trev:42\tprovider:access_token") ); } #[test] fn connect_activity_is_skipped_when_l7_will_count_the_request() { let (tx, mut rx) = mpsc::channel(4); let activity_tx = Some(tx); let l7_route = L7RouteSnapshot { configs: vec![L7ConfigSnapshot { config: websocket_l7_config(crate::l7::L7Protocol::Rest, false), }], l7_policy_generation: 1, }; let l4_route = L7RouteSnapshot { configs: Vec::new(), l7_policy_generation: 1, }; emit_connect_activity_if_l4_only(&activity_tx, Some(&l7_route)); assert!( rx.try_recv().is_err(), "L7-inspected CONNECT should not emit an extra L4 activity event" ); emit_connect_activity_if_l4_only(&activity_tx, Some(&l4_route)); let event = rx.try_recv().expect("L4-only CONNECT should emit activity"); assert!(!event.denied); assert_eq!(event.deny_group, "unknown"); emit_connect_activity_if_l4_only(&activity_tx, None); let event = rx .try_recv() .expect("CONNECT without an L7 route should emit activity"); assert!(!event.denied); assert_eq!(event.deny_group, "unknown"); } #[test] fn l7_hard_deny_reason_includes_jsonrpc_errors() { let cases: &[(&[u8], &str)] = &[ (b"{", "JSON-RPC request rejected: invalid JSON"), ( br#"{"id":1,"method":"reports.list"}"#, "JSON-RPC request rejected: missing or non-string 'jsonrpc' field", ), ]; for &(body, expected_reason) in cases { let request_info = crate::l7::L7RequestInfo { action: "POST".to_string(), target: "/rpc".to_string(), query_params: std::collections::HashMap::new(), graphql: None, jsonrpc: Some(crate::l7::jsonrpc::parse_jsonrpc_body( body, crate::l7::jsonrpc::JsonRpcInspectionMode::JsonRpc, )), }; let reason = crate::l7::relay::l7_request_hard_deny_reason( crate::l7::L7Protocol::JsonRpc, &request_info, ) .expect("JSON-RPC parse error"); assert_eq!(reason, expected_reason); } } #[test] fn l7_hard_deny_reason_includes_jsonrpc_response_frames() { let request_info = crate::l7::L7RequestInfo { action: "POST".to_string(), target: "/rpc".to_string(), query_params: std::collections::HashMap::new(), graphql: None, jsonrpc: Some(crate::l7::jsonrpc::JsonRpcRequestInfo { calls: Vec::new(), is_batch: false, receive_stream: false, has_response: true, error: None, }), }; let reason = crate::l7::relay::l7_request_hard_deny_reason( crate::l7::L7Protocol::JsonRpc, &request_info, ) .expect("JSON-RPC response hard deny"); assert_eq!(reason, crate::l7::relay::JSONRPC_RESPONSE_FRAME_DENY_REASON); assert!( crate::l7::relay::l7_request_hard_deny_reason( crate::l7::L7Protocol::Mcp, &request_info, ) .is_none(), "MCP response frames are evaluated by policy instead of hard-denied" ); } #[tokio::test] async fn forward_middleware_pipeline_denies_policy_invalid_transformation() { const TEST_POLICY: &str = include_str!("../data/sandbox-policy.rego"); let data = r#" network_policies: jsonrpc_api: name: jsonrpc_api endpoints: - host: api.example.test port: 80 path: /rpc protocol: json-rpc enforcement: enforce rules: - allow: method: sk-ABCDEFGHIJKLMNOP binaries: - { path: /usr/bin/node } "#; let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); let input = crate::opa::NetworkInput { host: "api.example.test".into(), port: 80, binary_path: PathBuf::from("/usr/bin/node"), binary_sha256: "unused".into(), ancestors: vec![], cmdline_paths: vec![], }; let (endpoint, generation) = engine .query_endpoint_config_with_generation(&input) .expect("endpoint config"); let config = crate::l7::parse_l7_config(&endpoint.expect("JSON-RPC endpoint")) .expect("parse JSON-RPC config"); let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); let body = br#"{"jsonrpc":"2.0","id":1,"method":"sk-ABCDEFGHIJKLMNOP"}"#; let request_info = crate::l7::L7RequestInfo { action: "POST".into(), target: "/rpc".into(), query_params: std::collections::HashMap::new(), graphql: None, jsonrpc: Some(crate::l7::jsonrpc::parse_jsonrpc_body_with_options( body, crate::l7::jsonrpc::JsonRpcInspectionOptions::for_config(&config), )), }; let raw = format!( "POST /rpc HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), std::str::from_utf8(body).unwrap() ) .into_bytes(); let request = crate::l7::rest::request_from_buffered_http("POST", "/rpc", "/rpc", raw).unwrap(); let ctx = crate::l7::relay::L7EvalContext { host: "api.example.test".into(), port: 80, request_default_port: Some(80), policy_name: "jsonrpc_api".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, ..Default::default() }; let runner = openshell_supervisor_middleware::ChainRunner::new( openshell_supervisor_middleware_builtins::services() .into_iter() .next() .expect("built-in middleware service"), ); let chain = vec![openshell_supervisor_middleware::ChainEntry { name: "redactor".into(), implementation: openshell_supervisor_middleware_builtins::BUILTIN_REGEX.into(), order: 0, config: prost_types::Struct::default(), on_error: openshell_supervisor_middleware::OnError::FailClosed, }]; let exchange = crate::l7::middleware::HttpMiddlewareExchange::new( "test-request-id".into(), chain, runner, tunnel_engine.generation_guard().clone(), ); let pipeline = ForwardMiddlewarePipeline { ctx: &ctx, scheme: "http", exchange: &exchange, l7_reevaluation: Some(ForwardL7Reevaluation { config: &config, engine: &tunnel_engine, request_info: &request_info, }), }; let (_app, mut client) = tokio::io::duplex(8192); let outcome = pipeline .apply(request, &mut client) .await .expect("forward middleware pipeline"); match outcome { crate::l7::middleware::MiddlewareApplyResult::Denied { denial } => { assert!(denial.is_none()); } crate::l7::middleware::MiddlewareApplyResult::Allowed(_) => { panic!("policy-invalid transformed request must be denied") } crate::l7::middleware::MiddlewareApplyResult::AdmissionExhausted => { panic!("test middleware work admission must be available") } } } #[tokio::test] async fn forward_reacquires_static_credentials_after_blocked_middleware() { use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; let policy = include_str!("../data/sandbox-policy.rego"); let engine = OpaEngine::from_strings(policy, "network_policies: {}\n").unwrap(); let guard = engine .generation_guard(engine.current_generation()) .expect("generation guard"); let entered = Arc::new(tokio::sync::Notify::new()); let release = Arc::new(tokio::sync::Notify::new()); let runner = openshell_supervisor_middleware::ChainRunner::new(Arc::new( BlockingForwardMiddleware { entered: Arc::clone(&entered), release: Arc::clone(&release), }, )); let state = ProviderCredentialState::from_bound_environment( 1, TestHashMap::from([("API_TOKEN".to_string(), "real-secret".to_string())]), TestHashMap::new(), TestHashMap::new(), TestHashMap::from([( "API_TOKEN".to_string(), StaticCredentialBinding { endpoints: vec![StaticCredentialEndpointBinding { host: "api.example.test".to_string(), port: 80, path: "/allowed/**".to_string(), }], credential_identity: "provider-a:API_TOKEN".to_string(), workload_credential_handle: String::new(), }, )]), Vec::new(), ) .expect("bound provider state"); let ctx = crate::l7::relay::L7EvalContext { host: "api.example.test".into(), port: 80, request_default_port: Some(80), policy_name: "forward".into(), binary_path: "/usr/bin/node".into(), provider_credentials: Some(state.clone()), secret_resolver: state.resolver(), ..Default::default() }; let raw = b"GET http://api.example.test/allowed/../outside HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\n\r\n"; let prepared = prepare_forward_target( "/allowed/../outside", crate::l7::path::CanonicalizeOptions::default(), ) .expect("canonical target"); assert_eq!(prepared.canonical_path, "/outside"); let request = crate::l7::rest::request_from_buffered_http( "GET", &prepared.canonical_path, &prepared.upstream_target, canonicalize_forward_host_header(raw, "api.example.test").unwrap(), ) .unwrap(); let chain = vec![openshell_supervisor_middleware::ChainEntry { name: "blocker".into(), implementation: "test/blocking-forward".into(), order: 0, config: prost_types::Struct::default(), on_error: openshell_supervisor_middleware::OnError::FailClosed, }]; let exchange = crate::l7::middleware::HttpMiddlewareExchange::new( "test-request-id".into(), chain, runner, guard.clone(), ); let pipeline = ForwardMiddlewarePipeline { ctx: &ctx, scheme: "http", exchange: &exchange, l7_reevaluation: None, }; let (_app, mut client) = tokio::io::duplex(8192); let revoke = async { entered.notified().await; state.revoke_static_provider_environment(2); release.notify_one(); }; let (outcome, ()) = tokio::join!(pipeline.apply(request, &mut client), revoke); let request = match outcome.expect("middleware pipeline") { crate::l7::middleware::MiddlewareApplyResult::Allowed(request) => request, crate::l7::middleware::MiddlewareApplyResult::Denied { .. } => { panic!("blocking middleware should allow after release") } crate::l7::middleware::MiddlewareApplyResult::AdmissionExhausted => { panic!("blocking middleware should already hold admission") } }; let credentials = endpoint_credentials_for_request( ctx.provider_credentials.as_ref(), ctx.secret_resolver.clone(), &ctx.host, ctx.port, &prepared.canonical_path, ); assert!( credentials.resolver.is_none(), "revoked live state must supersede the connection-open resolver" ); let rewrite = rewrite_forward_request( &request.raw_header, request.raw_header.len(), &prepared.upstream_target, "api.example.test", credentials.resolver.as_deref(), ); assert!( rewrite.is_err(), "revoked credential placeholder must fail before upstream relay" ); let (proxy_upstream, mut upstream) = tokio::io::duplex(8192); drop(proxy_upstream); let mut forwarded = Vec::new(); upstream.read_to_end(&mut forwarded).await.unwrap(); assert!( forwarded.is_empty(), "revoked forward credential request must not reach upstream" ); } #[test] fn forward_l7_allowed_activity_is_deferred_until_after_ssrf() { let (tx, mut rx) = mpsc::channel(4); let activity_tx = Some(tx); let l7_activity_pending = true; assert!( rx.try_recv().is_err(), "allowed L7 evaluation must not emit activity before SSRF succeeds" ); emit_activity_simple(activity_tx.as_ref(), true, "ssrf"); let event = rx .try_recv() .expect("SSRF denial should emit the request activity"); assert!(event.denied); assert_eq!(event.deny_group, "ssrf"); assert!( rx.try_recv().is_err(), "SSRF-denied forward request must not also emit allowed L7 activity" ); emit_forward_success_activity(activity_tx.as_ref(), l7_activity_pending); let event = rx .try_recv() .expect("L7 activity should emit after SSRF succeeds"); assert!(!event.denied); assert_eq!(event.deny_group, "l7_policy"); } #[test] fn forward_middleware_denial_does_not_emit_success_activity() { let (tx, mut rx) = mpsc::channel(4); let activity_tx = Some(tx); emit_activity_simple(activity_tx.as_ref(), true, "middleware"); let event = rx .try_recv() .expect("middleware denial should emit the request activity"); assert!(event.denied); assert_eq!(event.deny_group, "middleware"); assert!( rx.try_recv().is_err(), "middleware-denied forward request must not emit success activity" ); } #[test] fn forward_success_activity_uses_unknown_without_l7() { let (tx, mut rx) = mpsc::channel(4); let activity_tx = Some(tx); emit_forward_success_activity(activity_tx.as_ref(), false); let event = rx .try_recv() .expect("non-L7 forward success should emit activity"); assert!(!event.denied); assert_eq!(event.deny_group, "unknown"); } fn forward_test_guard() -> PolicyGenerationGuard { let policy = include_str!("../data/sandbox-policy.rego"); let policy_data = "network_policies: {}\n"; let engine = OpaEngine::from_strings(policy, policy_data).unwrap(); engine .generation_guard(engine.current_generation()) .unwrap() } #[tokio::test] async fn plaintext_forward_relay_applies_response_middleware() { let guard = forward_test_guard(); let ctx = crate::l7::relay::L7EvalContext { host: "api.example.test".into(), port: 80, request_default_port: Some(80), policy_name: "forward".into(), binary_path: "/usr/bin/curl".into(), ..Default::default() }; let runner = openshell_supervisor_middleware::ChainRunner::new(Arc::new( ForwardResponseHeadersMiddleware { expected_path: "/demo".into(), forbidden_path_fragment: "not-present".into(), block: false, }, )); let chain = vec![openshell_supervisor_middleware::ChainEntry { name: "response".into(), implementation: "test/forward-response".into(), order: 0, config: prost_types::Struct::default(), on_error: openshell_supervisor_middleware::OnError::FailClosed, }]; let exchange = crate::l7::middleware::HttpMiddlewareExchange::new( "correlated-request-id".into(), chain, runner, guard.clone(), ); let request = b"GET /demo HTTP/1.1\r\nHost: api.example.test\r\n\r\n".to_vec(); let (mut proxy_to_upstream, mut upstream) = tokio::io::duplex(8192); let (mut app, mut proxy_to_client) = tokio::io::duplex(8192); let upstream_task = tokio::spawn(async move { let mut request = vec![0; 1024]; let size = upstream.read(&mut request).await.unwrap(); assert!(request[..size].ends_with(b"\r\n\r\n")); upstream .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") .await .unwrap(); }); let outcome = relay_rewritten_forward_request( "GET", "/demo", request, &mut proxy_to_client, &mut proxy_to_upstream, ForwardRelayOptions { body_classifier: None, generation_guard: &guard, credential_generation: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: "", signing_region: "", host: "api.example.test", port: 80, response_middleware: Some(ForwardResponseMiddleware { ctx: &ctx, scheme: "http", exchange: &exchange, }), endpoint_observer: None, }, ) .await .expect("plaintext forward relay"); assert!(matches!( outcome, crate::l7::provider::RelayOutcome::Reusable )); upstream_task.await.unwrap(); drop(proxy_to_client); let mut response = Vec::new(); app.read_to_end(&mut response).await.unwrap(); let response = String::from_utf8(response).unwrap(); assert!(response.contains("x-forward-response-test: selected\r\n")); assert!(response.ends_with("\r\n\r\nok")); } #[tokio::test] async fn plaintext_forward_response_denial_never_echoes_query_secret() { const SECRET: &str = "sk-forward-query-secret"; let guard = forward_test_guard(); let ctx = crate::l7::relay::L7EvalContext { host: "api.example.test".into(), port: 80, request_default_port: Some(80), policy_name: "forward".into(), binary_path: "/usr/bin/curl".into(), ..Default::default() }; let runner = openshell_supervisor_middleware::ChainRunner::new(Arc::new( ForwardResponseHeadersMiddleware { expected_path: "/demo".into(), forbidden_path_fragment: SECRET.into(), block: true, }, )); let chain = vec![openshell_supervisor_middleware::ChainEntry { name: "response".into(), implementation: "test/forward-response".into(), order: 0, config: prost_types::Struct::default(), on_error: openshell_supervisor_middleware::OnError::FailClosed, }]; let exchange = crate::l7::middleware::HttpMiddlewareExchange::new( "correlated-request-id".into(), chain, runner, guard.clone(), ); let target = format!("/demo?access_token={SECRET}"); let request = format!("GET {target} HTTP/1.1\r\nHost: api.example.test\r\n\r\n").into_bytes(); let (mut proxy_to_upstream, mut upstream) = tokio::io::duplex(8192); let (mut app, mut proxy_to_client) = tokio::io::duplex(8192); let upstream_task = tokio::spawn(async move { let mut request = vec![0; 1024]; let size = upstream.read(&mut request).await.unwrap(); assert!(request[..size].ends_with(b"\r\n\r\n")); upstream .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") .await .unwrap(); }); let outcome = relay_rewritten_forward_request( "GET", &target, request, &mut proxy_to_client, &mut proxy_to_upstream, ForwardRelayOptions { body_classifier: None, generation_guard: &guard, credential_generation: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: "", signing_region: "", host: "api.example.test", port: 80, response_middleware: Some(ForwardResponseMiddleware { ctx: &ctx, scheme: "http", exchange: &exchange, }), endpoint_observer: None, }, ) .await .expect("plaintext forward response denial"); assert!(matches!( outcome, crate::l7::provider::RelayOutcome::Consumed )); upstream_task.await.unwrap(); drop(proxy_to_client); let mut response = Vec::new(); app.read_to_end(&mut response).await.unwrap(); let response = String::from_utf8(response).unwrap(); assert!(response.starts_with("HTTP/1.1 403 Forbidden\r\n")); assert!(response.contains("\"path\":\"/demo\"")); assert!(!response.contains(SECRET)); assert!(!response.contains("access_token")); } async fn relay_forward_request_and_capture( method: &str, path: &str, raw: &[u8], resolver: Option<&SecretResolver>, request_body_credential_rewrite: bool, ) -> Result { relay_forward_request_and_capture_classified( method, path, raw, resolver, request_body_credential_rewrite, None, ) .await } async fn relay_forward_request_and_capture_classified( method: &str, path: &str, raw: &[u8], resolver: Option<&SecretResolver>, request_body_credential_rewrite: bool, body_classifier: Option<&secrets::body::BodyCredentialClassifier>, ) -> Result { let guard = forward_test_guard(); let target_uri = std::str::from_utf8(raw) .expect("forward test request is UTF-8") .lines() .next() .and_then(|line| line.split(' ').nth(1)) .expect("forward test request has an absolute target"); let (_, host, port, _) = parse_proxy_uri(target_uri)?; let authority = canonical_forward_authority(&host, port); let rewritten = rewrite_forward_request(raw, raw.len(), path, &authority, resolver) .map_err(|e| miette::miette!("{e}"))?; let (mut proxy_to_upstream, mut upstream_side) = tokio::io::duplex(8192); let (mut _app_side, mut proxy_to_client) = tokio::io::duplex(8192); let upstream_task = tokio::spawn(async move { let mut buf = vec![0u8; 8192]; let mut total = 0usize; let mut expected_total = None; loop { let n = upstream_side.read(&mut buf[total..]).await.unwrap(); if n == 0 { break; } total += n; if expected_total.is_none() && let Some(end) = buf[..total].windows(4).position(|w| w == b"\r\n\r\n") { let header_end = end + 4; let headers = String::from_utf8_lossy(&buf[..header_end]); let len = headers .lines() .find_map(|line| { let (name, value) = line.split_once(':')?; name.eq_ignore_ascii_case("content-length") .then(|| value.trim().parse::().ok()) .flatten() }) .unwrap_or(0); expected_total = Some(header_end + len); } if expected_total.is_some_and(|expected| total >= expected) { break; } } upstream_side .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") .await .unwrap(); upstream_side.flush().await.unwrap(); String::from_utf8_lossy(&buf[..total]).to_string() }); relay_rewritten_forward_request( method, path, rewritten, &mut proxy_to_client, &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, credential_generation: None, body_classifier, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: resolver, request_body_credential_rewrite, deny_uninspected_credentials: body_classifier.is_some(), credential_signing: crate::l7::CredentialSigning::None, signing_service: "", signing_region: "", host: "", port: 0, response_middleware: None, endpoint_observer: None, }, ) .await?; upstream_task .await .map_err(|e| miette::miette!("upstream task failed: {e}")) } fn forward_token_grant_context( resolver_response: std::result::Result<&str, &str>, ) -> ( crate::l7::relay::L7EvalContext, crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, ) { let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; let fixture = forward_token_grant_fixture(provider_key, resolver_response, false); let ctx = crate::l7::relay::L7EvalContext { host: "api.example.test".into(), port: 8080, request_default_port: Some(8080), policy_name: "rest_api".into(), binary_path: "/usr/bin/curl".into(), ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, dynamic_credentials: Some(fixture.dynamic_credentials()), token_grant_resolver: Some(fixture.resolver()), ..Default::default() }; (ctx, fixture) } fn forward_token_exchange_context( resolver_response: std::result::Result<&str, &str>, ) -> ( crate::l7::relay::L7EvalContext, crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, ) { let (mut ctx, _) = forward_token_grant_context(Ok("unused-token")); let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; let fixture = forward_token_grant_fixture(provider_key, resolver_response, true); ctx.dynamic_credentials = Some(fixture.dynamic_credentials()); ctx.token_grant_resolver = Some(fixture.resolver()); (ctx, fixture) } fn forward_token_grant_fixture( provider_key: &str, resolver_response: std::result::Result<&str, &str>, token_exchange: bool, ) -> crate::l7::token_grant_injection::test_support::TokenGrantTestFixture { match (resolver_response, token_exchange) { (Ok(token), false) => { crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( provider_key, token, ) } (Ok(token), true) => { crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success_token_exchange( provider_key, token, ) } (Err(error), false) => { crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure( provider_key, error, ) } (Err(error), true) => { crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure_token_exchange( provider_key, error, ) } } } fn authorization_header_count(headers: &str) -> usize { headers .lines() .filter(|line| { line.split_once(':') .is_some_and(|(name, _)| name.eq_ignore_ascii_case("authorization")) }) .count() } fn forward_websocket_policy_parts( data: &str, host: &str, port: u16, path: &str, policy_name: &str, ) -> ( crate::l7::L7EndpointConfig, crate::opa::TunnelPolicyEngine, crate::l7::relay::L7EvalContext, ) { let policy = include_str!("../data/sandbox-policy.rego"); let engine = OpaEngine::from_strings(policy, data).unwrap(); let authorization = engine .authorize_egress(&crate::opa::NetworkInput { host: host.to_string(), port, binary_path: PathBuf::from("/usr/bin/node"), binary_sha256: String::new(), ancestors: vec![], cmdline_paths: vec![], }) .expect("authorize egress"); let decision = EgressDecision { intent: EgressIntent::forward_http(host.to_string(), port), action: authorization.action.clone(), policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(PathBuf::from("/usr/bin/node")), binary_pid: None, ancestors: vec![], cmdline_paths: vec![], }; let route = query_l7_route_snapshot(&decision, host, port).expect("L7 route should match"); let config = select_l7_config_for_path(&route.configs, path) .expect("path-specific L7 config should match") .config .clone(); let tunnel_engine = engine .clone_engine_for_tunnel(route.l7_policy_generation) .expect("tunnel engine"); let ctx = crate::l7::relay::L7EvalContext { host: host.to_string(), port, request_default_port: Some(port), policy_name: policy_name.to_string(), binary_path: "/usr/bin/node".to_string(), ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, ..Default::default() }; (config, tunnel_engine, ctx) } async fn read_http_headers(reader: &mut R) -> Vec { read_http_headers_with_timeout(reader, std::time::Duration::from_secs(1)).await } async fn read_http_headers_with_timeout( reader: &mut R, timeout: std::time::Duration, ) -> Vec { let mut bytes = Vec::new(); let mut chunk = [0u8; 256]; loop { let n = tokio::time::timeout(timeout, reader.read(&mut chunk)) .await .expect("HTTP headers should arrive") .expect("header read should succeed"); assert!(n > 0, "stream closed before HTTP headers"); bytes.extend_from_slice(&chunk[..n]); if bytes.windows(4).any(|w| w == b"\r\n\r\n") { return bytes; } } } async fn read_http_headers_unbounded(reader: &mut R) -> Vec { let mut bytes = Vec::new(); let mut chunk = [0u8; 256]; loop { let n = reader .read(&mut chunk) .await .expect("header read should succeed"); assert!(n > 0, "stream closed before HTTP headers"); bytes.extend_from_slice(&chunk[..n]); if bytes.windows(4).any(|w| w == b"\r\n\r\n") { return bytes; } } } fn masked_text_frame(payload: &[u8]) -> Vec { let mask = [0x11, 0x22, 0x33, 0x44]; assert!( payload.len() <= 125, "test helper only supports small frames" ); let payload_len = u8::try_from(payload.len()).expect("small frame length"); let mut frame = vec![0x81, 0x80 | payload_len]; frame.extend_from_slice(&mask); frame.extend( payload .iter() .enumerate() .map(|(idx, byte)| byte ^ mask[idx % 4]), ); frame } fn masked_close_code(frame: &[u8]) -> u16 { assert_eq!(frame[0] & 0x0f, 0x08, "expected a close frame"); assert_eq!(frame[1] & 0x7f, 2, "expected a two-byte close code"); assert_ne!(frame[1] & 0x80, 0, "client-to-upstream close is masked"); let decoded = [frame[6] ^ frame[2], frame[7] ^ frame[3]]; u16::from_be_bytes(decoded) } async fn forward_websocket_denied_after_upgrade( config: crate::l7::L7EndpointConfig, tunnel_engine: crate::opa::TunnelPolicyEngine, ctx: crate::l7::relay::L7EvalContext, path: &str, payload: &str, ) -> (miette::Report, Vec) { let host = ctx.host.clone(); let port = ctx.port; let raw = format!( "GET http://{host}{path} HTTP/1.1\r\n\ Host: {host}\r\n\ Upgrade: websocket\r\n\ Connection: Upgrade\r\n\ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ Sec-WebSocket-Version: 13\r\n\r\n" ); let rewritten = rewrite_forward_request( raw.as_bytes(), raw.len(), path, &canonical_forward_authority(&host, port), None, ) .expect("forward websocket request should rewrite to origin form"); let websocket_extensions = crate::l7::relay::websocket_extension_mode(&config, false); let target = path.to_string(); let query_params = std::collections::HashMap::new(); let (mut proxy_to_upstream, mut upstream) = tokio::io::duplex(8192); let (mut app, mut proxy_to_client) = tokio::io::duplex(8192); let relay = tokio::spawn(async move { let guard = tunnel_engine.generation_guard(); let outcome = relay_rewritten_forward_request( "GET", &target, rewritten, &mut proxy_to_client, &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: guard, credential_generation: None, body_classifier: None, websocket_extensions, secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: "", signing_region: "", host: "", port: 0, response_middleware: None, endpoint_observer: None, }, ) .await?; if let crate::l7::provider::RelayOutcome::Upgraded { overflow, websocket_permessage_deflate, .. } = outcome { let mut options = crate::l7::relay::upgrade_options( &config, &ctx, true, &target, &query_params, Some(&tunnel_engine), ); options.websocket.permessage_deflate = websocket_permessage_deflate; crate::l7::relay::handle_upgrade( &mut proxy_to_client, &mut proxy_to_upstream, overflow, &host, port, options, ) .await?; } Ok::<(), miette::Report>(()) }); let forwarded_headers = read_http_headers(&mut upstream).await; let forwarded_headers = String::from_utf8_lossy(&forwarded_headers); assert!(forwarded_headers.starts_with(&format!("GET {path} HTTP/1.1\r\n"))); assert!(forwarded_headers.contains("Upgrade: websocket\r\n")); upstream .write_all( b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", ) .await .unwrap(); let response = read_http_headers(&mut app).await; assert!(String::from_utf8_lossy(&response).contains("101 Switching Protocols")); app.write_all(&masked_text_frame(payload.as_bytes())) .await .unwrap(); let err = tokio::time::timeout(std::time::Duration::from_secs(1), relay) .await .expect("websocket relay should fail closed after denied frame") .expect("relay task should not panic") .expect_err("denied websocket frame should fail the forward relay"); let mut leaked = Vec::new(); tokio::time::timeout( std::time::Duration::from_secs(1), upstream.read_to_end(&mut leaked), ) .await .expect("upstream side should close") .expect("upstream read should succeed"); (err, leaked) } #[test] fn forward_websocket_upgrade_options_enable_native_policy_context() { let (_, resolver) = SecretResolver::from_provider_env( [("DISCORD_BOT_TOKEN".to_string(), "discord-real".to_string())] .into_iter() .collect(), ); let resolver = resolver.map(Arc::new); let policy = include_str!("../data/sandbox-policy.rego"); let policy_data = "network_policies: {}\n"; let engine = OpaEngine::from_strings(policy, policy_data).unwrap(); let tunnel_engine = engine .clone_engine_for_tunnel(engine.current_generation()) .unwrap(); let ctx = crate::l7::relay::L7EvalContext { host: "gateway.example.test".to_string(), port: 80, request_default_port: Some(80), policy_name: "ws_api".to_string(), binary_path: "/usr/bin/node".to_string(), ancestors: vec![], cmdline_paths: vec![], secret_resolver: resolver, ..Default::default() }; let query_params = std::collections::HashMap::new(); let extensions = crate::l7::relay::websocket_extension_mode( &websocket_l7_config(crate::l7::L7Protocol::Websocket, true), false, ); let options = crate::l7::relay::upgrade_options( &websocket_l7_config(crate::l7::L7Protocol::Websocket, true), &ctx, true, "/ws", &query_params, Some(&tunnel_engine), ); assert_eq!( extensions, crate::l7::rest::WebSocketExtensionMode::PermessageDeflate ); assert!(options.websocket.credential_rewrite); assert!(options.secret_resolver.is_some()); assert!(options.generation_guard.is_some()); assert!(options.engine.is_some()); assert!(options.ctx.is_some()); assert!(matches!( options.websocket.message_policy, crate::l7::relay::WebSocketMessagePolicy::Transport )); } #[test] fn forward_websocket_upgrade_options_preserve_rest_without_rewrite() { let ctx = crate::l7::relay::L7EvalContext { host: "gateway.example.test".to_string(), port: 80, request_default_port: Some(80), policy_name: "rest_api".to_string(), binary_path: "/usr/bin/node".to_string(), ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, ..Default::default() }; let query_params = std::collections::HashMap::new(); let config = websocket_l7_config(crate::l7::L7Protocol::Rest, false); let extensions = crate::l7::relay::websocket_extension_mode(&config, false); let options = crate::l7::relay::upgrade_options(&config, &ctx, true, "/ws", &query_params, None); assert_eq!( extensions, crate::l7::rest::WebSocketExtensionMode::Preserve ); assert!(!options.websocket.credential_rewrite); assert!(options.secret_resolver.is_none()); assert!(options.engine.is_none()); assert!(options.ctx.is_none()); assert!(matches!( options.websocket.message_policy, crate::l7::relay::WebSocketMessagePolicy::None )); } #[test] fn rest_websocket_upgrade_carries_guard_without_message_inspector() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), "network_policies: {}\n", ) .expect("test policy"); let tunnel_engine = engine .clone_engine_for_tunnel(engine.current_generation()) .expect("tunnel engine"); let ctx = crate::l7::relay::L7EvalContext { host: "gateway.example.test".into(), port: 443, policy_name: "rest_api".into(), binary_path: "/usr/bin/node".into(), ..Default::default() }; let config = websocket_l7_config(crate::l7::L7Protocol::Rest, false); let options = crate::l7::relay::upgrade_options( &config, &ctx, true, "/ws", &std::collections::HashMap::new(), Some(&tunnel_engine), ); assert!(matches!( options.websocket.message_policy, crate::l7::relay::WebSocketMessagePolicy::None )); assert!(options.generation_guard.is_some()); assert!(options.engine.is_some()); } #[tokio::test] async fn forward_websocket_upgrade_blocks_text_frame_by_policy() { let data = r#" network_policies: ws_api: name: ws_api endpoints: - host: gateway.example.test port: 80 path: "/ws" protocol: websocket enforcement: enforce rules: - allow: method: GET path: "/ws" - allow: method: WEBSOCKET_TEXT path: "/ws" deny_rules: - method: WEBSOCKET_TEXT path: "/ws" binaries: - { path: /usr/bin/node } "#; let (config, tunnel_engine, ctx) = forward_websocket_policy_parts(data, "gateway.example.test", 80, "/ws", "ws_api"); let (err, leaked) = forward_websocket_denied_after_upgrade( config, tunnel_engine, ctx, "/ws", r#"{"type":"unsafe"}"#, ) .await; assert!(err.to_string().contains("websocket text message denied")); assert_eq!( masked_close_code(&leaked), 1008, "only a policy close, not the denied text frame, may reach upstream" ); } #[tokio::test] async fn forward_graphql_websocket_upgrade_blocks_unallowed_operation() { let data = r#" network_policies: graphql_ws: name: graphql_ws endpoints: - host: gateway.example.test port: 80 path: "/graphql" protocol: websocket enforcement: enforce rules: - allow: method: GET path: "/graphql" - allow: operation_type: query fields: [viewer] deny_rules: - operation_type: query fields: [admin] binaries: - { path: /usr/bin/node } "#; let (config, tunnel_engine, ctx) = forward_websocket_policy_parts( data, "gateway.example.test", 80, "/graphql", "graphql_ws", ); assert!( config.websocket_graphql_policy, "operation rules should enable GraphQL-over-WebSocket inspection" ); let (err, leaked) = forward_websocket_denied_after_upgrade( config, tunnel_engine, ctx, "/graphql", r#"{"id":"1","type":"subscribe","payload":{"query":"query { admin }"}}"#, ) .await; assert!(err.to_string().contains("websocket GraphQL message denied")); assert_eq!( masked_close_code(&leaked), 1008, "only a policy close, not the denied GraphQL operation, may reach upstream" ); } #[test] fn l7_route_selection_prefers_path_specific_graphql_endpoint() { let configs = vec![ L7ConfigSnapshot { config: crate::l7::L7EndpointConfig { endpoint_id: String::new(), policy_hash: String::new(), protocol: crate::l7::L7Protocol::Rest, path: "/**".to_string(), tls: crate::l7::TlsMode::Auto, enforcement: crate::l7::EnforcementMode::Enforce, graphql_max_body_bytes: crate::l7::graphql::DEFAULT_MAX_BODY_BYTES, json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, mcp_strict_tool_names: true, mcp_versions: Vec::new(), allow_encoded_slash: false, websocket_credential_rewrite: false, request_body_credential_rewrite: false, allow_uninspected_credentials: false, provider_credentialed: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: String::new(), signing_region: String::new(), }, }, L7ConfigSnapshot { config: crate::l7::L7EndpointConfig { endpoint_id: String::new(), policy_hash: String::new(), protocol: crate::l7::L7Protocol::Graphql, path: "/graphql".to_string(), tls: crate::l7::TlsMode::Auto, enforcement: crate::l7::EnforcementMode::Enforce, graphql_max_body_bytes: crate::l7::graphql::DEFAULT_MAX_BODY_BYTES, json_rpc_max_body_bytes: crate::l7::jsonrpc::DEFAULT_MAX_BODY_BYTES, mcp_strict_tool_names: true, mcp_versions: Vec::new(), allow_encoded_slash: false, websocket_credential_rewrite: false, request_body_credential_rewrite: false, allow_uninspected_credentials: false, provider_credentialed: false, websocket_graphql_policy: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: String::new(), signing_region: String::new(), }, }, ]; let selected = select_l7_config_for_path(&configs, "/graphql").expect("expected path-specific route"); assert_eq!(selected.config.protocol, crate::l7::L7Protocol::Graphql); let selected = select_l7_config_for_path(&configs, "/repos/org/repo").expect("expected REST route"); assert_eq!(selected.config.protocol, crate::l7::L7Protocol::Rest); } // -- is_internal_ip: IPv4 -- #[test] fn test_rejects_ipv4_loopback() { assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::LOCALHOST))); assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)))); } #[test] fn test_rejects_ipv4_private_10() { assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))); assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(10, 255, 255, 255)))); } #[test] fn test_rejects_ipv4_private_172_16() { assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)))); assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(172, 31, 255, 255)))); } #[test] fn test_rejects_ipv4_private_192_168() { assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)))); assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new( 192, 168, 255, 255 )))); } #[test] fn test_rejects_ipv4_link_local_metadata() { // Cloud metadata endpoint assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new( 169, 254, 169, 254 )))); assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1)))); } #[test] fn test_rejects_ipv4_unspecified() { assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED))); } #[test] fn test_rejects_ipv4_cgnat() { // 100.64.0.0/10 — CGNAT / shared address space (RFC 6598) assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)))); assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 100, 50, 3)))); assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new( 100, 127, 255, 255 )))); // Just outside the /10 boundary assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(100, 128, 0, 1)))); assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new( 100, 63, 255, 255 )))); } #[test] fn test_rejects_ipv4_special_use_ranges() { // 192.0.0.0/24 — IETF protocol assignments assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(192, 0, 0, 1)))); // 198.18.0.0/15 — benchmarking assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1)))); assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 19, 255, 255)))); // 198.51.100.0/24 — TEST-NET-2 assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)))); // 203.0.113.0/24 — TEST-NET-3 assert!(is_internal_ip(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)))); } #[test] fn test_rejects_ipv6_mapped_cgnat() { // ::ffff:100.64.0.1 should be caught via IPv4-mapped unwrapping let v6 = Ipv4Addr::new(100, 64, 0, 1).to_ipv6_mapped(); assert!(is_internal_ip(IpAddr::V6(v6))); } #[test] fn test_allows_ipv4_public() { assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)))); assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)))); } #[test] fn test_allows_ipv4_non_private_172() { // 172.32.0.0 is outside the 172.16/12 private range assert!(!is_internal_ip(IpAddr::V4(Ipv4Addr::new(172, 32, 0, 1)))); } // -- is_internal_ip: IPv6 -- #[test] fn test_rejects_ipv6_loopback() { assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::LOCALHOST))); } #[test] fn test_rejects_ipv6_unspecified() { assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::UNSPECIFIED))); } #[test] fn test_rejects_ipv6_link_local() { // fe80::1 assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::new( 0xfe80, 0, 0, 0, 0, 0, 0, 1 )))); } #[test] fn test_rejects_ipv6_unique_local_address() { // fdc4:f303:9324::254 assert!(is_internal_ip(IpAddr::V6(Ipv6Addr::new( 0xfdc4, 0xf303, 0x9324, 0, 0, 0, 0, 0x0254 )))); } #[test] fn test_rejects_ipv4_mapped_ipv6_private() { // ::ffff:10.0.0.1 let v6 = Ipv4Addr::new(10, 0, 0, 1).to_ipv6_mapped(); assert!(is_internal_ip(IpAddr::V6(v6))); } #[test] fn test_rejects_ipv4_mapped_ipv6_loopback() { // ::ffff:127.0.0.1 let v6 = Ipv4Addr::LOCALHOST.to_ipv6_mapped(); assert!(is_internal_ip(IpAddr::V6(v6))); } #[test] fn test_rejects_ipv4_mapped_ipv6_link_local() { // ::ffff:169.254.169.254 let v6 = Ipv4Addr::new(169, 254, 169, 254).to_ipv6_mapped(); assert!(is_internal_ip(IpAddr::V6(v6))); } #[test] fn test_allows_ipv6_public() { // 2001:4860:4860::8888 (Google DNS) assert!(!is_internal_ip(IpAddr::V6(Ipv6Addr::new( 0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888 )))); } #[test] fn test_allows_ipv4_mapped_ipv6_public() { // ::ffff:8.8.8.8 let v6 = Ipv4Addr::new(8, 8, 8, 8).to_ipv6_mapped(); assert!(!is_internal_ip(IpAddr::V6(v6))); } // -- resolve_and_reject_internal -- #[test] fn test_parse_hosts_file_for_host_handles_comments_invalid_rows_and_case() { let contents = r#" # comment 192.168.1.105 searxng.local searxng bad-ip ignored.local 93.184.216.34 Example.Local # trailing comment ::1 loopback.local 192.168.1.105 searxng.local "#; let result = parse_hosts_file_for_host(contents, "SEARXNG.LOCAL"); assert_eq!(result, vec![IpAddr::V4(Ipv4Addr::new(192, 168, 1, 105))]); let public = parse_hosts_file_for_host(contents, "example.local"); assert_eq!(public, vec![IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))]); } #[test] fn test_resolve_from_hosts_file_contents_requires_exact_alias_match() { let contents = "192.168.1.105 searxng.local\n"; assert!( resolve_from_hosts_file_contents(contents, "searxng", 8080).is_empty(), "partial alias match should not resolve" ); let result = resolve_from_hosts_file_contents(contents, "searxng.local", 8080); assert_eq!( result, vec![SocketAddr::new( IpAddr::V4(Ipv4Addr::new(192, 168, 1, 105)), 8080 )] ); } #[test] fn test_resolve_from_hosts_file_contents_public_ip_passes_default_ssrf_check() { let addrs = resolve_from_hosts_file_contents("93.184.216.34 example.local\n", "example.local", 80); assert!(reject_internal_resolved_addrs("example.local", &addrs).is_ok()); } #[test] fn test_resolve_from_hosts_file_contents_private_ip_requires_allowed_ips() { let addrs = resolve_from_hosts_file_contents( "192.168.1.105 searxng.local\n", "searxng.local", 8080, ); let err = reject_internal_resolved_addrs("searxng.local", &addrs).unwrap_err(); assert!( err.contains("internal address"), "expected private hosts-file resolution to remain blocked: {err}" ); let nets = parse_allowed_ips(&["192.168.1.105/32".to_string()]).unwrap(); assert!( validate_allowed_ips_for_resolved_addrs("searxng.local", 8080, &addrs, &nets).is_ok() ); } #[test] fn test_declared_endpoint_private_hosts_file_resolution_allowed() { let addrs = resolve_from_hosts_file_contents( "192.168.1.105 searxng.local\n", "searxng.local", 8080, ); assert!(validate_declared_endpoint_resolved_addrs("searxng.local", 8080, &addrs).is_ok()); } #[test] fn test_declared_endpoint_loopback_stays_blocked() { let addrs = resolve_from_hosts_file_contents("127.0.0.1 loopback.local\n", "loopback.local", 80); let err = validate_declared_endpoint_resolved_addrs("loopback.local", 80, &addrs).unwrap_err(); assert!( err.contains("always-blocked"), "expected loopback to stay blocked: {err}" ); } #[test] fn test_declared_endpoint_link_local_stays_blocked() { let addrs = resolve_from_hosts_file_contents( "169.254.169.254 metadata.local\n", "metadata.local", 80, ); let err = validate_declared_endpoint_resolved_addrs("metadata.local", 80, &addrs).unwrap_err(); assert!( err.contains("always-blocked"), "expected link-local to stay blocked: {err}" ); } #[test] fn test_declared_endpoint_blocks_control_plane_ports() { let addrs = resolve_from_hosts_file_contents("10.0.0.5 kube-api.local\n", "kube-api.local", 6443); let err = validate_declared_endpoint_resolved_addrs("kube-api.local", 6443, &addrs).unwrap_err(); assert!( err.contains("blocked control-plane port"), "expected control-plane port to stay blocked: {err}" ); } #[test] fn test_resolve_from_hosts_file_contents_always_blocked_ip_stays_blocked() { let addrs = resolve_from_hosts_file_contents("127.0.0.1 loopback.local\n", "loopback.local", 80); let nets = vec!["127.0.0.0/8".parse::().unwrap()]; let err = validate_allowed_ips_for_resolved_addrs("loopback.local", 80, &addrs, &nets) .unwrap_err(); assert!( err.contains("always-blocked"), "expected always-blocked hosts-file resolution to stay blocked: {err}" ); } #[test] fn test_resolve_from_hosts_file_contents_returns_empty_without_match() { let result = resolve_from_hosts_file_contents("192.168.1.105 searxng.local\n", "missing.local", 80); assert!(result.is_empty()); } // -- is_host_gateway_alias -- #[test] fn test_is_host_gateway_alias_recognises_known_aliases() { assert!(is_host_gateway_alias("host.openshell.internal")); assert!(is_host_gateway_alias("host.containers.internal")); assert!(is_host_gateway_alias("host.docker.internal")); } #[test] fn test_is_host_gateway_alias_is_case_insensitive() { assert!(is_host_gateway_alias("HOST.OPENSHELL.INTERNAL")); assert!(is_host_gateway_alias("Host.Containers.Internal")); assert!(is_host_gateway_alias("HOST.DOCKER.INTERNAL")); } #[test] fn test_is_host_gateway_alias_rejects_unknown_hosts() { assert!(!is_host_gateway_alias("api.example.com")); assert!(!is_host_gateway_alias("host.openshell.internal.evil.com")); assert!(!is_host_gateway_alias("evil.host.openshell.internal")); assert!(!is_host_gateway_alias("openshell.internal")); assert!(!is_host_gateway_alias("")); } // -- is_cloud_metadata_ip -- #[test] fn test_is_cloud_metadata_ip_blocks_known_metadata_ip() { assert!(is_cloud_metadata_ip(IpAddr::V4(Ipv4Addr::new( 169, 254, 169, 254 )))); } #[test] fn test_is_cloud_metadata_ip_allows_other_link_local() { // The pasta gateway address on this test host — not a metadata IP. assert!(!is_cloud_metadata_ip(IpAddr::V4(Ipv4Addr::new( 169, 254, 1, 2 )))); assert!(!is_cloud_metadata_ip(IpAddr::V4(Ipv4Addr::new( 169, 254, 0, 1 )))); } #[test] fn test_is_cloud_metadata_ip_allows_private_and_public() { assert!(!is_cloud_metadata_ip(IpAddr::V4(Ipv4Addr::new( 10, 0, 0, 1 )))); assert!(!is_cloud_metadata_ip(IpAddr::V4(Ipv4Addr::new( 192, 168, 1, 1 )))); assert!(!is_cloud_metadata_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); } #[test] fn test_is_cloud_metadata_ip_blocks_ipv4_mapped_metadata() { // ::ffff:169.254.169.254 is the IPv4-mapped IPv6 representation of the // AWS/GCP/Azure IMDS endpoint. is_link_local_ip() recognizes it as // link-local, so is_cloud_metadata_ip() must also catch it — otherwise // the trusted-gateway exemption would be granted to the metadata service. let mapped = Ipv4Addr::new(169, 254, 169, 254).to_ipv6_mapped(); assert!( is_cloud_metadata_ip(IpAddr::V6(mapped)), "::ffff:169.254.169.254 must be recognized as cloud metadata" ); } #[test] fn test_is_cloud_metadata_ip_allows_other_ipv4_mapped_link_local() { // Other IPv4-mapped link-local addresses are NOT metadata. let mapped = Ipv4Addr::new(169, 254, 1, 2).to_ipv6_mapped(); assert!( !is_cloud_metadata_ip(IpAddr::V6(mapped)), "::ffff:169.254.1.2 should not be flagged as cloud metadata" ); } // -- detect_trusted_host_gateway -- #[test] fn test_detect_trusted_host_gateway_returns_ip_from_hosts_content() { // We test the underlying parser directly since detect_trusted_host_gateway // reads the real /etc/hosts. The production code composes these same primitives. let contents = "169.254.1.2\thost.openshell.internal host.containers.internal\n"; let ips = parse_hosts_file_for_host(contents, "host.openshell.internal"); assert_eq!(ips, vec![IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2))]); } #[test] fn test_detect_trusted_host_gateway_ignores_cloud_metadata_ip() { // Simulate a /etc/hosts where the driver injected the cloud metadata IP — // this should be caught and suppressed. let contents = "169.254.169.254\thost.openshell.internal\n"; let ips = parse_hosts_file_for_host(contents, "host.openshell.internal"); assert_eq!(ips, vec![IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))]); // is_cloud_metadata_ip should flag it, preventing the exemption. assert!(is_cloud_metadata_ip(ips[0])); } #[test] fn test_detect_trusted_host_gateway_no_entry_returns_empty() { let contents = "127.0.0.1 localhost\n"; let ips = parse_hosts_file_for_host(contents, "host.openshell.internal"); assert!(ips.is_empty()); } #[test] fn test_detect_trusted_host_gateway_rejects_loopback() { // Loopback is not link-local — must not receive the SSRF exemption. let ip = IpAddr::V4(Ipv4Addr::LOCALHOST); assert!(!is_cloud_metadata_ip(ip)); assert!(!is_link_local_ip(ip)); // The guard: !link-local → reject. assert!(!is_link_local_ip(ip)); } #[test] fn test_detect_trusted_host_gateway_rejects_unspecified() { // Unspecified (0.0.0.0) is not link-local — must not be trusted. let ip = IpAddr::V4(Ipv4Addr::UNSPECIFIED); assert!(!is_cloud_metadata_ip(ip)); assert!(!is_link_local_ip(ip)); assert!(!is_link_local_ip(ip)); } #[test] fn test_detect_trusted_host_gateway_rejects_loopback_v6() { let ip = IpAddr::V6(Ipv6Addr::LOCALHOST); assert!(!is_cloud_metadata_ip(ip)); assert!(!is_link_local_ip(ip)); } #[test] fn test_detect_trusted_host_gateway_rejects_private_ip() { // Docker bridge (172.17.0.1) and K8s host gateway (192.168.x.x) are // RFC 1918 private addresses — not link-local. Before this fix they // slipped through the old always-blocked guard and received the SSRF // exemption. The new guard (!is_link_local_ip) rejects them, so // connections to these hosts fall through to resolve_and_reject_internal(). for ip in [ IpAddr::V4(Ipv4Addr::new(172, 17, 0, 1)), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), ] { assert!(!is_cloud_metadata_ip(ip), "{ip} should not be metadata"); assert!(!is_link_local_ip(ip), "{ip} should not be link-local"); // Guard fires — exemption disabled. assert!(!is_link_local_ip(ip), "{ip}: guard must reject"); } } #[test] fn test_detect_trusted_host_gateway_allows_link_local_non_metadata() { // 169.254.1.2 (rootless Podman pasta gateway) IS link-local and is // not a cloud metadata IP — it is the only address class the exemption // is designed for. let ip = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)); assert!(!is_cloud_metadata_ip(ip)); assert!(is_link_local_ip(ip)); // Guard does NOT fire — this IP is eligible for the exemption. assert!(is_link_local_ip(ip)); } // -- parse_hosts_file_for_host: multi-entry / duplicate scenarios -- #[test] fn test_parse_hosts_file_single_entry() { // Normal driver-injected case: exactly one IP for the alias. let contents = "169.254.1.2\thost.openshell.internal host.containers.internal\n"; let ips = parse_hosts_file_for_host(contents, "host.openshell.internal"); assert_eq!(ips, vec![IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2))]); } #[test] fn test_parse_hosts_file_duplicate_same_ip_deduplicated() { // Same IP on two separate lines for the same alias — deduplicated to one. let contents = "169.254.1.2\thost.openshell.internal\n\ 169.254.1.2\thost.openshell.internal\n"; let ips = parse_hosts_file_for_host(contents, "host.openshell.internal"); assert_eq!( ips, vec![IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2))], "identical IPs across lines must be deduplicated" ); } #[test] fn test_parse_hosts_file_multiple_distinct_ips() { // Two distinct IPs for the same alias — both returned, first entry wins // in detect_trusted_host_gateway(), second would cause mismatch rejection // in resolve_and_check_trusted_gateway(). let contents = "169.254.1.2\thost.openshell.internal\n\ 169.254.1.3\thost.openshell.internal\n"; let ips = parse_hosts_file_for_host(contents, "host.openshell.internal"); assert_eq!(ips.len(), 2, "two distinct IPs must both be returned"); assert_eq!(ips[0], IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2))); assert_eq!(ips[1], IpAddr::V4(Ipv4Addr::new(169, 254, 1, 3))); } #[test] fn test_parse_hosts_file_first_entry_wins_on_ambiguity() { // detect_trusted_host_gateway() pins to the first entry via .next(). // Verify the ordering guarantee: first line wins. let contents = "169.254.1.3\thost.openshell.internal\n\ 169.254.1.2\thost.openshell.internal\n"; let ips = parse_hosts_file_for_host(contents, "host.openshell.internal"); assert_eq!( ips[0], IpAddr::V4(Ipv4Addr::new(169, 254, 1, 3)), "first line must be first in the returned vec" ); } #[test] fn test_parse_hosts_file_ignores_other_aliases_on_same_line() { // An entry with multiple aliases — only the matching alias counts. let contents = "169.254.1.2\thost.containers.internal host.openshell.internal host.docker.internal\n"; let ips = parse_hosts_file_for_host(contents, "host.openshell.internal"); assert_eq!(ips, vec![IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2))]); // Non-matching aliases on the same line do not produce extra entries. let ips2 = parse_hosts_file_for_host(contents, "host.docker.internal"); assert_eq!(ips2, vec![IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2))]); } #[test] fn test_parse_hosts_file_alias_not_present() { let contents = "127.0.0.1\tlocalhost\n\ ::1\t\tlocalhost\n"; let ips = parse_hosts_file_for_host(contents, "host.openshell.internal"); assert!(ips.is_empty()); } #[test] fn test_parse_hosts_file_comment_lines_skipped() { let contents = "# 169.254.1.2 host.openshell.internal\n\ 169.254.1.2\thost.openshell.internal\n"; let ips = parse_hosts_file_for_host(contents, "host.openshell.internal"); // Commented-out line must not produce an entry. assert_eq!(ips, vec![IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2))]); } #[test] fn test_parse_hosts_file_inline_comment_stripped() { // Anything after '#' on a data line is treated as a comment. let contents = "169.254.1.2\thost.openshell.internal # injected by driver\n"; let ips = parse_hosts_file_for_host(contents, "host.openshell.internal"); assert_eq!(ips, vec![IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2))]); } // -- resolve_and_check_trusted_gateway -- #[tokio::test] async fn test_trusted_gateway_allows_link_local_gateway_ip() { // Simulate the rootless Podman pasta case: host.openshell.internal // points to a link-local address which is the only path to the host. let trusted_gw = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)); // We resolve via /etc/hosts (pid=0 falls back to system), so we // exercise the trusted_gw mismatch / cloud-metadata guards directly // against a known resolved address. let addrs = [SocketAddr::new(trusted_gw, 8080)]; // Validate the guard logic inline (mirrors resolve_and_check_trusted_gateway). assert!(!is_cloud_metadata_ip(trusted_gw)); assert_eq!(addrs[0].ip(), trusted_gw); } #[tokio::test] async fn test_trusted_gateway_rejects_cloud_metadata_ip() { let trusted_gw = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)); let metadata_ip = IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)); // Simulate resolution returning the metadata IP. let addrs = [SocketAddr::new(metadata_ip, 80)]; // Cloud metadata check must fire before the trusted_gw equality check. let err: Result<(), String> = if is_cloud_metadata_ip(addrs[0].ip()) { Err(format!( "host resolves to cloud metadata address {}, connection rejected", addrs[0].ip() )) } else if addrs[0].ip() != trusted_gw { Err(format!( "host resolves to {} which does not match trusted host gateway \ {trusted_gw}, connection rejected", addrs[0].ip() )) } else { Ok(()) }; assert!(err.is_err()); assert!( err.unwrap_err().contains("cloud metadata"), "expected cloud-metadata rejection" ); } #[tokio::test] async fn test_trusted_gateway_rejects_mismatched_ip() { let trusted_gw = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)); let other_ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); let addrs = [SocketAddr::new(other_ip, 8080)]; let err: Result<(), String> = if is_cloud_metadata_ip(addrs[0].ip()) { Err("cloud metadata".to_string()) } else if addrs[0].ip() != trusted_gw { Err(format!( "{} does not match trusted host gateway {trusted_gw}", addrs[0].ip() )) } else { Ok(()) }; assert!(err.is_err()); assert!( err.unwrap_err() .contains("does not match trusted host gateway"), "expected mismatch rejection" ); } #[tokio::test] async fn test_trusted_gateway_rejects_control_plane_port() { // Control-plane port check runs before resolution. let result = resolve_and_check_trusted_gateway( "host.openshell.internal", 6443, IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)), 0, ) .await; assert!(result.is_err()); assert!( result.unwrap_err().contains("blocked control-plane port"), "expected control-plane port rejection" ); } #[tokio::test] async fn test_trusted_gateway_rejects_all_control_plane_ports() { let trusted_gw = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)); for &port in BLOCKED_CONTROL_PLANE_PORTS { let result = resolve_and_check_trusted_gateway("host.openshell.internal", port, trusted_gw, 0) .await; assert!( result.is_err(), "port {port} should be blocked by control-plane guard" ); assert!( result.unwrap_err().contains("blocked control-plane port"), "expected control-plane rejection for port {port}" ); } } #[tokio::test] async fn test_trusted_gateway_rejects_loopback_as_trusted_gw() { // Defense-in-depth: even if detect_trusted_host_gateway somehow admitted // a loopback IP, resolve_and_check_trusted_gateway must reject it. // Using an IP literal as the host bypasses DNS and gives a deterministic // resolved address, allowing us to exercise the actual function. let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST); let result = resolve_and_check_trusted_gateway("127.0.0.1", 8080, loopback, 0).await; assert!(result.is_err(), "loopback must be rejected"); let err = result.unwrap_err(); assert!( err.contains("non-link-local"), "expected non-link-local rejection, got: {err}" ); } #[tokio::test] async fn test_trusted_gateway_rejects_unspecified_as_trusted_gw() { // Defense-in-depth: 0.0.0.0 as trusted_gw must be rejected. // IP literal resolves to 0.0.0.0 directly, bypassing DNS. let unspecified = IpAddr::V4(Ipv4Addr::UNSPECIFIED); let result = resolve_and_check_trusted_gateway("0.0.0.0", 8080, unspecified, 0).await; assert!(result.is_err(), "unspecified must be rejected"); let err = result.unwrap_err(); assert!( err.contains("non-link-local"), "expected non-link-local rejection, got: {err}" ); } #[tokio::test] async fn test_trusted_gateway_rejects_ip_literal_mismatch() { // If the requested IP literal doesn't match trusted_gw, the mismatch // guard fires. This exercises the full resolution→validation path. let trusted_gw = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)); let other_ip = "10.0.0.1"; // RFC1918, resolves as a literal let result = resolve_and_check_trusted_gateway(other_ip, 8080, trusted_gw, 0).await; assert!(result.is_err(), "IP mismatch must be rejected"); let err = result.unwrap_err(); assert!( err.contains("does not match trusted host gateway"), "expected mismatch rejection, got: {err}" ); } #[tokio::test] async fn test_trusted_gateway_rejects_cloud_metadata_literal() { // Cloud metadata IP as a literal address — must be rejected even when // it matches trusted_gw (which detect_trusted_host_gateway prevents, // but this is the defense-in-depth layer). let metadata = IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)); let result = resolve_and_check_trusted_gateway("169.254.169.254", 80, metadata, 0).await; assert!(result.is_err(), "cloud metadata IP must be rejected"); let err = result.unwrap_err(); assert!( err.contains("cloud metadata"), "expected cloud-metadata rejection, got: {err}" ); } #[tokio::test] async fn test_trusted_gateway_rejects_private_ip_as_trusted_gw() { // Defense-in-depth: a private RFC 1918 IP (e.g. Docker bridge 172.17.0.1) // must be rejected even if it somehow matched trusted_gw. // detect_trusted_host_gateway() already blocks these via !is_link_local_ip(), // but resolve_and_check_trusted_gateway() must enforce the same invariant. let docker_bridge = IpAddr::V4(Ipv4Addr::new(172, 17, 0, 1)); let result = resolve_and_check_trusted_gateway("172.17.0.1", 8080, docker_bridge, 0).await; assert!(result.is_err(), "private RFC 1918 IP must be rejected"); let err = result.unwrap_err(); assert!( err.contains("non-link-local"), "expected non-link-local rejection for private IP, got: {err}" ); } #[tokio::test] async fn test_rejects_localhost_resolution() { let result = resolve_and_reject_internal("localhost", 80, 0).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( err.contains("internal address"), "expected 'internal address' in error: {err}" ); } #[tokio::test] async fn test_rejects_loopback_ip_literal() { let result = resolve_and_reject_internal("127.0.0.1", 443, 0).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( err.contains("internal address"), "expected 'internal address' in error: {err}" ); } #[tokio::test] async fn test_rejects_metadata_ip() { let result = resolve_and_reject_internal("169.254.169.254", 80, 0).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( err.contains("internal address"), "expected 'internal address' in error: {err}" ); } #[tokio::test] async fn test_dns_failure_returns_error() { let result = resolve_and_reject_internal("this-host-does-not-exist.invalid", 80, 0).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( err.contains("DNS resolution failed"), "expected 'DNS resolution failed' in error: {err}" ); } // -- is_always_blocked_ip -- #[test] fn test_always_blocked_loopback_v4() { assert!(is_always_blocked_ip(IpAddr::V4(Ipv4Addr::LOCALHOST))); assert!(is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new( 127, 0, 0, 2 )))); } #[test] fn test_always_blocked_link_local_v4() { assert!(is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new( 169, 254, 169, 254 )))); assert!(is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new( 169, 254, 0, 1 )))); } #[test] fn test_always_blocked_loopback_v6() { assert!(is_always_blocked_ip(IpAddr::V6(Ipv6Addr::LOCALHOST))); } #[test] fn test_always_blocked_link_local_v6() { assert!(is_always_blocked_ip(IpAddr::V6(Ipv6Addr::new( 0xfe80, 0, 0, 0, 0, 0, 0, 1 )))); } #[test] fn test_always_blocked_ipv4_unspecified() { assert!(is_always_blocked_ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED))); } #[test] fn test_always_blocked_ipv6_unspecified() { assert!(is_always_blocked_ip(IpAddr::V6(Ipv6Addr::UNSPECIFIED))); } #[test] fn test_always_blocked_ipv4_mapped_v6_loopback() { let v6 = Ipv4Addr::LOCALHOST.to_ipv6_mapped(); assert!(is_always_blocked_ip(IpAddr::V6(v6))); } #[test] fn test_always_blocked_ipv4_mapped_v6_link_local() { let v6 = Ipv4Addr::new(169, 254, 169, 254).to_ipv6_mapped(); assert!(is_always_blocked_ip(IpAddr::V6(v6))); } #[test] fn test_always_blocked_allows_rfc1918() { // RFC 1918 addresses should NOT be always-blocked (they're allowed // when allowed_ips is configured) assert!(!is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new( 10, 0, 0, 1 )))); assert!(!is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new( 172, 16, 0, 1 )))); assert!(!is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new( 192, 168, 0, 1 )))); } #[test] fn test_always_blocked_allows_public() { assert!(!is_always_blocked_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); assert!(!is_always_blocked_ip(IpAddr::V6(Ipv6Addr::new( 0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888 )))); } // -- parse_allowed_ips -- #[test] fn test_parse_cidr_notation() { let raw = vec!["10.0.5.0/24".to_string()]; let nets = parse_allowed_ips(&raw).unwrap(); assert_eq!(nets.len(), 1); assert!(nets[0].contains(&IpAddr::V4(Ipv4Addr::new(10, 0, 5, 1)))); assert!(!nets[0].contains(&IpAddr::V4(Ipv4Addr::new(10, 0, 6, 1)))); } #[test] fn test_parse_exact_ip() { let raw = vec!["10.0.5.20".to_string()]; let nets = parse_allowed_ips(&raw).unwrap(); assert_eq!(nets.len(), 1); assert!(nets[0].contains(&IpAddr::V4(Ipv4Addr::new(10, 0, 5, 20)))); assert!(!nets[0].contains(&IpAddr::V4(Ipv4Addr::new(10, 0, 5, 21)))); } #[test] fn test_parse_multiple_entries() { let raw = vec![ "10.0.0.0/8".to_string(), "172.16.0.0/12".to_string(), "192.168.1.1".to_string(), ]; let nets = parse_allowed_ips(&raw).unwrap(); assert_eq!(nets.len(), 3); } #[test] fn test_parse_invalid_entry_errors() { let raw = vec!["not-an-ip".to_string()]; let result = parse_allowed_ips(&raw); assert!(result.is_err()); assert!(result.unwrap_err().contains("invalid CIDR/IP")); } #[test] fn test_parse_mixed_valid_invalid_errors() { let raw = vec!["10.0.5.0/24".to_string(), "garbage".to_string()]; let result = parse_allowed_ips(&raw); assert!(result.is_err()); } #[tokio::test] async fn test_resolve_check_allowed_ips_blocks_loopback() { // Construct nets directly (parse_allowed_ips now rejects always-blocked). let nets = vec!["127.0.0.0/8".parse::().unwrap()]; let result = resolve_and_check_allowed_ips("127.0.0.1", 80, &nets, 0).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( err.contains("always-blocked"), "expected 'always-blocked' in error: {err}" ); } #[tokio::test] async fn test_resolve_check_allowed_ips_blocks_metadata() { // Construct nets directly (parse_allowed_ips now rejects always-blocked). let nets = vec!["169.254.0.0/16".parse::().unwrap()]; let result = resolve_and_check_allowed_ips("169.254.169.254", 80, &nets, 0).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( err.contains("always-blocked"), "expected 'always-blocked' in error: {err}" ); } #[tokio::test] async fn test_resolve_check_allowed_ips_blocks_unspecified() { // Construct nets directly (parse_allowed_ips now rejects always-blocked). let nets = vec!["0.0.0.0/0".parse::().unwrap()]; let result = resolve_and_check_allowed_ips("0.0.0.0", 80, &nets, 0).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( err.contains("always-blocked"), "expected 'always-blocked' in error: {err}" ); } #[tokio::test] async fn test_resolve_check_allowed_ips_rejects_outside_allowlist() { // 8.8.8.8 resolves to a public IP which is NOT in 10.0.0.0/8 let nets = parse_allowed_ips(&["10.0.0.0/8".to_string()]).unwrap(); let result = resolve_and_check_allowed_ips("dns.google", 443, &nets, 0).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( err.contains("not in allowed_ips"), "expected 'not in allowed_ips' in error: {err}" ); } // --- SEC-005: CIDR breadth warning and control-plane port blocklist --- #[tokio::test] async fn test_resolve_check_allowed_ips_blocks_control_plane_ports() { // Use a public CIDR (parse_allowed_ips now rejects 0.0.0.0/0). let nets = parse_allowed_ips(&["8.8.8.0/24".to_string()]).unwrap(); // K8s API server port let result = resolve_and_check_allowed_ips("8.8.8.8", 6443, &nets, 0).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("blocked control-plane port")); // etcd client port let result = resolve_and_check_allowed_ips("8.8.8.8", 2379, &nets, 0).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("blocked control-plane port")); // kubelet API port let result = resolve_and_check_allowed_ips("8.8.8.8", 10250, &nets, 0).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("blocked control-plane port")); } #[tokio::test] async fn test_resolve_check_allowed_ips_allows_non_control_plane_ports() { // Port 443 should not be blocked by the control-plane port list let nets = parse_allowed_ips(&["8.8.8.0/24".to_string()]).unwrap(); let result = resolve_and_check_allowed_ips("8.8.8.8", 443, &nets, 0).await; assert!(result.is_ok()); } #[test] fn test_parse_allowed_ips_broad_cidr_is_accepted() { // Broad CIDRs are accepted (just warned about) -- design trade-off let result = parse_allowed_ips(&["10.0.0.0/8".to_string()]); assert!(result.is_ok()); } // --- parse_allowed_ips: always-blocked rejection tests --- #[test] fn test_parse_allowed_ips_rejects_loopback_cidr() { let result = parse_allowed_ips(&["127.0.0.0/8".to_string()]); assert!(result.is_err()); assert!(result.unwrap_err().contains("always-blocked")); } #[test] fn test_parse_allowed_ips_rejects_link_local_cidr() { let result = parse_allowed_ips(&["169.254.0.0/16".to_string()]); assert!(result.is_err()); assert!(result.unwrap_err().contains("always-blocked")); } #[test] fn test_parse_allowed_ips_rejects_unspecified() { let result = parse_allowed_ips(&["0.0.0.0".to_string()]); assert!(result.is_err()); assert!(result.unwrap_err().contains("always-blocked")); } #[test] fn test_parse_allowed_ips_rejects_single_loopback_ip() { let result = parse_allowed_ips(&["127.0.0.1".to_string()]); assert!(result.is_err()); assert!(result.unwrap_err().contains("always-blocked")); } #[test] fn test_parse_allowed_ips_rejects_single_metadata_ip() { let result = parse_allowed_ips(&["169.254.169.254".to_string()]); assert!(result.is_err()); assert!(result.unwrap_err().contains("always-blocked")); } #[test] fn test_parse_allowed_ips_rejects_wildcard_cidr() { let result = parse_allowed_ips(&["0.0.0.0/0".to_string()]); assert!(result.is_err()); assert!(result.unwrap_err().contains("always-blocked")); } #[test] fn test_parse_allowed_ips_mixed_valid_and_blocked() { // A blocked entry taints the whole batch. let result = parse_allowed_ips(&["10.0.5.0/24".to_string(), "127.0.0.1".to_string()]); assert!(result.is_err()); assert!(result.unwrap_err().contains("always-blocked")); } #[test] fn test_parse_allowed_ips_accepts_rfc1918() { let result = parse_allowed_ips(&["10.0.5.0/24".to_string(), "192.168.1.0/24".to_string()]); assert!(result.is_ok()); } // --- implicit_allowed_ips_for_ip_host: always-blocked skip tests --- #[test] fn test_implicit_allowed_ips_skips_loopback() { let result = implicit_allowed_ips_for_ip_host("127.0.0.1"); assert!(result.is_empty()); } #[test] fn test_implicit_allowed_ips_skips_link_local() { let result = implicit_allowed_ips_for_ip_host("169.254.169.254"); assert!(result.is_empty()); } #[test] fn test_implicit_allowed_ips_skips_unspecified() { let result = implicit_allowed_ips_for_ip_host("0.0.0.0"); assert!(result.is_empty()); } #[test] fn test_implicit_allowed_ips_allows_rfc1918() { let result = implicit_allowed_ips_for_ip_host("10.0.5.20"); assert_eq!(result, vec!["10.0.5.20"]); } // --- extract_host_from_uri tests --- #[test] fn test_extract_host_from_http_uri() { assert_eq!( extract_host_from_uri("http://example.com/path"), "example.com" ); } #[test] fn test_extract_host_from_https_uri() { assert_eq!( extract_host_from_uri("https://api.openai.com/v1/chat/completions"), "api.openai.com" ); } #[test] fn test_extract_host_from_uri_with_port() { assert_eq!( extract_host_from_uri("http://example.com:8080/path"), "example.com" ); } #[test] fn test_extract_host_from_uri_ipv6() { assert_eq!(extract_host_from_uri("http://[::1]:8080/path"), "[::1]"); } #[test] fn test_extract_host_from_uri_no_path() { assert_eq!(extract_host_from_uri("http://example.com"), "example.com"); } #[test] fn test_extract_host_from_uri_empty() { assert_eq!(extract_host_from_uri(""), "unknown"); } #[test] fn test_extract_host_from_uri_malformed() { // Gracefully handles garbage input let result = extract_host_from_uri("not-a-uri"); assert!(!result.is_empty()); } // --- parse_proxy_uri tests --- #[test] fn test_parse_proxy_uri_standard() { let (scheme, host, port, path) = parse_proxy_uri("http://10.86.8.223:8000/screenshot/").unwrap(); assert_eq!(scheme, "http"); assert_eq!(host, "10.86.8.223"); assert_eq!(port, 8000); assert_eq!(path, "/screenshot/"); } #[test] fn test_parse_proxy_uri_default_port() { let (scheme, host, port, path) = parse_proxy_uri("http://example.com/path").unwrap(); assert_eq!(scheme, "http"); assert_eq!(host, "example.com"); assert_eq!(port, 80); assert_eq!(path, "/path"); } #[test] fn test_parse_proxy_uri_https_default_port() { let (scheme, host, port, path) = parse_proxy_uri("https://api.example.com/v1/chat").unwrap(); assert_eq!(scheme, "https"); assert_eq!(host, "api.example.com"); assert_eq!(port, 443); assert_eq!(path, "/v1/chat"); } #[test] fn test_parse_proxy_uri_missing_path() { let (_, host, port, path) = parse_proxy_uri("http://10.0.0.1:9090").unwrap(); assert_eq!(host, "10.0.0.1"); assert_eq!(port, 9090); assert_eq!(path, "/"); } #[test] fn test_parse_proxy_uri_with_query() { let (_, _, _, path) = parse_proxy_uri("http://host:80/api?key=val&foo=bar").unwrap(); assert_eq!(path, "/api?key=val&foo=bar"); } #[test] fn test_parse_proxy_uri_with_query_and_no_path() { let (_, host, port, path) = parse_proxy_uri("http://host:8080?key=val&foo=bar").unwrap(); assert_eq!(host, "host"); assert_eq!(port, 8080); assert_eq!(path, "/?key=val&foo=bar"); } #[test] fn forward_telemetry_path_omits_queries_and_redacts_credential_syntax() { let target = "http://host:80/v1/openshell:resolve:env:API_TOKEN?token=real-secret"; let redacted = forward_telemetry_path(target); assert_eq!(redacted, "/v1/[CREDENTIAL]"); assert!(!redacted.contains("API_TOKEN")); assert!(!redacted.contains("real-secret")); let malformed = forward_telemetry_path( "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", ); assert_eq!(malformed, "/[INVALID_REQUEST_TARGET]"); assert!(!malformed.contains("API_TOKEN")); assert!(!malformed.contains("real-secret")); } #[test] fn unsupported_forward_scheme_event_omits_request_url() { use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; let event = build_forward_unsupported_scheme_ocsf_event("GET", "https", "api.example.com", 443); let json = event.to_json().unwrap(); assert_eq!(json["http_request"]["http_method"], "GET"); assert!(json["http_request"].get("url").is_none()); assert_eq!(json["http_response"]["code"], 400); validate_required_fields(&json, &load_class_schema("http_activity")); } #[test] fn credential_endpoint_mismatch_event_includes_method_and_response() { use openshell_ocsf::validation::{load_class_schema, validate_required_fields}; let event = build_credential_endpoint_mismatch_event("POST", "api.example.com", 443, "bound"); let json = event.to_json().unwrap(); assert_eq!(json["class_uid"], 4002); assert_eq!(json["activity_name"], "Post"); assert_eq!(json["http_request"]["http_method"], "POST"); assert!(json["http_request"].get("url").is_none()); assert_eq!(json["http_response"]["code"], 403); validate_required_fields(&json, &load_class_schema("http_activity")); } #[test] fn forward_credentials_capture_endpoint_resolver_and_revision_together() { use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; let state = ProviderCredentialState::from_bound_environment( 42, TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), TestHashMap::new(), TestHashMap::new(), TestHashMap::from([( "API_TOKEN".to_string(), StaticCredentialBinding { endpoints: vec![StaticCredentialEndpointBinding { host: "api.example.com".to_string(), port: 80, path: "/allowed/**".to_string(), }], credential_identity: "provider-a:API_TOKEN".to_string(), workload_credential_handle: String::new(), }, )]), Vec::new(), ) .expect("bound provider state"); let credentials = endpoint_credentials_for_request( Some(&state), None, "api.example.com", 80, "/allowed/v1", ); assert_eq!(credentials.revision, Some(42)); assert_eq!( credentials .resolver .expect("endpoint resolver") .resolve_placeholder("openshell:resolve:env:v42_API_TOKEN"), Some("secret") ); } #[test] fn forward_binding_uses_canonical_path_for_dot_segment_traversal() { use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; let state = ProviderCredentialState::from_bound_environment( 1, TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), TestHashMap::new(), TestHashMap::new(), TestHashMap::from([( "API_TOKEN".to_string(), StaticCredentialBinding { endpoints: vec![StaticCredentialEndpointBinding { host: "api.example.com".to_string(), port: 80, path: "/allowed/**".to_string(), }], credential_identity: "provider-a:API_TOKEN".to_string(), workload_credential_handle: String::new(), }, )]), Vec::new(), ) .expect("bound provider state"); let placeholder = "openshell:resolve:env:v1_API_TOKEN"; for raw_path in ["/allowed/../outside", "/allowed/%2e%2e/outside"] { let prepared = prepare_forward_target(raw_path, crate::l7::path::CanonicalizeOptions::default()) .expect("prepared target"); assert_eq!(prepared.canonical_path, "/outside"); let resolver = endpoint_secret_resolver( Some(&state), state.resolver(), "api.example.com", 80, &prepared.canonical_path, ) .expect("scoped resolver"); let error = resolver .rewrite_header_value(placeholder) .expect_err("canonical endpoint must deny traversal"); assert!(error.is_endpoint_mismatch()); } } #[test] fn live_forward_state_is_authoritative_after_revocation() { use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; let state = ProviderCredentialState::from_bound_environment( 1, TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), TestHashMap::new(), TestHashMap::new(), TestHashMap::from([( "API_TOKEN".to_string(), StaticCredentialBinding { endpoints: vec![StaticCredentialEndpointBinding { host: "api.example.com".to_string(), port: 80, path: "/**".to_string(), }], credential_identity: "provider-a:API_TOKEN".to_string(), workload_credential_handle: String::new(), }, )]), Vec::new(), ) .expect("bound provider state"); let connection_open_resolver = state.resolver(); state.revoke_static_provider_environment(2); assert!( endpoint_secret_resolver( Some(&state), connection_open_resolver, "api.example.com", 80, "/v1", ) .is_none(), "live revocation must not fall back to the connection-open resolver" ); } #[test] fn test_parse_proxy_uri_ipv6() { let (_, host, port, path) = parse_proxy_uri("http://[::1]:8080/test").unwrap(); assert_eq!(host, "::1"); assert_eq!(port, 8080); assert_eq!(path, "/test"); } #[test] fn test_parse_proxy_uri_ipv6_default_port() { let (_, host, port, path) = parse_proxy_uri("http://[fe80::1]/path").unwrap(); assert_eq!(host, "fe80::1"); assert_eq!(port, 80); assert_eq!(path, "/path"); } #[test] fn test_parse_proxy_uri_ipv6_with_query_and_no_path() { let (_, host, port, path) = parse_proxy_uri("http://[fe80::1]:8080?key=val").unwrap(); assert_eq!(host, "fe80::1"); assert_eq!(port, 8080); assert_eq!(path, "/?key=val"); } #[test] fn test_parse_proxy_uri_rejects_fragment() { assert!(parse_proxy_uri("http://example.com#secret").is_err()); assert!(parse_proxy_uri("http://[fe80::1]#secret").is_err()); } #[test] fn test_parse_proxy_uri_missing_scheme() { let result = parse_proxy_uri("example.com/path"); assert!(result.is_err()); } #[test] fn test_parse_proxy_uri_empty_host() { let result = parse_proxy_uri("http:///path"); assert!(result.is_err()); } // -- parse_target: CONNECT target parser regression tests -- #[test] fn test_parse_target_valid_baseline() { let (host, port) = parse_target("example.com:443").unwrap(); assert_eq!(host, "example.com"); assert_eq!(port, 443); } #[test] fn test_normalize_host_strips_single_trailing_dot() { assert_eq!(normalize_host("api.example.com."), "api.example.com"); } #[test] fn test_normalize_host_remains_the_same() { assert_eq!(normalize_host("api.example.com"), "api.example.com"); } #[test] fn test_parse_target_preserves_case() { let (host, port) = parse_target("EXAMPLE.COM:443").unwrap(); assert_eq!(host, "EXAMPLE.COM", "parse_target should preserve case"); assert_eq!(port, 443); } #[test] fn test_parse_target_accepts_empty_host() { let (host, port) = parse_target(":443").unwrap(); assert!(host.is_empty(), "empty host accepted without validation"); assert_eq!(port, 443); } #[test] fn test_parse_target_nul_byte_passes_through() { let (host, _) = parse_target("evil.com\0.safe.com:443").unwrap(); assert_eq!( host, "evil.com\0.safe.com", "NUL byte not stripped or rejected" ); } #[test] fn test_parse_target_control_char_passes_through() { let (host, _) = parse_target("evil\x01.com:443").unwrap(); assert!( host.contains('\x01'), "control characters pass through without validation" ); } #[test] fn test_parse_target_percent_encoded_dot_is_literal() { let (host, _) = parse_target("evil%2ecom:443").unwrap(); assert_eq!( host, "evil%2ecom", "percent-encoded dot not decoded — literal %2e in host" ); } #[test] fn test_parse_target_percent_encoded_nul_is_literal() { let (host, _) = parse_target("evil%00.safe.com:443").unwrap(); assert_eq!( host, "evil%00.safe.com", "percent-encoded NUL not decoded — literal %00 in host" ); } #[test] fn test_parse_target_rejects_missing_port_separator() { assert!( parse_target("hostonly").is_err(), "missing colon should be rejected" ); } #[test] fn test_parse_target_rejects_non_numeric_port() { assert!( parse_target("host:notaport").is_err(), "non-numeric port should be rejected" ); } #[test] fn test_parse_target_rejects_port_overflow() { assert!( parse_target("host:65536").is_err(), "port > 65535 should be rejected by u16 parse" ); } #[test] fn test_parse_target_accepts_port_zero() { let (_, port) = parse_target("host:0").unwrap(); assert_eq!(port, 0); } #[test] fn test_parse_target_accepts_port_max() { let (_, port) = parse_target("host:65535").unwrap(); assert_eq!(port, 65535); } #[test] fn test_parse_target_bracket_chars_pass_through() { let (host, _) = parse_target("a]b[c:443").unwrap(); assert_eq!(host, "a]b[c", "brackets pass through without validation"); } #[test] fn test_parse_target_oversized_hostname_accepted() { let long_host = "a".repeat(254); let target = format!("{long_host}:443"); let (host, _) = parse_target(&target).unwrap(); assert_eq!( host.len(), 254, "hostname exceeding DNS 253-char limit not rejected" ); } #[test] fn test_parse_target_backslash_passes_through() { let (host, _) = parse_target("evil.com\\..safe.com:443").unwrap(); assert!( host.contains('\\'), "backslash passes through without validation" ); } #[test] fn test_parse_target_slash_passes_through() { let (host, _) = parse_target("evil.com/../safe.com:443").unwrap(); assert!( host.contains('/'), "forward slash passes through without validation" ); } #[test] fn test_parse_target_extra_colon_fails_port_parse() { assert!( parse_target("host:80:extra").is_err(), "trailing content after port should fail u16 parse" ); } #[test] fn test_parse_target_ipv6_bracket_notation() { let (host, port) = parse_target("[::1]:443").unwrap(); assert_eq!(host, "::1", "brackets are stripped from the parsed host"); assert_eq!(port, 443); let (host, port) = parse_target("[2001:db8::1]:8443").unwrap(); assert_eq!(host, "2001:db8::1"); assert_eq!(port, 8443); } #[test] fn test_parse_target_rejects_malformed_ipv6_brackets() { for target in [ // Unclosed bracket. "[::1:443", // No port after the bracket. "[::1]", "[::1]443", // Empty or non-numeric port. "[::1]:", "[::1]:notaport", ] { assert!(parse_target(target).is_err(), "{target} should be rejected"); } } // -- parse_proxy_uri: hostname parser regression tests -- #[test] fn test_parse_proxy_uri_trailing_dot_host() { let (_, host, port, _) = parse_proxy_uri("http://api.example.com.:80/path").unwrap(); let host = normalize_host(&host); assert_eq!(host, "api.example.com"); assert_eq!(port, 80_u16); } #[test] fn test_parse_proxy_uri_nul_byte_in_host() { let (_, host, port, _) = parse_proxy_uri("http://evil.com\0.safe.com:80/path").unwrap(); assert_eq!( host, "evil.com\0.safe.com", "NUL byte not stripped or rejected in forward proxy URI" ); assert_eq!(port, 80); } #[test] fn test_parse_proxy_uri_control_char_in_host() { let (_, host, _, _) = parse_proxy_uri("http://evil\x01.com:80/").unwrap(); assert!( host.contains('\x01'), "control characters pass through without validation" ); } #[test] fn test_parse_proxy_uri_percent_encoded_dot_in_host() { let (_, host, _, _) = parse_proxy_uri("http://evil%2ecom:80/").unwrap(); assert_eq!( host, "evil%2ecom", "percent-encoded dot not decoded — literal %2e in host" ); } #[test] fn test_parse_proxy_uri_oversized_hostname() { let long_host = "a".repeat(254); let uri = format!("http://{long_host}:80/"); let (_, host, _, _) = parse_proxy_uri(&uri).unwrap(); assert_eq!( host.len(), 254, "hostname exceeding DNS 253-char limit not rejected" ); } // --- rewrite_forward_request tests --- #[tokio::test] async fn forward_proxy_injects_token_grant_before_rewriting_request() { let (ctx, fixture) = forward_token_grant_context(Ok("grant-token")); let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n".to_vec(); let with_token = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx) .await .expect("forward token grant should inject"); let rewritten = rewrite_forward_request( &with_token, with_token.len(), "/v1/projects", "api.example.test:8080", None, ) .expect("forward request should rewrite"); let rewritten = String::from_utf8_lossy(&rewritten); assert!(rewritten.starts_with("GET /v1/projects HTTP/1.1\r\n")); assert!(rewritten.contains("Authorization: Bearer grant-token\r\n")); assert!(!rewritten.contains("stale-token")); assert_eq!(authorization_header_count(&rewritten), 1); fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } #[tokio::test] async fn forward_proxy_injects_token_exchange_before_rewriting_request() { let (ctx, fixture) = forward_token_exchange_context(Ok("grant-token")); let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n".to_vec(); let with_token = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx) .await .expect("forward token exchange should inject"); let rewritten = rewrite_forward_request( &with_token, with_token.len(), "/v1/projects", "api.example.test:8080", None, ) .expect("forward request should rewrite"); let rewritten = String::from_utf8_lossy(&rewritten); assert!(rewritten.starts_with("GET /v1/projects HTTP/1.1\r\n")); assert!(rewritten.contains("Authorization: Bearer grant-token\r\n")); assert!(!rewritten.contains("stale-token")); assert_eq!(authorization_header_count(&rewritten), 1); fixture.assert_one_token_exchange_request( "api.example.test\t8080\t/v1/**\tprovider:access_token", ); } #[tokio::test] async fn forward_proxy_token_grant_failure_returns_error_before_rewrite() { let (ctx, fixture) = forward_token_grant_context(Err("oauth unavailable")); let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nConnection: close\r\n\r\n".to_vec(); let err = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx) .await .expect_err("forward token grant failure should stop request rewriting"); assert!(err.to_string().contains("Token grant failed")); assert!(err.to_string().contains("oauth unavailable")); fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } #[tokio::test] async fn forward_proxy_token_exchange_failure_returns_error_before_rewrite() { let (ctx, fixture) = forward_token_exchange_context(Err("oauth unavailable")); let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nConnection: close\r\n\r\n".to_vec(); let err = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx) .await .expect_err("forward token exchange failure should stop request rewriting"); assert!(err.to_string().contains("Token grant failed")); assert!(err.to_string().contains("oauth unavailable")); fixture.assert_one_token_exchange_request( "api.example.test\t8080\t/v1/**\tprovider:access_token", ); } #[test] fn test_rewrite_get_request() { let raw = b"GET http://10.0.0.1:8000/api HTTP/1.1\r\nHost: 10.0.0.1:8000\r\nAccept: */*\r\n\r\n"; let result = rewrite_forward_request(raw, raw.len(), "/api", "10.0.0.1:8000", None) .expect("should succeed"); let result_str = String::from_utf8_lossy(&result); assert!(result_str.starts_with("GET /api HTTP/1.1\r\n")); assert!(result_str.contains("Host: 10.0.0.1:8000")); assert!(result_str.contains("Connection: close")); assert!(result_str.contains("Via: 1.1 openshell-sandbox")); } #[test] fn canonical_forward_authority_formats_ports_and_ipv6() { for (uri, expected) in [ ("http://API.EXAMPLE.TEST/path", "api.example.test"), ("http://api.example.test:8080/path", "api.example.test:8080"), ("http://[2001:DB8::1]/path", "[2001:db8::1]"), ("http://[2001:DB8::1]:8080/path", "[2001:db8::1]:8080"), ] { let (_, host, port, _) = parse_proxy_uri(uri).expect("parse absolute target"); assert_eq!(canonical_forward_authority(&host, port), expected); } } #[test] fn forward_host_header_is_replaced_from_absolute_target() { let raw = b"POST http://allowed.example.test:8080/api HTTP/1.1\r\n\ Host: disallowed.example.test\r\n\ hOsT: second.example.test\r\n\ Content-Length: 4\r\n\r\nbody"; let authority = "allowed.example.test:8080"; let canonical = canonicalize_forward_host_header(raw, authority) .expect("canonicalize received Host fields"); let canonical = String::from_utf8(canonical).expect("canonical request is UTF-8"); let host_fields: Vec<_> = canonical .split("\r\n") .skip(1) .take_while(|line| !line.is_empty()) .filter(|line| { line.split_once(':') .is_some_and(|(name, _)| name.eq_ignore_ascii_case("host")) }) .collect(); assert_eq!(host_fields, ["Host: allowed.example.test:8080"]); assert!(!canonical.contains("disallowed.example.test")); assert!(!canonical.contains("second.example.test")); assert!(canonical.ends_with("\r\n\r\nbody")); let rewritten = rewrite_forward_request(raw, raw.len(), "/api", authority, None) .expect("final rewrite enforces canonical Host"); let rewritten = String::from_utf8(rewritten).expect("rewritten request is UTF-8"); assert_eq!( rewritten .split("\r\n") .filter(|line| { line.split_once(':') .is_some_and(|(name, _)| name.eq_ignore_ascii_case("host")) }) .collect::>(), ["Host: allowed.example.test:8080"] ); assert!(!rewritten.contains("disallowed.example.test")); assert!(!rewritten.contains("second.example.test")); } #[test] fn forward_host_header_is_generated_when_missing() { let raw = b"GET http://allowed.example.test/api HTTP/1.1\r\nAccept: */*\r\n\r\n"; let canonical = canonicalize_forward_host_header(raw, "allowed.example.test") .expect("generate missing Host field"); let canonical = String::from_utf8(canonical).expect("canonical request is UTF-8"); assert!(canonical.starts_with( "GET http://allowed.example.test/api HTTP/1.1\r\nHost: allowed.example.test\r\n" )); } #[tokio::test] async fn middleware_selected_forward_keeps_canonical_host_on_the_wire() { let authority = "allowed.example.test"; let raw = b"GET http://allowed.example.test/api HTTP/1.1\r\n\ Host: disallowed.example.test\r\n\ HOST: second.example.test\r\n\r\n"; let raw = canonicalize_forward_host_header(raw, authority) .expect("canonicalize Host before middleware"); let request = crate::l7::rest::request_from_buffered_http("GET", "/api", "/api", raw) .expect("build middleware request"); let ctx = crate::l7::relay::L7EvalContext { host: authority.into(), port: 80, request_default_port: Some(80), policy_name: "test".into(), binary_path: "/usr/bin/node".into(), ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, ..Default::default() }; let runner = openshell_supervisor_middleware::ChainRunner::new( openshell_supervisor_middleware_builtins::services() .into_iter() .next() .expect("built-in middleware service"), ); let guard = forward_test_guard(); let chain = vec![openshell_supervisor_middleware::ChainEntry { name: "redactor".into(), implementation: openshell_supervisor_middleware_builtins::BUILTIN_REGEX.into(), order: 0, config: prost_types::Struct::default(), on_error: openshell_supervisor_middleware::OnError::FailClosed, }]; let exchange = crate::l7::middleware::HttpMiddlewareExchange::new( "test-request-id".into(), chain, runner, guard, ); let pipeline = ForwardMiddlewarePipeline { ctx: &ctx, scheme: "http", exchange: &exchange, l7_reevaluation: None, }; let (_app, mut client) = tokio::io::duplex(8192); let allowed = pipeline .apply(request, &mut client) .await .expect("middleware pipeline"); let crate::l7::middleware::MiddlewareApplyResult::Allowed(request) = allowed else { panic!("middleware-selected request should be allowed"); }; let rewritten = rewrite_forward_request( &request.raw_header, request.raw_header.len(), "/api", authority, None, ) .expect("rewrite middleware-selected request"); let rewritten = String::from_utf8(rewritten).expect("rewritten request is UTF-8"); assert_eq!( rewritten .split("\r\n") .filter(|line| { line.split_once(':') .is_some_and(|(name, _)| name.eq_ignore_ascii_case("host")) }) .collect::>(), ["Host: allowed.example.test"] ); assert!(!rewritten.contains("disallowed.example.test")); assert!(!rewritten.contains("second.example.test")); } #[test] fn test_rewrite_strips_proxy_headers() { let raw = b"GET http://host/p HTTP/1.1\r\nHost: host\r\nProxy-Authorization: Basic abc\r\nProxy-Connection: keep-alive\r\nAccept: */*\r\n\r\n"; let result = rewrite_forward_request(raw, raw.len(), "/p", "host", None).expect("should succeed"); let result_str = String::from_utf8_lossy(&result); assert!( !result_str .to_ascii_lowercase() .contains("proxy-authorization") ); assert!(!result_str.to_ascii_lowercase().contains("proxy-connection")); assert!(result_str.contains("Accept: */*")); } #[test] fn test_rewrite_replaces_connection_header() { let raw = b"GET http://host/p HTTP/1.1\r\nHost: host\r\nConnection: keep-alive\r\n\r\n"; let result = rewrite_forward_request(raw, raw.len(), "/p", "host", None).expect("should succeed"); let result_str = String::from_utf8_lossy(&result); assert!(result_str.contains("Connection: close")); assert!(!result_str.contains("keep-alive")); } #[test] fn test_rewrite_strips_connection_nominated_headers() { let raw = b"GET http://host/p HTTP/1.1\r\nHost: host\r\nX-Guard: hidden\r\nConnection: keep-alive, x-guard\r\nKeep-Alive: timeout=5\r\nX-Visible: yes\r\n\r\n"; let result = rewrite_forward_request(raw, raw.len(), "/p", "host", None).expect("should succeed"); let result_str = String::from_utf8_lossy(&result); let lower = result_str.to_ascii_lowercase(); assert!(!lower.contains("x-guard:")); assert!(!lower.contains("keep-alive:")); assert!(result_str.contains("Connection: close\r\n")); assert!(result_str.contains("X-Visible: yes\r\n")); } #[test] fn test_rewrite_preserves_body_overflow() { let raw = b"POST http://host/api HTTP/1.1\r\nHost: host\r\nContent-Length: 13\r\n\r\n{\"key\":\"val\"}"; let result = rewrite_forward_request(raw, raw.len(), "/api", "host", None).expect("should succeed"); let result_str = String::from_utf8_lossy(&result); assert!(result_str.contains("{\"key\":\"val\"}")); assert!(result_str.contains("POST /api HTTP/1.1")); } #[test] fn test_rewrite_preserves_existing_via() { let raw = b"GET http://host/p HTTP/1.1\r\nHost: host\r\nVia: 1.0 upstream\r\n\r\n"; let result = rewrite_forward_request(raw, raw.len(), "/p", "host", None).expect("should succeed"); let result_str = String::from_utf8_lossy(&result); assert!(result_str.contains("Via: 1.0 upstream")); // Should not add a second Via header assert!(!result_str.contains("Via: 1.1 openshell-sandbox")); } #[test] fn test_rewrite_forward_request_uses_canonical_path_on_the_wire() { // Regression: the forward-proxy caller must canonicalize first and // then pass the canonical form to rewrite_forward_request so that // OPA's policy evaluation and the bytes dispatched to the upstream // agree. Prior to this guarantee, OPA saw the canonical form while // the upstream re-normalized the raw path independently, re-opening // the parser-differential this PR closes. let raw = b"GET http://host/public/../secret HTTP/1.1\r\nHost: host\r\n\r\n"; let (canon, _) = crate::l7::path::canonicalize_request_target( "/public/../secret", &crate::l7::path::CanonicalizeOptions::default(), ) .expect("canonicalization should succeed for the attack payload"); assert_eq!(canon.path, "/secret"); let rewritten = rewrite_forward_request(raw, raw.len(), &canon.path, "host", None) .expect("rewrite_forward_request should succeed"); let rewritten_str = String::from_utf8_lossy(&rewritten); assert!( rewritten_str.starts_with("GET /secret HTTP/1.1\r\n"), "outbound request line must use canonical path, got: {rewritten_str:?}" ); assert!( !rewritten_str.contains(".."), "outbound bytes must not leak the pre-canonical form, got: {rewritten_str:?}" ); } #[test] fn test_rewrite_forward_request_preserves_canonical_query_on_the_wire() { let raw = b"GET http://host/public/../graphql?query=query+Viewer+%7B+viewer+%7B+login+%7D+%7D HTTP/1.1\r\nHost: host\r\n\r\n"; let (canon, raw_query) = crate::l7::path::canonicalize_request_target( "/public/../graphql?query=query+Viewer+%7B+viewer+%7B+login+%7D+%7D", &crate::l7::path::CanonicalizeOptions::default(), ) .expect("canonicalization should preserve query separately"); let upstream_target = match raw_query.as_deref() { Some(raw_query) if !raw_query.is_empty() => format!("{}?{raw_query}", canon.path), _ => canon.path, }; let rewritten = rewrite_forward_request(raw, raw.len(), &upstream_target, "host", None) .expect("rewrite_forward_request should succeed"); let rewritten_str = String::from_utf8_lossy(&rewritten); assert!( rewritten_str.starts_with( "GET /graphql?query=query+Viewer+%7B+viewer+%7B+login+%7D+%7D HTTP/1.1\r\n" ), "outbound request line must preserve canonical query, got: {rewritten_str:?}" ); } #[test] fn test_rewrite_resolves_placeholder_auth_headers() { let (_, resolver) = SecretResolver::from_provider_env( [("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string())] .into_iter() .collect(), ); let raw = b"GET http://host/p HTTP/1.1\r\nHost: host\r\nAuthorization: Bearer openshell:resolve:env:ANTHROPIC_API_KEY\r\n\r\n"; let result = rewrite_forward_request(raw, raw.len(), "/p", "host", resolver.as_ref()) .expect("should succeed"); let result_str = String::from_utf8_lossy(&result); assert!(result_str.contains("Authorization: Bearer sk-test")); assert!(!result_str.contains("openshell:resolve:env:ANTHROPIC_API_KEY")); } #[tokio::test] async fn forward_initial_body_is_classified_separately_from_auth_headers() { use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; let state = ProviderCredentialState::from_bound_environment( 42, TestHashMap::from([("API_KEY".into(), "private-test-secret".into())]), TestHashMap::new(), TestHashMap::new(), TestHashMap::from([( "API_KEY".into(), StaticCredentialBinding { credential_identity: "provider".into(), workload_credential_handle: String::new(), endpoints: vec![StaticCredentialEndpointBinding { host: "api.example.com".into(), port: 80, path: "/**".into(), }], }, )]), vec![], ) .unwrap(); let issued = state.snapshot().child_env["API_KEY"].clone(); let (resolver, classifier, _) = state.resolver_and_body_classifier_for_endpoint("api.example.com", 80, "/chat"); for token in [ "openshell:resolve:env:KEY".to_owned(), issued.clone(), issued.replace(':', "%3A"), format!( "sk-OPENSHELL-RESOLVE-ENV-{}", issued.strip_prefix("openshell:resolve:env:").unwrap() ), ] { let body = format!(r#"{{"messages":[{{"role":"tool","content":"{token}"}}]}}"#); let raw = format!( "POST http://api.example.com/chat HTTP/1.1\r\nHost: api.example.com\r\nAuthorization: Bearer {issued}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len() ); for _ in 0..2 { let forwarded = relay_forward_request_and_capture_classified( "POST", "/chat", raw.as_bytes(), resolver.as_deref(), false, classifier.as_deref(), ) .await .unwrap(); let (headers, actual_body) = forwarded.split_once("\r\n\r\n").unwrap(); assert!(headers.contains("Authorization: Bearer private-test-secret")); assert_eq!(actual_body, body); assert!(!actual_body.contains("private-test-secret")); } } // Header placeholders remain fail-closed even when the body can be literal. let raw = b"POST http://api.example.com/chat HTTP/1.1\r\nHost: api.example.com\r\nAuthorization: Bearer openshell:resolve:env:MISSING\r\nContent-Length: 0\r\n\r\n"; assert!( rewrite_forward_request( raw, raw.len(), "/chat", "api.example.com", resolver.as_deref() ) .is_err() ); } #[tokio::test] async fn forward_relay_rewrites_urlencoded_body_alias_from_initial_read() { let (_, resolver) = SecretResolver::from_provider_env( [("API_TOKEN".to_string(), "provider-real-token".to_string())] .into_iter() .collect(), ); let resolver = resolver.expect("resolver"); let alias = "provider-OPENSHELL-RESOLVE-ENV-API_TOKEN"; let body = format!("token={alias}&channel=C123"); let raw = format!( "POST http://api.example.com/api/messages HTTP/1.1\r\n\ Host: api.example.com\r\n\ Authorization: Bearer {alias}\r\n\ Content-Type: application/x-www-form-urlencoded\r\n\ Content-Length: {}\r\n\r\n{}", body.len(), body ); let forwarded = relay_forward_request_and_capture( "POST", "/api/messages", raw.as_bytes(), Some(&resolver), true, ) .await .expect("forward relay should rewrite credentials"); let expected_body = "token=provider-real-token&channel=C123"; assert!(forwarded.starts_with("POST /api/messages HTTP/1.1\r\n")); assert!(forwarded.contains("Authorization: Bearer provider-real-token\r\n")); assert!(forwarded.contains(&format!("Content-Length: {}\r\n", expected_body.len()))); assert!(forwarded.ends_with(expected_body)); assert!(!forwarded.contains("OPENSHELL-RESOLVE-ENV")); } #[tokio::test] async fn forward_relay_rewrites_urlencoded_canonical_body_from_initial_read() { let (_, resolver) = SecretResolver::from_provider_env( [("API_TOKEN".to_string(), "provider-real-token".to_string())] .into_iter() .collect(), ); let resolver = resolver.expect("resolver"); let alias = "provider-OPENSHELL-RESOLVE-ENV-API_TOKEN"; let body = "token=openshell%3Aresolve%3Aenv%3AAPI_TOKEN&channel=C123"; let raw = format!( "POST http://api.example.com/api/messages HTTP/1.1\r\n\ Host: api.example.com\r\n\ Authorization: Bearer {alias}\r\n\ Content-Type: application/x-www-form-urlencoded\r\n\ Content-Length: {}\r\n\r\n{}", body.len(), body ); let forwarded = relay_forward_request_and_capture( "POST", "/api/messages", raw.as_bytes(), Some(&resolver), true, ) .await .expect("forward relay should rewrite credentials"); let expected_body = "token=provider-real-token&channel=C123"; assert!(forwarded.contains("Authorization: Bearer provider-real-token\r\n")); assert!(forwarded.contains(&format!("Content-Length: {}\r\n", expected_body.len()))); assert!(forwarded.ends_with(expected_body)); assert!(!forwarded.contains("openshell%3Aresolve%3Aenv%3AAPI_TOKEN")); assert!(!forwarded.contains("openshell:resolve:env:API_TOKEN")); } #[tokio::test] async fn forward_relay_body_endpoint_mismatch_is_typed_before_upstream_write() { use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; let state = ProviderCredentialState::from_bound_environment( 1, TestHashMap::from([("API_TOKEN".to_string(), "provider-real-token".to_string())]), TestHashMap::new(), TestHashMap::new(), TestHashMap::from([( "API_TOKEN".to_string(), StaticCredentialBinding { endpoints: vec![StaticCredentialEndpointBinding { host: "allowed.example.com".to_string(), port: 80, path: "/allowed/**".to_string(), }], credential_identity: "provider-a:API_TOKEN".to_string(), workload_credential_handle: String::new(), }, )]), Vec::new(), ) .expect("bound provider state"); let resolver = state .resolver_for_endpoint("api.example.com", 80, "/api/messages") .expect("endpoint-scoped resolver"); let body = "token=openshell:resolve:env:v1_API_TOKEN"; let raw = format!( "POST http://api.example.com/api/messages HTTP/1.1\r\n\ Host: api.example.com\r\n\ Content-Type: application/x-www-form-urlencoded\r\n\ Content-Length: {}\r\n\r\n{}", body.len(), body ); let guard = forward_test_guard(); let rewritten = rewrite_forward_request( raw.as_bytes(), raw.len(), "/api/messages", "api.example.com", Some(&resolver), ) .expect("header rewrite should defer body overflow to body rewriter"); let (mut proxy_to_upstream, mut upstream_side) = tokio::io::duplex(8192); let (mut _app_side, mut proxy_to_client) = tokio::io::duplex(8192); let err = relay_rewritten_forward_request( "POST", "/api/messages", rewritten, &mut proxy_to_client, &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, credential_generation: None, body_classifier: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: Some(&resolver), request_body_credential_rewrite: true, deny_uninspected_credentials: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: "", signing_region: "", host: "", port: 0, response_middleware: None, endpoint_observer: None, }, ) .await .expect_err("unresolved body placeholder should fail closed"); let credential_error = err .downcast_ref::() .expect("body mismatch must retain its typed error"); assert!(credential_error.is_endpoint_mismatch()); assert!(!err.to_string().contains("provider-real-token")); assert!(!err.to_string().contains("API_TOKEN")); drop(proxy_to_upstream); let mut forwarded = Vec::new(); upstream_side.read_to_end(&mut forwarded).await.unwrap(); assert!( forwarded.is_empty(), "failed forward body rewrite must not reach upstream" ); } #[tokio::test] async fn forward_relay_sigv4_endpoint_mismatch_is_typed_before_upstream_write() { use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; let values = TestHashMap::from([ ("AWS_ACCESS_KEY_ID".to_string(), "access".to_string()), ("AWS_SECRET_ACCESS_KEY".to_string(), "secret".to_string()), ("AWS_SESSION_TOKEN".to_string(), "session".to_string()), ]); let bindings = values .keys() .map(|key| { ( key.clone(), StaticCredentialBinding { endpoints: vec![StaticCredentialEndpointBinding { host: "allowed.example.com".to_string(), port: 80, path: "/allowed/**".to_string(), }], credential_identity: format!("provider-a:{key}"), workload_credential_handle: String::new(), }, ) }) .collect(); let state = ProviderCredentialState::from_bound_environment( 1, values, TestHashMap::new(), TestHashMap::new(), bindings, Vec::new(), ) .expect("bound provider state"); let resolver = state .resolver_for_endpoint("api.example.com", 80, "/api") .expect("endpoint-scoped resolver"); let guard = forward_test_guard(); let rewritten = b"GET /api HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: 0\r\n\r\n".to_vec(); let (mut proxy_to_upstream, mut upstream_side) = tokio::io::duplex(8192); let (mut _app_side, mut proxy_to_client) = tokio::io::duplex(8192); let err = relay_rewritten_forward_request( "GET", "/api", rewritten, &mut proxy_to_client, &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, credential_generation: None, body_classifier: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: Some(&resolver), request_body_credential_rewrite: false, deny_uninspected_credentials: false, credential_signing: crate::l7::CredentialSigning::SigV4NoBody, signing_service: "execute-api", signing_region: "us-west-2", host: "api.example.com", port: 80, response_middleware: None, endpoint_observer: None, }, ) .await .expect_err("SigV4 endpoint mismatch should fail closed"); let credential_error = err .downcast_ref::() .expect("SigV4 mismatch must retain its typed error"); assert!(credential_error.is_endpoint_mismatch()); drop(proxy_to_upstream); let mut forwarded = Vec::new(); upstream_side.read_to_end(&mut forwarded).await.unwrap(); assert!( forwarded.is_empty(), "failed SigV4 credential lookup must not reach upstream" ); } #[test] fn test_forward_rewrite_preserves_websocket_upgrade_connection_header() { let raw = "GET http://gateway.example.test/ws HTTP/1.1\r\n\ Host: gateway.example.test\r\n\ Upgrade: h2c\r\n\ Upgrade: h2c, websocket\r\n\ Connection: keep-alive, Upgrade\r\n\ Keep-Alive: timeout=5\r\n\ X-Guard: hidden\r\n\ Connection: x-guard\r\n\ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover\r\n\ Sec-WebSocket-Version: 13\r\n\r\n"; let result = rewrite_forward_request( raw.as_bytes(), raw.len(), "/ws", "gateway.example.test", None, ) .expect("websocket forward rewrite should succeed"); let result_str = String::from_utf8_lossy(&result); assert!(result_str.starts_with("GET /ws HTTP/1.1\r\n")); assert_eq!(result_str.matches("Connection: Upgrade\r\n").count(), 1); assert_eq!(result_str.matches("Upgrade: websocket\r\n").count(), 1); assert!(!result_str.to_ascii_lowercase().contains("upgrade: h2c")); assert!(!result_str.contains("keep-alive")); assert!(!result_str.to_ascii_lowercase().contains("x-guard:")); assert!( !result_str.contains("Connection: close\r\n"), "websocket forward proxy must not strip the upgrade token" ); } #[tokio::test] async fn test_forward_relay_guard_blocks_stale_generation_before_upstream_write() { let policy = include_str!("../data/sandbox-policy.rego"); let policy_data = "network_policies: {}\n"; let engine = OpaEngine::from_strings(policy, policy_data).unwrap(); let guard = engine .generation_guard(engine.current_generation()) .unwrap(); engine.reload(policy, policy_data).unwrap(); let raw = b"GET http://host/api HTTP/1.1\r\nHost: host\r\n\r\n"; let rewritten = rewrite_forward_request(raw, raw.len(), "/api", "host", None) .expect("rewrite should succeed"); let (mut proxy_to_upstream, mut upstream_side) = tokio::io::duplex(8192); let (mut _app_side, mut proxy_to_client) = tokio::io::duplex(8192); let result = relay_rewritten_forward_request( "GET", "/api", rewritten, &mut proxy_to_client, &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, credential_generation: None, body_classifier: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: "", signing_region: "", host: "", port: 0, response_middleware: None, endpoint_observer: None, }, ) .await; assert!( result.is_err(), "stale generation must stop forward relay before upstream write" ); drop(proxy_to_upstream); let mut forwarded = Vec::new(); upstream_side.read_to_end(&mut forwarded).await.unwrap(); assert!( forwarded.is_empty(), "stale forward request bytes must not reach upstream" ); } #[tokio::test] async fn test_forward_relay_rejects_cl_te_smuggling_before_upstream_write() { let policy = include_str!("../data/sandbox-policy.rego"); let policy_data = "network_policies: {}\n"; let engine = OpaEngine::from_strings(policy, policy_data).unwrap(); let guard = engine .generation_guard(engine.current_generation()) .unwrap(); let raw = b"POST http://host/api HTTP/1.1\r\nHost: host\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n"; let rewritten = rewrite_forward_request(raw, raw.len(), "/api", "host", None) .expect("rewrite should succeed"); let (mut proxy_to_upstream, mut upstream_side) = tokio::io::duplex(8192); let (mut _app_side, mut proxy_to_client) = tokio::io::duplex(8192); let result = relay_rewritten_forward_request( "POST", "/api", rewritten, &mut proxy_to_client, &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, credential_generation: None, body_classifier: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, credential_signing: crate::l7::CredentialSigning::None, signing_service: "", signing_region: "", host: "", port: 0, response_middleware: None, endpoint_observer: None, }, ) .await; assert!(result.is_err(), "forward relay must reject CL/TE ambiguity"); drop(proxy_to_upstream); let mut forwarded = Vec::new(); upstream_side.read_to_end(&mut forwarded).await.unwrap(); assert!( forwarded.is_empty(), "smuggled forward request bytes must not reach upstream" ); } // --- Forward proxy SSRF defence tests --- // // The forward proxy handler uses the same SSRF logic as the CONNECT path: // - No allowed_ips: resolve_and_reject_internal blocks private IPs, allows public. // - With allowed_ips: resolve_and_check_allowed_ips validates against allowlist. // // These tests document that contract for the forward proxy path specifically. #[tokio::test] async fn test_forward_public_ip_allowed_without_allowed_ips() { // Public IPs (e.g. dns.google -> 8.8.8.8) should pass through // resolve_and_reject_internal without needing allowed_ips. let result = resolve_and_reject_internal("dns.google", 80, 0).await; assert!( result.is_ok(), "Public IP should be allowed without allowed_ips: {result:?}" ); let addrs = result.unwrap(); assert!(!addrs.is_empty(), "Should resolve to at least one address"); // All resolved addresses should be public. for addr in &addrs { assert!( !is_internal_ip(addr.ip()), "dns.google should resolve to public IPs, got {}", addr.ip() ); } } #[tokio::test] async fn test_forward_private_ip_rejected_without_allowed_ips() { // Private IP literals should be rejected by resolve_and_reject_internal. let result = resolve_and_reject_internal("10.0.0.1", 80, 0).await; assert!( result.is_err(), "Private IP should be rejected without allowed_ips" ); let err = result.unwrap_err(); assert!( err.contains("internal address"), "expected 'internal address' in error: {err}" ); } #[tokio::test] async fn test_forward_private_ip_accepted_with_allowed_ips() { // Private IP with matching allowed_ips should pass through. let nets = parse_allowed_ips(&["10.0.0.0/8".to_string()]).unwrap(); let result = resolve_and_check_allowed_ips("10.0.0.1", 80, &nets, 0).await; assert!( result.is_ok(), "Private IP with matching allowed_ips should be accepted: {result:?}" ); } #[tokio::test] async fn test_forward_private_ip_rejected_with_wrong_allowed_ips() { // Private IP not in allowed_ips should be rejected. let nets = parse_allowed_ips(&["192.168.0.0/16".to_string()]).unwrap(); let result = resolve_and_check_allowed_ips("10.0.0.1", 80, &nets, 0).await; assert!( result.is_err(), "Private IP not in allowed_ips should be rejected" ); let err = result.unwrap_err(); assert!( err.contains("not in allowed_ips"), "expected 'not in allowed_ips' in error: {err}" ); } #[tokio::test] async fn test_forward_loopback_always_blocked_even_with_allowed_ips() { // Loopback addresses are always blocked, even if in allowed_ips. // Construct nets directly (parse_allowed_ips now rejects always-blocked). let nets = vec!["127.0.0.0/8".parse::().unwrap()]; let result = resolve_and_check_allowed_ips("127.0.0.1", 80, &nets, 0).await; assert!(result.is_err(), "Loopback should be always blocked"); let err = result.unwrap_err(); assert!( err.contains("always-blocked"), "expected 'always-blocked' in error: {err}" ); } #[tokio::test] async fn test_forward_link_local_always_blocked_even_with_allowed_ips() { // Link-local / cloud metadata addresses are always blocked. // Construct nets directly (parse_allowed_ips now rejects always-blocked). let nets = vec!["169.254.0.0/16".parse::().unwrap()]; let result = resolve_and_check_allowed_ips("169.254.169.254", 80, &nets, 0).await; assert!(result.is_err(), "Link-local should be always blocked"); let err = result.unwrap_err(); assert!( err.contains("always-blocked"), "expected 'always-blocked' in error: {err}" ); } // -- SSRF: malformed hostname resolution regression tests -- #[tokio::test] async fn test_resolve_reject_internal_fails_closed_on_nul_hostname() { let result = resolve_and_reject_internal("evil.com\0.safe.com", 443, 0).await; assert!( result.is_err(), "NUL-containing hostname should fail DNS resolution (fail closed)" ); } #[tokio::test] async fn test_resolve_allowed_ips_fails_closed_on_nul_hostname() { let nets = parse_allowed_ips(&["0.0.0.0/0".to_string()]) .unwrap_or_else(|_| vec!["0.0.0.0/0".parse::().unwrap()]); let result = resolve_and_check_allowed_ips("evil.com\0.safe.com", 443, &nets, 0).await; assert!( result.is_err(), "NUL-containing hostname should fail DNS resolution (fail closed)" ); } // -- implicit_allowed_ips_for_ip_host -- #[test] fn test_implicit_allowed_ips_returns_ip_for_ipv4_literal() { let result = implicit_allowed_ips_for_ip_host("192.168.1.100"); assert_eq!(result, vec!["192.168.1.100"]); } #[test] fn test_implicit_allowed_ips_skips_ipv6_loopback() { // ::1 is always-blocked, so implicit allowed_ips should be empty. let result = implicit_allowed_ips_for_ip_host("::1"); assert!(result.is_empty()); } #[test] fn test_implicit_allowed_ips_returns_empty_for_hostname() { let result = implicit_allowed_ips_for_ip_host("api.github.com"); assert!(result.is_empty()); } #[test] fn test_implicit_allowed_ips_returns_empty_for_wildcard() { let result = implicit_allowed_ips_for_ip_host("*.example.com"); assert!(result.is_empty()); } // -- build_json_error_response -- #[test] fn test_json_error_response_403() { let resp = build_json_error_response( 403, "Forbidden", "policy_denied", "CONNECT api.example.com:443 not permitted by policy", ); let resp_str = String::from_utf8(resp).unwrap(); assert!(resp_str.starts_with("HTTP/1.1 403 Forbidden\r\n")); assert!(resp_str.contains("Content-Type: application/json\r\n")); assert!(resp_str.contains("Connection: close\r\n")); // Extract body after \r\n\r\n let body_start = resp_str.find("\r\n\r\n").unwrap() + 4; let body: serde_json::Value = serde_json::from_str(&resp_str[body_start..]).unwrap(); assert_eq!(body["error"], "policy_denied"); assert_eq!( body["detail"], "CONNECT api.example.com:443 not permitted by policy" ); } #[test] fn test_json_error_response_502() { let resp = build_json_error_response( 502, "Bad Gateway", "upstream_unreachable", "connection to api.example.com:443 failed", ); let resp_str = String::from_utf8(resp).unwrap(); assert!(resp_str.starts_with("HTTP/1.1 502 Bad Gateway\r\n")); let body_start = resp_str.find("\r\n\r\n").unwrap() + 4; let body: serde_json::Value = serde_json::from_str(&resp_str[body_start..]).unwrap(); assert_eq!(body["error"], "upstream_unreachable"); assert_eq!(body["detail"], "connection to api.example.com:443 failed"); } #[test] fn middleware_deny_response_uses_policy_config_identity() { let resp = build_middleware_deny_response( "api-policy", &openshell_supervisor_middleware::MiddlewareDenial { config_name: "prototype-content-guard".into(), reason_code: Some("content_match".into()), }, ); let resp_str = String::from_utf8(resp).unwrap(); let body_start = resp_str.find("\r\n\r\n").unwrap() + 4; let body: serde_json::Value = serde_json::from_str(&resp_str[body_start..]).unwrap(); assert_eq!(body["error"], "middleware_denied"); assert_eq!(body["detail"], "Request rejected by configured middleware"); assert_eq!(body["middleware"], "prototype-content-guard"); assert_eq!(body["reason_code"], "content_match"); assert_eq!(body["policy"], "api-policy"); assert!(body.get("rule_missing").is_none()); assert!(body.get("next_steps").is_none()); } /// Locks the fail-closed response the CONNECT handler sends when TLS is /// detected but no termination state exists (ephemeral CA setup failed). /// The proxy must refuse the connection with a 503 instead of raw-tunneling /// the TLS stream, which would bypass credential rewrite and leak /// placeholders verbatim. #[test] fn test_json_error_response_503_tls_termination_unavailable() { let detail = TLS_TERMINATION_UNAVAILABLE_DETAIL; let resp = build_json_error_response( 503, "Service Unavailable", "tls_termination_unavailable", detail, ); let resp_str = String::from_utf8(resp).unwrap(); assert!(resp_str.starts_with("HTTP/1.1 503 Service Unavailable\r\n")); assert!(resp_str.contains("Connection: close\r\n")); let body_start = resp_str.find("\r\n\r\n").unwrap() + 4; let body: serde_json::Value = serde_json::from_str(&resp_str[body_start..]).unwrap(); assert_eq!(body["error"], "tls_termination_unavailable"); assert_eq!(body["detail"], detail); } /// Connection-level regression for the pre-200 fail-closed gate. A real /// loopback client opens a connection and the proxy-side gate runs with no /// TLS termination state for a terminating (non-`tls: skip`) route. The /// FIRST bytes the client reads back must be a real `HTTP/1.1 503`, not the /// `HTTP/1.1 200 Connection Established` that would establish the tunnel and /// bury the refusal inside it as a TLS protocol error (PR #2162 gator flaw). #[tokio::test] async fn connect_without_tls_termination_refused_with_503_before_200() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let mut client = TcpStream::connect(addr).await.unwrap(); let (mut server, _) = listener.accept().await.unwrap(); // Terminating route (tls: skip = false) with no termination state. let refused = refuse_connect_when_tls_unavailable(&mut server, false, false) .await .expect("gate write should succeed"); assert!( refused, "gate must refuse when TLS termination is unavailable" ); let mut buf = vec![0u8; 512]; let n = client.read(&mut buf).await.unwrap(); let response = String::from_utf8_lossy(&buf[..n]); assert!( response.starts_with("HTTP/1.1 503 Service Unavailable\r\n"), "client must read a 503 as the first bytes, not a 200; got: {response}" ); assert!( !response.contains("200 Connection Established"), "no tunnel must be established before the refusal; got: {response}" ); let body_start = response.find("\r\n\r\n").unwrap() + 4; let body: serde_json::Value = serde_json::from_str(&response[body_start..]).unwrap(); assert_eq!(body["error"], "tls_termination_unavailable"); assert_eq!(body["detail"], TLS_TERMINATION_UNAVAILABLE_DETAIL); } /// The pre-200 gate must NOT touch the socket when the route can proceed: /// either TLS termination state is present, or the route is `tls: skip` /// (raw tunnel, no termination or credential injection). In both cases the /// gate returns `false` and writes nothing, letting the caller send its /// own `200 Connection Established`. #[tokio::test] async fn connect_with_tls_termination_or_skip_proceeds_without_writing() { for (tls_present, tls_skip) in [(true, false), (false, true), (true, true)] { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let mut client = TcpStream::connect(addr).await.unwrap(); let (mut server, _) = listener.accept().await.unwrap(); let refused = refuse_connect_when_tls_unavailable(&mut server, tls_present, tls_skip) .await .expect("gate should succeed"); assert!( !refused, "gate must proceed (tls_present={tls_present}, tls_skip={tls_skip})" ); // Nothing was written; after dropping the server the client sees a // clean EOF (0 bytes) rather than any buffered response. drop(server); let mut buf = vec![0u8; 16]; let n = client.read(&mut buf).await.unwrap(); assert_eq!( n, 0, "gate must not write to the socket when proceeding \ (tls_present={tls_present}, tls_skip={tls_skip})" ); } } /// Drives a real `CONNECT` through the full `handle_tcp_connection` entry /// point and returns `(completed, client_response_bytes, denial_stages)`. /// `completed` is `false` when the handler was still running at `budget` /// (e.g. a `tls: skip` route that proceeded past the fail-closed gate and /// blocked on the unroutable upstream connect). `denial_stages` collects the /// `denial_stage` of every `DenialEvent` the handler emitted — empty means /// the connection was allowed and not blocked/refused at any stage. /// /// The client is an in-process `TcpStream` (no child process), so the client /// socket is owned solely by this test process. Identity resolution finds /// that owner in the descendant scan (which includes the entrypoint PID /// itself), binds to `current_exe()`, and never falls through to the /// whole-`/proc` scan — the environment-sensitive path that made a forked /// child flaky under a busy CI `/proc`. Callers gate on Linux; /// `authorize_egress_intent` denies unconditionally without `/proc`. async fn drive_connect_through_handler( endpoint_yaml: &str, connect_target: &str, budget: std::time::Duration, ) -> (bool, Vec, Vec) { const POLICY_REGO: &str = include_str!("../data/sandbox-policy.rego"); // Allow the current test binary — the in-process client's // `/proc//exe` — to reach the endpoint. Matching is by path. let exe = std::env::current_exe().expect("current_exe"); let data = format!( r#"network_policies: test_allow: name: test_allow endpoints: {endpoint_yaml} binaries: - {{ path: "{exe}" }} "#, exe = exe.display(), ); let engine = Arc::new(OpaEngine::from_strings(POLICY_REGO, &data).expect("load policy")); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let proxy_port = listener.local_addr().unwrap().port(); // In-process client: connect, send the CONNECT request, read the reply to // EOF. The proxy closes the socket after writing a 503/403; a proceeding // `tls: skip` route stalls on the upstream connect until the handler is // dropped at `budget`, which closes the socket and ends the read. let target = connect_target.to_string(); let client = tokio::spawn(async move { let mut sock = TcpStream::connect(("127.0.0.1", proxy_port)).await.unwrap(); let req = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n"); sock.write_all(req.as_bytes()).await.unwrap(); let mut buf = Vec::new(); let _ = sock.read_to_end(&mut buf).await; buf }); let (server, _peer) = listener.accept().await.unwrap(); let entrypoint_pid = Arc::new(AtomicU32::new(std::process::id())); let cache = Arc::new(BinaryIdentityCache::new()); let (denial_tx, mut denial_rx) = mpsc::unbounded_channel(); let completed = tokio::time::timeout( budget, Box::pin(handle_tcp_connection( server, engine, cache, entrypoint_pid, None, // tls_state — ephemeral CA unavailable None, // policy_local_ctx AgentProposals::default(), // agent_proposals Arc::new(None), // backend_host_gateway Arc::new(None), // trusted_host_gateway Arc::new(None), // upstream_proxy None, // provider_credentials None, // secret_resolver None, // dynamic_credentials Some(denial_tx), // denial_tx — positive allow/deny signal None, // activity_tx None, // endpoint_observation_tx )), ) .await .is_ok(); let stdout = client.await.expect("client task"); // The handler future is now finished or dropped, so its sender half is // gone; drain every denial it emitted. let mut denial_stages = Vec::new(); while let Ok(event) = denial_rx.try_recv() { denial_stages.push(event.denial_stage); } (completed, stdout, denial_stages) } /// Drives an absolute-form request through the same explicit-proxy entry /// point used by CONNECT and returns the response and denial stages. async fn drive_forward_through_handler( endpoint_yaml: &str, target: &str, ) -> (Vec, Vec) { const POLICY_REGO: &str = include_str!("../data/sandbox-policy.rego"); let exe = std::env::current_exe().expect("current_exe"); // JSON strings are valid YAML scalars and correctly escape Windows // path separators such as `D:\\...`. let exe_yaml = serde_json::to_string(&exe.to_string_lossy()).expect("encode executable"); let data = format!( r#"network_policies: test_allow: name: test_allow endpoints: {endpoint_yaml} binaries: - {{ path: {exe_yaml} }} "#, ); let engine = Arc::new(OpaEngine::from_strings(POLICY_REGO, &data).expect("load policy")); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let proxy_port = listener.local_addr().unwrap().port(); let target = target.to_string(); let client = tokio::spawn(async move { let mut socket = TcpStream::connect(("127.0.0.1", proxy_port)).await.unwrap(); let request = format!("GET {target} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"); socket.write_all(request.as_bytes()).await.unwrap(); let mut response = Vec::new(); socket.read_to_end(&mut response).await.unwrap(); response }); let (server, _) = listener.accept().await.unwrap(); let (denial_tx, mut denial_rx) = mpsc::unbounded_channel(); Box::pin(handle_tcp_connection( server, engine, Arc::new(BinaryIdentityCache::new()), Arc::new(AtomicU32::new(std::process::id())), None, None, AgentProposals::default(), Arc::new(None), Arc::new(None), Arc::new(None), None, None, None, Some(denial_tx), None, None, )) .await .expect("forward handler should complete"); let response = client.await.expect("client task"); let mut denial_stages = Vec::new(); while let Ok(event) = denial_rx.try_recv() { denial_stages.push(event.denial_stage); } (response, denial_stages) } /// End-to-end regression for the gator finding on PR #2162: with no TLS /// termination state, a terminating `CONNECT` must have its 503 written as /// the FIRST bytes on the socket — never after a `200 Connection /// Established` that would bury the refusal inside the tunnel as a TLS error. #[tokio::test] async fn connect_handler_refuses_terminating_route_with_503_before_200() { if !cfg!(target_os = "linux") { eprintln!("skipping: handler identity binding requires /proc (Linux)"); return; } // Public documentation IP (RFC 5737 TEST-NET-3): passes the SSRF check // but is never actually connected — the fail-closed gate refuses first. // The 30s budget is a generous belt against a slow CI runner; the refusal // returns in milliseconds. let (completed, stdout, _denials) = drive_connect_through_handler( " - { host: \"203.0.113.10\", port: 443 }\n", "203.0.113.10:443", std::time::Duration::from_secs(30), ) .await; assert!(completed, "the refusal must return promptly, not hang"); let resp = String::from_utf8_lossy(&stdout); assert!( resp.starts_with("HTTP/1.1 503 Service Unavailable"), "first bytes must be a 503, not a 200; got: {resp:?}" ); assert!( !resp.contains("200 Connection Established"), "no tunnel must be established before the refusal; got: {resp:?}" ); assert!( resp.contains("tls_termination_unavailable"), "must be the TLS-termination-unavailable refusal; got: {resp:?}" ); } /// Ordering regression (gator re-check item 1): during CA-init failure, an /// internal-address `CONNECT` must still receive the SSRF `403`, not the /// TLS-unavailable `503` — SSRF validation runs before the fail-closed gate. #[tokio::test] async fn connect_handler_returns_ssrf_403_for_internal_address_not_503() { if !cfg!(target_os = "linux") { eprintln!("skipping: handler identity binding requires /proc (Linux)"); return; } let (completed, stdout, _denials) = drive_connect_through_handler( " - { host: \"127.0.0.1\", port: 443 }\n", "127.0.0.1:443", std::time::Duration::from_secs(30), ) .await; assert!(completed, "the SSRF denial must return promptly, not hang"); let resp = String::from_utf8_lossy(&stdout); assert!( resp.starts_with("HTTP/1.1 403 Forbidden"), "internal address must get the SSRF 403; got: {resp:?}" ); assert!( resp.contains("ssrf_denied"), "must be an SSRF denial; got: {resp:?}" ); assert!( !resp.contains("503"), "SSRF runs before the fail-closed gate, so the 503 must not appear; got: {resp:?}" ); } #[tokio::test] async fn forward_handler_preserves_ssrf_response_and_denial_stage() { if !cfg!(target_os = "linux") { eprintln!("skipping: handler identity binding requires /proc (Linux)"); return; } let (response, denial_stages) = Box::pin(drive_forward_through_handler( " - { host: \"127.0.0.1\", port: 80 }\n", "http://127.0.0.1/private", )) .await; let response = String::from_utf8_lossy(&response); assert!( response.starts_with("HTTP/1.1 403 Forbidden"), "internal forward destination must get the SSRF 403; got: {response:?}" ); assert!( response.contains("ssrf_denied"), "expected the SSRF-specific denial body; got: {response:?}" ); assert!( response.contains("GET 127.0.0.1:80 blocked: declared endpoint check failed"), "an explicit loopback endpoint must fail declared-endpoint validation; got: {response:?}" ); assert_eq!(denial_stages, ["ssrf"]); } /// A real `tls: skip` policy path through the handler is exempt from the /// fail-closed gate even with no TLS termination state: the handler proceeds /// past the refusal to the raw-tunnel upstream connect (which stalls on the /// unroutable target), emitting no 503 and no client response before it. #[tokio::test] async fn connect_handler_tls_skip_route_is_not_refused() { if !cfg!(target_os = "linux") { eprintln!("skipping: handler identity binding requires /proc (Linux)"); return; } // `_completed` is intentionally ignored: whether the unroutable connect // stalls (times out) or is rejected fast by a CI egress firewall, the // invariant is the same — a `tls: skip` route is exempt from the // fail-closed gate, so it proceeds to the tunnel connect and writes no // 503/denial to the client beforehand. let (_completed, stdout, denial_stages) = drive_connect_through_handler( " - { host: \"203.0.113.10\", port: 443, tls: skip }\n", "203.0.113.10:443", std::time::Duration::from_millis(800), ) .await; let resp = String::from_utf8_lossy(&stdout); assert!( stdout.is_empty(), "tls: skip must emit no refusal/denial before the tunnel connect; got: {resp:?}" ); // Positive allow evidence, so this test cannot pass vacuously on a // policy/identity deny (which would emit a DenialEvent): the handler // must have emitted no denial at any stage. assert!( denial_stages.is_empty(), "tls: skip must be allowed end-to-end, not denied at any stage; got: {denial_stages:?}" ); } /// Verifies the policy half of the Linux handler tests on every platform /// (no `/proc` needed): the temp-dir binary glob yields an Allow for a /// literal-IP endpoint, `tls: skip` reads back as `TlsMode::Skip`, and a /// terminating endpoint reads back as `TlsMode::Auto`. Locks the OPA /// preconditions so a policy-format regression is caught even where the /// process-identity binding can't run. #[test] fn handler_test_policy_allows_glob_binary_and_reads_tls_mode() { const POLICY_REGO: &str = include_str!("../data/sandbox-policy.rego"); let eval = |tls_line: &str| { let data = format!( r#"network_policies: test_allow: name: test_allow endpoints: - {{ host: "203.0.113.10", port: 443{tls_line} }} binaries: - {{ path: "/tmp/openshell-connect-test/*" }} "# ); let engine = OpaEngine::from_strings(POLICY_REGO, &data).expect("load policy"); let input = crate::opa::NetworkInput { host: "203.0.113.10".to_string(), port: 443, binary_path: PathBuf::from("/tmp/openshell-connect-test/connect-bash"), binary_sha256: "unused".to_string(), ancestors: vec![], cmdline_paths: vec![], }; let authorization = engine.authorize_egress(&input).expect("evaluate"); match &authorization.action { NetworkAction::Allow { matched_policy } => { assert!(matched_policy.is_some(), "allow must carry the policy name"); } NetworkAction::Deny { reason } => { panic!("glob binary must be allowed, got deny: {reason}") } } let decision = EgressDecision { intent: EgressIntent::connect("203.0.113.10".to_string(), 443), action: authorization.action.clone(), policy_generation: authorization.generation, identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(input.binary_path), binary_pid: Some(1), ancestors: vec![], cmdline_paths: vec![], }; query_tls_mode(&decision, "203.0.113.10", 443) }; assert_eq!( eval(", tls: skip"), crate::l7::TlsMode::Skip, "tls: skip endpoint must resolve to TlsMode::Skip" ); assert_eq!( eval(""), crate::l7::TlsMode::Auto, "terminating endpoint must resolve to TlsMode::Auto" ); } #[test] fn test_json_error_response_content_length_matches() { let resp = build_json_error_response(403, "Forbidden", "test", "detail"); let resp_str = String::from_utf8(resp).unwrap(); // Extract Content-Length value let cl_line = resp_str .lines() .find(|l| l.starts_with("Content-Length:")) .unwrap(); let cl: usize = cl_line.split(": ").nth(1).unwrap().trim().parse().unwrap(); // Verify body length matches let body_start = resp_str.find("\r\n\r\n").unwrap() + 4; assert_eq!(resp_str[body_start..].len(), cl); } /// End-to-end regression for the `docker cp` hot-swap hazard around /// unlinked process executables. /// /// `binary_path()` strips the kernel's `" (deleted)"` suffix so policy /// identity and logs use a clean display path. Integrity verification must /// not hash that display path after a hot-swap, because it may now point to /// unrelated replacement bytes. It hashes `/proc//exe` instead, which /// resolves to the live executable inode even after the original path was /// unlinked. /// /// Test shape (from the review comment on the initial PR): /// 1. Start a `TcpListener` in the test process. /// 2. Copy `/bin/bash` to a temp path we control. /// 3. Prime `BinaryIdentityCache` with that temp binary's hash. /// 4. Spawn the temp bash as a child with a `/dev/tcp` one-liner that /// opens a real TCP connection to the listener and holds it open /// inside the bash process. /// 5. Accept the connection on the listener side and capture both socket /// endpoints — that's what `resolve_process_identity` uses to walk /// `/proc/net/tcp` back to the child PID. /// 6. Overwrite the temp bash on disk with different bytes to simulate /// a `docker cp` hot-swap. The running child is unaffected (it still /// executes from its in-memory image), but `/proc//exe` will /// now readlink to `" (deleted)"` OR the overwritten file, depending /// on whether the filesystem reused the inode. /// 7. Call `resolve_process_identity` and assert: /// - identity resolution succeeds using the live executable hash, and /// - the returned display path does not contain the kernel-added /// `"(deleted)"` suffix. #[cfg(target_os = "linux")] #[test] fn resolve_process_identity_hashes_live_exe_after_hot_swap() { use crate::identity::BinaryIdentityCache; use std::io::Read; use std::net::TcpListener; use std::os::unix::fs::PermissionsExt; use std::process::{Command, Stdio}; use std::time::Duration; // Skip if /bin/bash is not present (e.g. minimal containers). if !std::path::Path::new("/bin/bash").exists() { eprintln!("skipping: /bin/bash not available"); return; } // 1. Start a listener on loopback. let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); let proxy_addr = listener.local_addr().unwrap(); // 2. Copy /bin/bash to a temp path. let tmp = tempfile::TempDir::new().unwrap(); let bash_v1 = tmp.path().join("hotswap-bash"); std::fs::copy("/bin/bash", &bash_v1).expect("copy bash"); std::fs::set_permissions(&bash_v1, std::fs::Permissions::from_mode(0o755)).unwrap(); // 3. Prime the cache with the v1 hash of the temp bash. let cache = BinaryIdentityCache::new(); let v1_hash = cache .verify_or_cache(&bash_v1) .expect("prime cache with v1 bash hash"); assert!(!v1_hash.is_empty()); // 4. Spawn the temp bash with a /dev/tcp one-liner that opens a real // connection to the listener and blocks in bash's `read` builtin // to keep it open. Do not use an external command like `sleep`: // it inherits the socket fd and intentionally trips the shared // socket ambiguity guard instead of exercising the hot-swap path. let script = format!( "exec 3<>/dev/tcp/127.0.0.1/{}; read -r -t 30 _ <&3 || true", proxy_addr.port() ); let mut child = Command::new(&bash_v1) .arg("-c") .arg(&script) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("spawn hotswap-bash child"); // 5. Accept on the listener side and capture the peer endpoint. listener.set_nonblocking(false).expect("blocking listener"); let (mut stream, workload_addr) = match listener.accept() { Ok(pair) => pair, Err(e) => { let _ = child.kill(); let _ = child.wait(); panic!("failed to accept child connection: {e}"); } }; let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); // Drain any spurious data; we just need the socket open. stream .set_read_timeout(Some(Duration::from_millis(50))) .ok(); let mut buf = [0u8; 16]; let _ = stream.read(&mut buf); // Give the kernel a moment so /proc//net/tcp and // /proc//fd/ both reflect the ESTABLISHED socket. std::thread::sleep(Duration::from_millis(50)); // 6. Simulate `docker cp`: unlink the running binary and create a // fresh file with different bytes at the same path. Writing // in place via O_TRUNC is rejected by the kernel with ETXTBSY // because the inode is still being executed. Unlink is cheap: // the inode persists in memory via the child's exec mapping, // so the child keeps running, but a new inode now lives at // `bash_v1` with a different SHA-256. std::fs::remove_file(&bash_v1).expect("unlink running bash_v1"); let tampered_bytes = b"#!/bin/sh\n# tampered bash v2 from hotswap test\nexit 0\n"; std::fs::write(&bash_v1, tampered_bytes).expect("write replacement bytes"); // 7. Resolve identity through the real helper and assert the // contract: hash the live executable via /proc//exe while // returning a clean display path for policy/logging. let test_pid = std::process::id(); let result = resolve_process_identity(test_pid, connection, &cache); let child_pid = child.id(); // Always clean up the child before asserting so a failure doesn't // leak a sleeping process across test runs. let _ = child.kill(); let _ = child.wait(); match result { Ok(identity) => { assert_eq!( identity.binary_pid, child_pid, "expected the hot-swapped bash child to own the socket" ); assert_eq!( identity.bin_path, bash_v1, "expected stripped display path to remain the original binary path" ); assert!( !identity.bin_path.to_string_lossy().contains("(deleted)"), "resolved binary path still tainted: {}", identity.bin_path.display() ); assert_eq!( identity.bin_hash, v1_hash, "expected integrity hash from the live executable, not replacement bytes" ); } Err(err) => panic!( "resolve_process_identity failed after hot-swap; expected live-exe identity: {}", err.reason ), } } #[cfg(target_os = "linux")] #[test] fn exited_socket_owner_does_not_hide_a_surviving_owner() { let current_pid = std::process::id(); let owners = crate::procfs::TcpPeerSocketOwners { inode: 42, owners: vec![ crate::procfs::SocketOwner { pid: i32::MAX.cast_unsigned(), source: crate::procfs::SocketOwnerSource::ProcFallback, }, crate::procfs::SocketOwner { pid: current_pid, source: crate::procfs::SocketOwnerSource::ProcFallback, }, ], }; let identity = resolve_socket_owner_identities(owners, current_pid, &BinaryIdentityCache::new()) .unwrap_or_else(|error| panic!("surviving owner: {}", error.reason)); assert_eq!(identity.binary_pid, current_pid); } #[cfg(target_os = "linux")] #[test] // TODO: exec'ing /bin/sleep (SELinux label bin_t) from a user_home_t test // binary causes /proc//exe readlink to return ENOENT on // SELinux-enforcing hosts. Fix by building a test-sleep-helper binary in // the same crate so it inherits the user_home_t label. fn resolve_process_identity_denies_fork_exec_shared_socket_ambiguity() { use crate::identity::BinaryIdentityCache; use std::ffi::CString; use std::net::{TcpListener, TcpStream}; use std::os::fd::AsRawFd; use std::time::{Duration, Instant}; struct ChildGuard(libc::pid_t); impl Drop for ChildGuard { fn drop(&mut self) { #[allow(unsafe_code)] unsafe { libc::kill(self.0, libc::SIGKILL); libc::waitpid(self.0, std::ptr::null_mut(), 0); } } } if !std::path::Path::new("/bin/sleep").exists() { eprintln!("skipping: /bin/sleep not available"); return; } if std::process::Command::new("getenforce") .output() .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).trim() == "Enforcing") { eprintln!( "skipping: SELinux is enforcing — cross-label /proc//exe readlink fails" ); return; } let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); let proxy_addr = listener.local_addr().unwrap(); let stream = TcpStream::connect(proxy_addr).expect("connect"); let workload_addr = stream.local_addr().unwrap(); let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); let (_accepted, _) = listener.accept().expect("accept"); let fd = stream.as_raw_fd(); // libc/syscall FFI requires unsafe #[allow(unsafe_code)] unsafe { let flags = libc::fcntl(fd, libc::F_GETFD); assert!(flags >= 0, "F_GETFD failed"); assert_eq!( libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC), 0, "F_SETFD failed" ); } let sleep_path = CString::new("/bin/sleep").unwrap(); let arg0 = CString::new("sleep").unwrap(); let arg1 = CString::new("30").unwrap(); // libc/syscall FFI requires unsafe #[allow(unsafe_code)] let child_pid = unsafe { libc::fork() }; assert!(child_pid >= 0, "fork failed"); if child_pid == 0 { // libc/syscall FFI requires unsafe #[allow(unsafe_code)] unsafe { libc::execl( sleep_path.as_ptr(), arg0.as_ptr(), arg1.as_ptr(), std::ptr::null::(), ); libc::_exit(127); } } let _guard = ChildGuard(child_pid); let entrypoint_pid = std::process::id(); let deadline = Instant::now() + Duration::from_secs(5); loop { if let Ok(link) = std::fs::read_link(format!("/proc/{child_pid}/exe")) && link.to_string_lossy().contains("sleep") { break; } assert!( Instant::now() < deadline, "child pid {child_pid} did not exec into sleep within 5s" ); std::thread::sleep(Duration::from_millis(20)); } let cache = BinaryIdentityCache::new(); let mut result = resolve_process_identity(entrypoint_pid, connection, &cache); for _ in 0..10 { match &result { Err(err) if err.reason.contains("No such file or directory") || err.reason.contains("os error 2") => { // /proc//fd scan transiently failed; give procfs time to settle. std::thread::sleep(Duration::from_millis(50)); result = resolve_process_identity(entrypoint_pid, connection, &cache); } Ok(_) => { // On arm64 under heavy CI load the /proc fd scan can transiently // miss the parent process's socket fd, making the scan return only // the child as owner and yielding a spurious Ok. Retry to give // both owners time to appear consistently in /proc//fd. std::thread::sleep(Duration::from_millis(50)); result = resolve_process_identity(entrypoint_pid, connection, &cache); } _ => break, } } match result { Ok(identity) => panic!( "resolve_process_identity unexpectedly succeeded for shared socket owned by PID {}", identity.binary_pid ), Err(err) => { assert!( err.reason.contains("ambiguous shared socket ownership"), "expected ambiguous socket ownership error, got: {}", err.reason ); assert!( err.reason.contains(&entrypoint_pid.to_string()), "error should include parent PID; got: {}", err.reason ); assert!( err.reason.contains(&child_pid.to_string()), "error should include child PID; got: {}", err.reason ); } } } #[tokio::test] async fn test_exit_receiver_fires_when_task_exits() { let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); let handle = tokio::spawn(async move { let _guard = exited_tx; }); handle.await.unwrap(); // The sender was dropped when the task completed, so the receiver // should resolve immediately with an Err (sender dropped). assert!(exited_rx.await.is_err()); } #[tokio::test] async fn test_exit_receiver_fires_when_task_is_aborted() { let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); let handle = tokio::spawn(async move { let _guard = exited_tx; std::future::pending::<()>().await; }); handle.abort(); // Abort drops the task's locals, including the sender guard. assert!(exited_rx.await.is_err()); } #[tokio::test] async fn test_take_exit_receiver_returns_real_receiver() { let (tx, rx) = tokio::sync::oneshot::channel::<()>(); let join = tokio::spawn(std::future::pending::<()>()); let mut handle = ProxyHandle { http_addr: None, join, exited_rx: Some(rx), }; let mut taken = handle .take_exit_receiver() .expect("first take should return Some"); assert!(taken.try_recv().is_err()); drop(tx); assert!(taken.await.is_err()); } #[tokio::test] async fn test_take_exit_receiver_second_call_returns_none() { let (_tx, rx) = tokio::sync::oneshot::channel::<()>(); let join = tokio::spawn(std::future::pending::<()>()); let mut handle = ProxyHandle { http_addr: None, join, exited_rx: Some(rx), }; let _first = handle.take_exit_receiver(); assert!(handle.take_exit_receiver().is_none()); } #[tokio::test] async fn test_proxy_handle_drop_fires_exit_receiver() { let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); let join = tokio::spawn(async move { let _guard = exited_tx; std::future::pending::<()>().await; }); let mut handle = ProxyHandle { http_addr: None, join, exited_rx: Some(exited_rx), }; let rx = handle.take_exit_receiver().expect("should return Some"); drop(handle); assert!(rx.await.is_err()); } // --- classify_accept_error tests --- #[cfg(unix)] #[test] fn test_classify_terminal_error_ebadf() { let err = std::io::Error::from_raw_os_error(libc::EBADF); let mut fd = 0; let mut unk = 0; assert_eq!( classify_accept_error(&err, &mut fd, &mut unk), AcceptAction::Terminal ); } #[cfg(unix)] #[test] fn test_classify_terminal_error_einval() { let err = std::io::Error::from_raw_os_error(libc::EINVAL); let mut fd = 0; let mut unk = 0; assert_eq!( classify_accept_error(&err, &mut fd, &mut unk), AcceptAction::Terminal ); } #[cfg(unix)] #[test] fn test_classify_terminal_error_enotsock() { let err = std::io::Error::from_raw_os_error(libc::ENOTSOCK); let mut fd = 0; let mut unk = 0; assert_eq!( classify_accept_error(&err, &mut fd, &mut unk), AcceptAction::Terminal ); } #[cfg(unix)] #[test] fn test_classify_fd_exhaustion_returns_retry_medium() { let err = std::io::Error::from_raw_os_error(libc::EMFILE); let mut fd = 0; let mut unk = 0; let action = classify_accept_error(&err, &mut fd, &mut unk); assert!( matches!( action, AcceptAction::Retry { severity: SeverityId::Medium, .. } ), "expected Retry/Medium for EMFILE, got {action:?}", ); } #[cfg(unix)] #[test] fn test_classify_unknown_error_returns_retry_low() { let err = std::io::Error::from_raw_os_error(libc::ECONNREFUSED); let mut fd = 0; let mut unk = 0; let action = classify_accept_error(&err, &mut fd, &mut unk); assert!( matches!( action, AcceptAction::Retry { severity: SeverityId::Low, .. } ), "expected Retry/Low for unknown error, got {action:?}", ); } #[cfg(unix)] #[test] fn test_classify_fd_exhaustion_backoff_increases_and_caps() { let mut fd = 0; let mut unk = 0; let err = std::io::Error::from_raw_os_error(libc::EMFILE); let mut prev_backoff = std::time::Duration::ZERO; for _ in 0..6 { match classify_accept_error(&err, &mut fd, &mut unk) { AcceptAction::Retry { backoff, .. } => { assert!( backoff > prev_backoff, "backoff should increase: {backoff:?} <= {prev_backoff:?}", ); prev_backoff = backoff; } AcceptAction::Terminal => panic!("expected Retry, got Terminal"), } } // After enough consecutive errors the backoff should hit the 5s cap. for _ in 6..12 { classify_accept_error(&err, &mut fd, &mut unk); } match classify_accept_error(&err, &mut fd, &mut unk) { AcceptAction::Retry { backoff, .. } => { assert_eq!( backoff, std::time::Duration::from_secs(5), "backoff should cap at 5000ms", ); } AcceptAction::Terminal => panic!("expected Retry, got Terminal"), } } #[cfg(unix)] #[test] fn test_classify_unknown_errors_exit_after_threshold() { let mut fd = 0; let mut unk = 0; let err = std::io::Error::from_raw_os_error(libc::ECONNREFUSED); for i in 1..MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { let action = classify_accept_error(&err, &mut fd, &mut unk); assert!( matches!(action, AcceptAction::Retry { .. }), "call {i} should be Retry, got {action:?}", ); } let final_action = classify_accept_error(&err, &mut fd, &mut unk); assert_eq!( final_action, AcceptAction::Terminal, "call {MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS} should be Terminal", ); } #[cfg(unix)] #[test] fn test_classify_success_resets_counters() { let mut fd = 0; let mut unk = 0; let err = std::io::Error::from_raw_os_error(libc::ECONNREFUSED); for _ in 1..MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { classify_accept_error(&err, &mut fd, &mut unk); } fd = 0; unk = 0; for i in 1..MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { let action = classify_accept_error(&err, &mut fd, &mut unk); assert!( matches!(action, AcceptAction::Retry { .. }), "after reset, call {i} should be Retry, got {action:?}", ); } } #[cfg(unix)] #[test] fn test_classify_fd_error_resets_unknown_counter() { let mut fd = 0; let mut unk = 0; let unknown_err = std::io::Error::from_raw_os_error(libc::ECONNREFUSED); let fd_err = std::io::Error::from_raw_os_error(libc::EMFILE); for _ in 0..5 { classify_accept_error(&unknown_err, &mut fd, &mut unk); } classify_accept_error(&fd_err, &mut fd, &mut unk); for i in 1..MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS { let action = classify_accept_error(&unknown_err, &mut fd, &mut unk); assert!( matches!(action, AcceptAction::Retry { .. }), "after FD reset, call {i} should be Retry, got {action:?}", ); } } #[cfg(unix)] #[test] fn test_classify_transient_and_terminal_are_disjoint() { let mut res = 0; let mut unk = 0; for errno in [ libc::EMFILE, libc::ENFILE, libc::ENOBUFS, libc::ENOMEM, libc::ECONNABORTED, libc::ECONNRESET, libc::EINTR, libc::ENETDOWN, libc::EHOSTDOWN, libc::EHOSTUNREACH, libc::EOPNOTSUPP, libc::ENETUNREACH, libc::ENOSR, libc::ETIMEDOUT, ] { let err = std::io::Error::from_raw_os_error(errno); assert!( matches!( classify_accept_error(&err, &mut res, &mut unk), AcceptAction::Retry { .. } ), "errno {errno} should be Retry", ); res = 0; unk = 0; } for errno in [libc::EBADF, libc::EINVAL, libc::ENOTSOCK] { let err = std::io::Error::from_raw_os_error(errno); assert_eq!( classify_accept_error(&err, &mut res, &mut unk), AcceptAction::Terminal, "errno {errno} should be Terminal", ); } } #[cfg(unix)] #[test] fn test_transient_errors_never_hit_unknown_budget() { let mut res = 0; let mut unk = 0; let err = std::io::Error::from_raw_os_error(libc::ECONNABORTED); for i in 0..(MAX_CONSECUTIVE_UNKNOWN_ACCEPT_ERRORS + 5) { let action = classify_accept_error(&err, &mut res, &mut unk); assert!( matches!(action, AcceptAction::Retry { .. }), "transient error on call {i} should always Retry, got {action:?}", ); } } #[path = "compatibility.rs"] mod compatibility; }