Files

13456 lines
502 KiB
Rust

// 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<BoundaryDuplexStream>;
type AcceptedProxyConnection = (
BoundaryDuplexStream,
Option<Result<ContractBinaryIdentity, ResolveError>>,
Option<(SocketAddr, SocketAddr)>,
Option<TransparentOpen>,
);
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<SocketAddr>,
transparent_destination: Option<SocketAddr>,
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<String, openshell_core::proto::ProviderProfileCredential> {
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<SocketAddr>,
join: JoinHandle<()>,
exited_rx: Option<tokio::sync::oneshot::Receiver<()>>,
}
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<SocketAddr>,
opa_engine: Arc<OpaEngine>,
identity_cache: Arc<BinaryIdentityCache>,
entrypoint_pid: Arc<AtomicU32>,
tls_state: Option<Arc<ProxyTlsState>>,
provider_credentials: Option<ProviderCredentialState>,
policy_local_ctx: Option<Arc<PolicyLocalContext>>,
denial_tx: Option<mpsc::UnboundedSender<DenialEvent>>,
activity_tx: Option<ActivitySender>,
endpoint_observation_tx: Option<EndpointObservationSender>,
engine_ready: tokio::sync::watch::Receiver<bool>,
upstream_proxy_args: &upstream_proxy::UpstreamProxyArgs,
backend_host_gateway: Option<IpAddr>,
network_mediation_source: Option<Arc<dyn NetworkMediationSource>>,
policy_dns_store: Option<Arc<ResolvedEndpointStore>>,
direct_listener_identity: Option<ContractBinaryIdentity>,
) -> Result<Self> {
// 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<Option<IpAddr>> = 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<Option<UpstreamProxyConfig>> = 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<SocketAddr> {
self.http_addr
}
pub fn take_exit_receiver(&mut self) -> Option<tokio::sync::oneshot::Receiver<()>> {
self.exited_rx.take()
}
}
async fn preauthorize_transparent_open(
connection: PendingTcpOpen,
policy_dns_store: Option<&Arc<ResolvedEndpointStore>>,
opa_engine: &OpaEngine,
denial_tx: Option<&mpsc::UnboundedSender<DenialEvent>>,
backend_host_gateway: Option<IpAddr>,
trusted_host_gateway: Option<IpAddr>,
) -> Option<AcceptedProxyConnection> {
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<ContractBinaryIdentity, ResolveError>,
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::<Vec<_>>()
.join(" -> "),
identity
.cmdline_paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.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<ResolvedEndpointStore>>,
opa_engine: &OpaEngine,
) -> Result<String> {
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<JoinHandle<()>>,
}
#[cfg(target_os = "linux")]
impl TransparentTcpHandle {
#[allow(clippy::too_many_arguments)]
pub(crate) fn start(
listeners: Vec<TcpListener>,
store: Arc<ResolvedEndpointStore>,
opa_engine: Arc<OpaEngine>,
identity_cache: Arc<BinaryIdentityCache>,
entrypoint_pid: Arc<AtomicU32>,
agent_proposals: openshell_core::proposals::AgentProposals,
denial_tx: Option<mpsc::UnboundedSender<DenialEvent>>,
activity_tx: Option<ActivitySender>,
upstream_proxy_args: &upstream_proxy::UpstreamProxyArgs,
engine_ready: tokio::sync::watch::Receiver<bool>,
) -> Result<Self> {
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<ResolvedEndpointStore>,
opa_engine: Arc<OpaEngine>,
identity_cache: Arc<BinaryIdentityCache>,
entrypoint_pid: Arc<AtomicU32>,
agent_proposals: openshell_core::proposals::AgentProposals,
denial_tx: Option<mpsc::UnboundedSender<DenialEvent>>,
activity_tx: Option<ActivitySender>,
upstream_proxy: Arc<Option<UpstreamProxyConfig>>,
) -> 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<SocketAddr>,
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::<Vec<_>>();
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<SocketAddr> {
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::<libc::sockaddr_in>())
.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::<libc::sockaddr_in6>())
.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<ActivitySender>, 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<crate::l7::middleware::UninspectableTrafficGate> {
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<C>(client: &mut C) -> Result<Option<TunnelProtocol>>
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<ActivitySender>,
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<ForwardL7Reevaluation<'a>>,
}
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<C>(
&self,
request: crate::l7::provider::L7Request,
client: &mut C,
) -> Result<crate::l7::middleware::MiddlewareApplyResult>
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<Sender>`.
fn emit_denial(
tx: &Option<mpsc::UnboundedSender<DenialEvent>>,
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<DenialEvent>>,
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<SocketAddr>,
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<C>(
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<mpsc::UnboundedSender<DenialEvent>>,
activity_tx: &Option<ActivitySender>,
) -> 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<C>(
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<DenialEvent>>,
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<OpaEngine>,
identity_cache: Arc<BinaryIdentityCache>,
entrypoint_pid: Arc<AtomicU32>,
tls_state: Option<Arc<ProxyTlsState>>,
policy_local_ctx: Option<Arc<PolicyLocalContext>>,
agent_proposals: openshell_core::proposals::AgentProposals,
backend_host_gateway: Arc<Option<IpAddr>>,
trusted_host_gateway: Arc<Option<IpAddr>>,
upstream_proxy: Arc<Option<UpstreamProxyConfig>>,
provider_credentials: Option<ProviderCredentialState>,
secret_resolver: Option<Arc<SecretResolver>>,
dynamic_credentials: Option<
Arc<
std::sync::RwLock<
std::collections::HashMap<String, openshell_core::proto::ProviderProfileCredential>,
>,
>,
>,
denial_tx: Option<mpsc::UnboundedSender<DenialEvent>>,
activity_tx: Option<ActivitySender>,
endpoint_observation_tx: Option<EndpointObservationSender>,
) -> 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<Result<ContractBinaryIdentity, ResolveError>>,
socket_addrs: Option<(SocketAddr, SocketAddr)>,
transparent_open: Option<TransparentOpen>,
policy_dns_store: Option<Arc<ResolvedEndpointStore>>,
opa_engine: Arc<OpaEngine>,
identity_cache: Arc<BinaryIdentityCache>,
entrypoint_pid: Arc<AtomicU32>,
tls_state: Option<Arc<ProxyTlsState>>,
policy_local_ctx: Option<Arc<PolicyLocalContext>>,
agent_proposals: openshell_core::proposals::AgentProposals,
backend_host_gateway: Arc<Option<IpAddr>>,
trusted_host_gateway: Arc<Option<IpAddr>>,
upstream_proxy: Arc<Option<UpstreamProxyConfig>>,
provider_credentials: Option<ProviderCredentialState>,
secret_resolver: Option<Arc<SecretResolver>>,
dynamic_credentials: Option<
Arc<
std::sync::RwLock<
std::collections::HashMap<String, openshell_core::proto::ProviderProfileCredential>,
>,
>,
>,
denial_tx: Option<mpsc::UnboundedSender<DenialEvent>>,
activity_tx: Option<ActivitySender>,
endpoint_observation_tx: Option<EndpointObservationSender>,
) -> 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::<Vec<_>>()
.join(" -> ")
};
let cmdline_str = if decision.cmdline_paths.is_empty() {
"-".to_string()
} else {
decision
.cmdline_paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.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<PathBuf>,
cmdline_paths: Vec<PathBuf>,
bin_hash: String,
}
#[cfg(target_os = "linux")]
#[derive(Debug, Eq, PartialEq)]
struct PolicyIdentityKey {
bin_path: PathBuf,
ancestors: Vec<PathBuf>,
cmdline_paths: Vec<PathBuf>,
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<PathBuf>,
binary_pid: Option<u32>,
ancestors: Vec<PathBuf>,
}
#[cfg(target_os = "linux")]
fn resolve_owner_identity(
owner_pid: u32,
entrypoint_pid: u32,
identity_cache: &BinaryIdentityCache,
) -> std::result::Result<ResolvedIdentity, IdentityError> {
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<PathBuf> = 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/<entrypoint_pid>/net/tcp` to find the socket inode, locates
/// every owning PID, reads `/proc/<pid>/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/<pid>/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<ResolvedIdentity, IdentityError> {
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<ResolvedIdentity, IdentityError> {
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<u32> = 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::<Vec<_>>()
.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<PathBuf>,
binary_pid: Option<u32>,
ancestors: Vec<PathBuf>,
cmdline_paths: Vec<PathBuf>|
-> 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<u32> {
(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<ContractBinaryIdentity, ResolveError>,
) -> EgressDecision {
let deny = |reason: String,
binary: Option<PathBuf>,
ancestors: Vec<PathBuf>,
cmdline_paths: Vec<PathBuf>| 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<C>(
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<IpAddr>,
trusted_host_gateway: Option<IpAddr>,
) -> 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<L7RouteSnapshot> {
// 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<crate::l7::EndpointObserver> {
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<crate::l7::EndpointCredentialGuard> {
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<String> {
let lookup_host = normalize_host_lookup_key(host);
if let Ok(ip) = lookup_host.parse::<IpAddr>() {
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<IpAddr> {
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<IpAddr> {
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<Vec<SocketAddr>, 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<Vec<SocketAddr>> {
normalize_host_lookup_key(host)
.parse::<IpAddr>()
.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<IpAddr> {
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::<IpAddr>() 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<SocketAddr> {
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<Vec<SocketAddr>> {
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<Vec<SocketAddr>> {
None
}
async fn resolve_socket_addrs(
host: &str,
port: u16,
entrypoint_pid: u32,
) -> std::result::Result<Vec<SocketAddr>, 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<SocketAddr> = 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<UpstreamProxyConfig>,
host_lc: &str,
raw_host_lc: &str,
port: u16,
addrs: &[SocketAddr],
) -> std::io::Result<upstream_proxy::PrefixedStream> {
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<UpstreamProxyConfig>,
host_lc: &str,
port: u16,
addrs: &[SocketAddr],
) -> std::io::Result<upstream_proxy::PrefixedStream> {
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<Vec<SocketAddr>, 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<Vec<SocketAddr>, 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<Vec<SocketAddr>, 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<Vec<ipnet::IpNet>, 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::<ipnet::IpNet>().or_else(|_| {
entry
.parse::<IpAddr>()
.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<String> {
// 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: &regorus::Value, key: &str) -> Vec<String> {
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::<u16>()
.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::<u16>()
.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<Arc<SecretResolver>>,
host: &str,
port: u16,
canonical_path: &str,
) -> Option<Arc<SecretResolver>> {
endpoint_credentials_for_request(provider_credentials, fallback, host, port, canonical_path)
.resolver
}
struct ForwardEndpointCredentials {
body_classifier: Option<Arc<secrets::body::BodyCredentialClassifier>>,
resolver: Option<Arc<SecretResolver>>,
revision: Option<u64>,
}
fn endpoint_credentials_for_request(
provider_credentials: Option<&ProviderCredentialState>,
fallback: Option<Arc<SecretResolver>>,
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<String>,
upstream_target: String,
telemetry_path: String,
}
fn prepare_forward_target(
target: &str,
canonicalize_options: crate::l7::path::CanonicalizeOptions,
) -> Result<PreparedForwardTarget, crate::l7::path::CanonicalizeError> {
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<Vec<u8>> {
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<Vec<u8>, 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::<Vec<_>>();
let connection_nominated: std::collections::HashSet<String> = 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<usize> {
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<crate::l7::rest::CredentialGenerationGuard<'a>>,
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<ForwardResponseMiddleware<'a>>,
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<C, U>(
method: &str,
path: &str,
rewritten: Vec<u8>,
client: &mut C,
upstream: &mut U,
options: ForwardRelayOptions<'_>,
) -> Result<crate::l7::provider::RelayOutcome>
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<u8>,
l7_ctx: &crate::l7::relay::L7EvalContext,
) -> Result<Vec<u8>> {
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<ContractBinaryIdentity, ResolveError>>,
socket_addrs: Option<(SocketAddr, SocketAddr)>,
opa_engine: Arc<OpaEngine>,
identity_cache: Arc<BinaryIdentityCache>,
entrypoint_pid: Arc<AtomicU32>,
policy_local_ctx: Option<Arc<PolicyLocalContext>>,
agent_proposals: openshell_core::proposals::AgentProposals,
backend_host_gateway: Arc<Option<IpAddr>>,
trusted_host_gateway: Arc<Option<IpAddr>>,
provider_credentials: Option<ProviderCredentialState>,
secret_resolver: Option<Arc<SecretResolver>>,
dynamic_credentials: Option<
Arc<
std::sync::RwLock<
std::collections::HashMap<String, openshell_core::proto::ProviderProfileCredential>,
>,
>,
>,
denial_tx: Option<&mpsc::UnboundedSender<DenialEvent>>,
activity_tx: Option<&ActivitySender>,
endpoint_observation_tx: Option<EndpointObservationSender>,
) -> 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::<Vec<_>>()
.join(" -> ")
};
let cmdline_str = if decision.cmdline_paths.is_empty() {
"-".to_string()
} else {
decision
.cmdline_paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.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<crate::opa::TunnelPolicyEngine> = 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<crate::l7::L7EndpointConfig> = 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::<secrets::body::BodyCredentialError>() {
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::<secrets::UnresolvedPlaceholderError>() {
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<u8> {
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<u8> {
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<u8> {
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<u8> {
build_middleware_platform_response(policy_name, "403 Forbidden")
}
fn build_middleware_unavailable_response(policy_name: &str) -> Vec<u8> {
build_middleware_platform_response(policy_name, "503 Service Unavailable")
}
fn build_middleware_platform_response(policy_name: &str, status: &str) -> Vec<u8> {
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<C>(
client: &mut C,
tls_state_present: bool,
effective_tls_skip: bool,
) -> Result<bool>
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<openshell_core::proto::MiddlewareDescribeRequest>,
) -> std::result::Result<
tonic::Response<openshell_core::proto::MiddlewareManifest>,
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<openshell_core::proto::ValidateConfigRequest>,
) -> std::result::Result<
tonic::Response<openshell_core::proto::ValidateConfigResponse>,
tonic::Status,
> {
Ok(tonic::Response::new(
openshell_core::proto::ValidateConfigResponse {
valid: true,
reason: String::new(),
},
))
}
async fn evaluate_http_request(
&self,
_request: tonic::Request<openshell_core::proto::HttpRequestEvaluation>,
) -> std::result::Result<
tonic::Response<openshell_core::proto::HttpRequestResult>,
tonic::Status,
> {
Err(tonic::Status::unimplemented("WebSocket-only test service"))
}
async fn open_websocket_session(
&self,
mut receiver: mpsc::Receiver<openshell_core::proto::WebSocketSessionEvent>,
) -> std::result::Result<openshell_core::middleware::WebSocketResponseStream, tonic::Status>
{
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<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
}
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<openshell_core::proto::HttpRequestResult> {
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<openshell_core::proto::HttpResponseEvent>,
) -> std::result::Result<openshell_core::middleware::HttpResponseResultStream, tonic::Status>
{
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<openshell_core::proto::HttpRequestResult> {
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<Ipv4Addr> {
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<u8>) -> Vec<u8> {
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::<usize>().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<String> {
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<String> {
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::<usize>().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<R: TokioAsyncRead + Unpin>(reader: &mut R) -> Vec<u8> {
read_http_headers_with_timeout(reader, std::time::Duration::from_secs(1)).await
}
async fn read_http_headers_with_timeout<R: TokioAsyncRead + Unpin>(
reader: &mut R,
timeout: std::time::Duration,
) -> Vec<u8> {
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<R: TokioAsyncRead + Unpin>(reader: &mut R) -> Vec<u8> {
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<u8> {
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<u8>) {
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::<ipnet::IpNet>().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::<ipnet::IpNet>().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::<ipnet::IpNet>().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::<ipnet::IpNet>().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::<Vec<_>>(),
["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::<Vec<_>>(),
["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::<secrets::UnresolvedPlaceholderError>()
.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::<secrets::UnresolvedPlaceholderError>()
.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::<ipnet::IpNet>().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::<ipnet::IpNet>().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::<ipnet::IpNet>().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<u8>, Vec<String>) {
const POLICY_REGO: &str = include_str!("../data/sandbox-policy.rego");
// Allow the current test binary — the in-process client's
// `/proc/<pid>/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<u8>, Vec<String>) {
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/<pid>/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/<child>/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/<pid>/net/tcp and
// /proc/<pid>/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/<pid>/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/<pid>/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/<pid>/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::c_char>(),
);
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/<pid>/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/<pid>/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;
}