mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-01 07:53:42 +08:00
Signed-off-by: John Myers <johntmyers@users.noreply.github.com> Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
4455 lines
164 KiB
Rust
4455 lines
164 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.
|
|
|
|
use crate::denial_aggregator::DenialEvent;
|
|
use crate::identity::BinaryIdentityCache;
|
|
use crate::l7::tls::ProxyTlsState;
|
|
use crate::opa::{NetworkAction, OpaEngine};
|
|
use crate::policy::ProxyPolicy;
|
|
use crate::secrets::{SecretResolver, rewrite_header_line};
|
|
use miette::{IntoDiagnostic, Result};
|
|
use openshell_core::net::{is_always_blocked_ip, is_internal_ip};
|
|
use openshell_ocsf::{
|
|
ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest,
|
|
NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit,
|
|
};
|
|
use std::net::{IpAddr, SocketAddr};
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::{TcpListener, TcpStream};
|
|
use tokio::sync::mpsc;
|
|
use tokio::task::JoinHandle;
|
|
use tracing::{debug, warn};
|
|
|
|
const MAX_HEADER_BYTES: usize = 8192;
|
|
const INFERENCE_LOCAL_HOST: &str = "inference.local";
|
|
const INFERENCE_LOCAL_PORT: u16 = 443;
|
|
|
|
/// Maximum total bytes for a streaming inference response body (32 MiB).
|
|
const MAX_STREAMING_BODY: usize = 32 * 1024 * 1024;
|
|
|
|
/// Idle timeout per chunk when relaying streaming inference responses.
|
|
///
|
|
/// Reasoning models (e.g. nemotron-3-super, o1, o3) can pause for 60+ seconds
|
|
/// between "thinking" and output phases. 120s provides headroom while still
|
|
/// catching genuinely stuck streams.
|
|
const CHUNK_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
|
|
|
/// Result of a proxy CONNECT policy decision.
|
|
struct ConnectDecision {
|
|
action: NetworkAction,
|
|
/// Resolved binary path.
|
|
binary: Option<PathBuf>,
|
|
/// PID owning the socket.
|
|
binary_pid: Option<u32>,
|
|
/// Ancestor binary paths from process tree walk.
|
|
ancestors: Vec<PathBuf>,
|
|
/// Cmdline-derived absolute paths (for script detection).
|
|
cmdline_paths: Vec<PathBuf>,
|
|
}
|
|
|
|
/// Outcome of an inference interception attempt.
|
|
///
|
|
/// Returned by [`handle_inference_interception`] so the call site can emit
|
|
/// a structured CONNECT deny log when the connection is not successfully routed.
|
|
#[derive(Debug)]
|
|
enum InferenceOutcome {
|
|
/// At least one request was successfully routed to a local inference backend.
|
|
Routed,
|
|
/// The connection was denied (TLS failure, non-inference request, etc.).
|
|
Denied { reason: String },
|
|
}
|
|
|
|
/// Inference routing context for sandbox-local execution.
|
|
///
|
|
/// Holds a `Router` (HTTP client) and cached sets of resolved routes.
|
|
/// User routes serve `inference.local` traffic; system routes are consumed
|
|
/// in-process by the supervisor for platform functions (e.g. agent harness).
|
|
pub struct InferenceContext {
|
|
pub patterns: Vec<crate::l7::inference::InferenceApiPattern>,
|
|
router: openshell_router::Router,
|
|
/// Routes for the user-facing `inference.local` endpoint.
|
|
routes: Arc<tokio::sync::RwLock<Vec<openshell_router::config::ResolvedRoute>>>,
|
|
/// Routes for supervisor-only system inference (`sandbox-system`).
|
|
system_routes: Arc<tokio::sync::RwLock<Vec<openshell_router::config::ResolvedRoute>>>,
|
|
}
|
|
|
|
impl InferenceContext {
|
|
pub fn new(
|
|
patterns: Vec<crate::l7::inference::InferenceApiPattern>,
|
|
router: openshell_router::Router,
|
|
routes: Vec<openshell_router::config::ResolvedRoute>,
|
|
system_routes: Vec<openshell_router::config::ResolvedRoute>,
|
|
) -> Self {
|
|
Self {
|
|
patterns,
|
|
router,
|
|
routes: Arc::new(tokio::sync::RwLock::new(routes)),
|
|
system_routes: Arc::new(tokio::sync::RwLock::new(system_routes)),
|
|
}
|
|
}
|
|
|
|
/// Get a handle to the user route cache for background refresh.
|
|
pub fn route_cache(
|
|
&self,
|
|
) -> Arc<tokio::sync::RwLock<Vec<openshell_router::config::ResolvedRoute>>> {
|
|
self.routes.clone()
|
|
}
|
|
|
|
/// Get a handle to the system route cache for background refresh.
|
|
pub fn system_route_cache(
|
|
&self,
|
|
) -> Arc<tokio::sync::RwLock<Vec<openshell_router::config::ResolvedRoute>>> {
|
|
self.system_routes.clone()
|
|
}
|
|
|
|
/// Make an inference call using system routes (supervisor-only).
|
|
///
|
|
/// This is the in-process API for platform functions. It bypasses the
|
|
/// CONNECT proxy entirely — the supervisor calls the router directly
|
|
/// from the host network namespace.
|
|
pub async fn system_inference(
|
|
&self,
|
|
protocol: &str,
|
|
method: &str,
|
|
path: &str,
|
|
headers: Vec<(String, String)>,
|
|
body: bytes::Bytes,
|
|
) -> Result<openshell_router::ProxyResponse, openshell_router::RouterError> {
|
|
let routes = self.system_routes.read().await;
|
|
self.router
|
|
.proxy_with_candidates(protocol, method, path, headers, body, &routes)
|
|
.await
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ProxyHandle {
|
|
#[allow(dead_code)]
|
|
http_addr: Option<SocketAddr>,
|
|
join: JoinHandle<()>,
|
|
}
|
|
|
|
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 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>>,
|
|
inference_ctx: Option<Arc<InferenceContext>>,
|
|
secret_resolver: Option<Arc<SecretResolver>>,
|
|
denial_tx: Option<mpsc::UnboundedSender<DenialEvent>>,
|
|
) -> 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 listener = TcpListener::bind(http_addr).await.into_diagnostic()?;
|
|
let local_addr = listener.local_addr().into_diagnostic()?;
|
|
{
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Listen)
|
|
.severity(SeverityId::Informational)
|
|
.status(StatusId::Success)
|
|
.dst_endpoint(Endpoint::from_ip(local_addr.ip(), local_addr.port()))
|
|
.message(format!("Proxy listening on {local_addr}"))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
|
|
let join = tokio::spawn(async move {
|
|
loop {
|
|
match listener.accept().await {
|
|
Ok((stream, _addr)) => {
|
|
let opa = opa_engine.clone();
|
|
let cache = identity_cache.clone();
|
|
let spid = entrypoint_pid.clone();
|
|
let tls = tls_state.clone();
|
|
let inf = inference_ctx.clone();
|
|
let resolver = secret_resolver.clone();
|
|
let dtx = denial_tx.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(err) = handle_tcp_connection(
|
|
stream, opa, cache, spid, tls, inf, resolver, dtx,
|
|
)
|
|
.await
|
|
{
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Low)
|
|
.status(StatusId::Failure)
|
|
.message(format!("Proxy connection error: {err}"))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
});
|
|
}
|
|
Err(err) => {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Low)
|
|
.status(StatusId::Failure)
|
|
.message(format!("Proxy accept error: {err}"))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(Self {
|
|
http_addr: Some(local_addr),
|
|
join,
|
|
})
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub const fn http_addr(&self) -> Option<SocketAddr> {
|
|
self.http_addr
|
|
}
|
|
}
|
|
|
|
impl Drop for ProxyHandle {
|
|
fn drop(&mut self) {
|
|
self.join.abort();
|
|
}
|
|
}
|
|
|
|
/// 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: &ConnectDecision,
|
|
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: &ConnectDecision,
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
async fn handle_tcp_connection(
|
|
mut client: TcpStream,
|
|
opa_engine: Arc<OpaEngine>,
|
|
identity_cache: Arc<BinaryIdentityCache>,
|
|
entrypoint_pid: Arc<AtomicU32>,
|
|
tls_state: Option<Arc<ProxyTlsState>>,
|
|
inference_ctx: Option<Arc<InferenceContext>>,
|
|
secret_resolver: Option<Arc<SecretResolver>>,
|
|
denial_tx: Option<mpsc::UnboundedSender<DenialEvent>>,
|
|
) -> Result<()> {
|
|
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 request = String::from_utf8_lossy(&buf[..used]);
|
|
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 handle_forward_proxy(
|
|
method,
|
|
target,
|
|
&buf[..],
|
|
used,
|
|
&mut client,
|
|
opa_engine,
|
|
identity_cache,
|
|
entrypoint_pid,
|
|
secret_resolver,
|
|
denial_tx.as_ref(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let (host, port) = parse_target(target)?;
|
|
let host_lc = host.to_ascii_lowercase();
|
|
|
|
if host_lc == INFERENCE_LOCAL_HOST && port == INFERENCE_LOCAL_PORT {
|
|
respond(&mut client, b"HTTP/1.1 200 Connection Established\r\n\r\n").await?;
|
|
let outcome = handle_inference_interception(
|
|
client,
|
|
INFERENCE_LOCAL_HOST,
|
|
port,
|
|
tls_state.as_ref(),
|
|
inference_ctx.as_ref(),
|
|
)
|
|
.await?;
|
|
if let InferenceOutcome::Denied { reason } = outcome {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Open)
|
|
.action(ActionId::Denied)
|
|
.disposition(DispositionId::Blocked)
|
|
.severity(SeverityId::Medium)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(INFERENCE_LOCAL_HOST, port))
|
|
.message(format!("Inference interception denied: {reason}"))
|
|
.status_detail(&reason)
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
let peer_addr = client.peer_addr().into_diagnostic()?;
|
|
let _local_addr = client.local_addr().into_diagnostic()?;
|
|
|
|
// 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 opa_clone = opa_engine.clone();
|
|
let cache_clone = identity_cache.clone();
|
|
let pid_clone = entrypoint_pid.clone();
|
|
let host_clone = host_lc.clone();
|
|
let decision = tokio::task::spawn_blocking(move || {
|
|
evaluate_opa_tcp(
|
|
peer_addr,
|
|
&opa_clone,
|
|
&cache_clone,
|
|
&pid_clone,
|
|
&host_clone,
|
|
port,
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|e| miette::miette!("identity resolution task panicked: {e}"))?;
|
|
|
|
// 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(crate::ocsf_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(peer_addr.ip(), peer_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",
|
|
);
|
|
respond(
|
|
&mut client,
|
|
&build_json_error_response(
|
|
403,
|
|
"Forbidden",
|
|
"policy_denied",
|
|
&format!("CONNECT {host_lc}:{port} not permitted by policy"),
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire);
|
|
|
|
// Query allowed_ips from the matched endpoint config (if any).
|
|
// When present, the SSRF check validates resolved IPs against this
|
|
// allowlist instead of blanket-blocking all private IPs.
|
|
// When the policy host is already a literal IP address, treat it as
|
|
// implicitly allowed — the user explicitly declared the destination.
|
|
let mut raw_allowed_ips = query_allowed_ips(&opa_engine, &decision, &host_lc, port);
|
|
if raw_allowed_ips.is_empty() {
|
|
raw_allowed_ips = implicit_allowed_ips_for_ip_host(&host);
|
|
}
|
|
|
|
// Defense-in-depth: resolve DNS and reject connections to internal IPs.
|
|
let dns_connect_start = std::time::Instant::now();
|
|
let mut upstream = if !raw_allowed_ips.is_empty() {
|
|
// allowed_ips mode: validate resolved IPs against CIDR allowlist.
|
|
// Loopback and link-local are still always blocked.
|
|
match parse_allowed_ips(&raw_allowed_ips) {
|
|
Ok(nets) => {
|
|
match resolve_and_check_allowed_ips(&host, port, &nets, sandbox_entrypoint_pid)
|
|
.await
|
|
{
|
|
Ok(addrs) => TcpStream::connect(addrs.as_slice())
|
|
.await
|
|
.into_diagnostic()?,
|
|
Err(reason) => {
|
|
{
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_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(peer_addr.ip(), peer_addr.port())
|
|
.actor_process(
|
|
Process::from_bypass(&binary_str, &pid_str, &ancestors_str)
|
|
.with_cmd_line(&cmdline_str),
|
|
)
|
|
.firewall_rule("-", "ssrf")
|
|
.message(format!(
|
|
"CONNECT blocked: allowed_ips check failed for {host_lc}:{port}"
|
|
))
|
|
.status_detail(&reason)
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
emit_denial(
|
|
&denial_tx,
|
|
&host_lc,
|
|
port,
|
|
&binary_str,
|
|
&decision,
|
|
&reason,
|
|
"ssrf",
|
|
);
|
|
respond(
|
|
&mut client,
|
|
&build_json_error_response(
|
|
403,
|
|
"Forbidden",
|
|
"ssrf_denied",
|
|
&format!(
|
|
"CONNECT {host_lc}:{port} blocked: allowed_ips check failed"
|
|
),
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
Err(reason) => {
|
|
{
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_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(peer_addr.ip(), peer_addr.port())
|
|
.actor_process(
|
|
Process::from_bypass(&binary_str, &pid_str, &ancestors_str)
|
|
.with_cmd_line(&cmdline_str),
|
|
)
|
|
.firewall_rule("-", "ssrf")
|
|
.message(format!(
|
|
"CONNECT blocked: invalid allowed_ips in policy for {host_lc}:{port}"
|
|
))
|
|
.status_detail(&reason)
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
emit_denial(
|
|
&denial_tx,
|
|
&host_lc,
|
|
port,
|
|
&binary_str,
|
|
&decision,
|
|
&reason,
|
|
"ssrf",
|
|
);
|
|
respond(
|
|
&mut client,
|
|
&build_json_error_response(
|
|
403,
|
|
"Forbidden",
|
|
"ssrf_denied",
|
|
&format!("CONNECT {host_lc}:{port} blocked: invalid allowed_ips in policy"),
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
} else {
|
|
// Default: reject all internal IPs (loopback, RFC 1918, link-local).
|
|
match resolve_and_reject_internal(&host, port, sandbox_entrypoint_pid).await {
|
|
Ok(addrs) => TcpStream::connect(addrs.as_slice())
|
|
.await
|
|
.into_diagnostic()?,
|
|
Err(reason) => {
|
|
{
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_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(peer_addr.ip(), peer_addr.port())
|
|
.actor_process(
|
|
Process::from_bypass(&binary_str, &pid_str, &ancestors_str)
|
|
.with_cmd_line(&cmdline_str),
|
|
)
|
|
.firewall_rule("-", "ssrf")
|
|
.message(format!(
|
|
"CONNECT blocked: internal address {host_lc}:{port}"
|
|
))
|
|
.status_detail(&reason)
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
emit_denial(
|
|
&denial_tx,
|
|
&host_lc,
|
|
port,
|
|
&binary_str,
|
|
&decision,
|
|
&reason,
|
|
"ssrf",
|
|
);
|
|
respond(
|
|
&mut client,
|
|
&build_json_error_response(
|
|
403,
|
|
"Forbidden",
|
|
"ssrf_denied",
|
|
&format!("CONNECT {host_lc}:{port} blocked: internal address"),
|
|
),
|
|
)
|
|
.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?;
|
|
|
|
// Check if endpoint has L7 config for protocol-aware inspection
|
|
let l7_config = query_l7_config(&opa_engine, &decision, &host_lc, port);
|
|
|
|
// Log the allowed CONNECT — use CONNECT_L7 when L7 inspection follows,
|
|
// so log consumers can distinguish L4-only decisions from tunnel lifecycle events.
|
|
let connect_msg = if l7_config.is_some() {
|
|
"CONNECT_L7"
|
|
} else {
|
|
"CONNECT"
|
|
};
|
|
{
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Open)
|
|
.action(ActionId::Allowed)
|
|
.disposition(DispositionId::Allowed)
|
|
.severity(SeverityId::Informational)
|
|
.status(StatusId::Success)
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.src_endpoint_addr(peer_addr.ip(), peer_addr.port())
|
|
.actor_process(
|
|
Process::from_bypass(&binary_str, &pid_str, &ancestors_str)
|
|
.with_cmd_line(&cmdline_str),
|
|
)
|
|
.firewall_rule(policy_str, "opa")
|
|
.message(format!("{connect_msg} allowed {host_lc}:{port}"))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
|
|
// Determine effective TLS mode. Check the raw endpoint config for
|
|
// `tls: skip` independently of L7 config (which requires `protocol`).
|
|
let effective_tls_skip =
|
|
query_tls_mode(&opa_engine, &decision, &host_lc, port) == crate::l7::TlsMode::Skip;
|
|
|
|
// Build L7 eval context (shared by TLS-terminated and plaintext paths).
|
|
let ctx = crate::l7::relay::L7EvalContext {
|
|
host: host_lc.clone(),
|
|
port,
|
|
policy_name: matched_policy.clone().unwrap_or_default(),
|
|
binary_path: decision
|
|
.binary
|
|
.as_ref()
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
.unwrap_or_default(),
|
|
ancestors: decision
|
|
.ancestors
|
|
.iter()
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
.collect(),
|
|
cmdline_paths: decision
|
|
.cmdline_paths
|
|
.iter()
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
.collect(),
|
|
secret_resolver: secret_resolver.clone(),
|
|
};
|
|
|
|
if effective_tls_skip {
|
|
// tls: skip — raw tunnel, no termination, no credential injection.
|
|
debug!(
|
|
host = %host_lc,
|
|
port = port,
|
|
"tls: skip — bypassing TLS auto-detection, raw tunnel"
|
|
);
|
|
let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream)
|
|
.await
|
|
.into_diagnostic()?;
|
|
return Ok(());
|
|
}
|
|
|
|
// Auto-detect TLS by peeking the first bytes.
|
|
let mut peek_buf = [0u8; 8];
|
|
let n = client.peek(&mut peek_buf).await.into_diagnostic()?;
|
|
if n == 0 {
|
|
return Ok(());
|
|
}
|
|
|
|
let is_tls = crate::l7::tls::looks_like_tls(&peek_buf[..n]);
|
|
let is_http = crate::l7::rest::looks_like_http(&peek_buf[..n]);
|
|
|
|
if is_tls {
|
|
// TLS detected — terminate unconditionally.
|
|
if let Some(ref tls) = tls_state {
|
|
let tls_result = async {
|
|
let mut tls_client =
|
|
crate::l7::tls::tls_terminate_client(client, tls, &host_lc).await?;
|
|
let mut tls_upstream =
|
|
crate::l7::tls::tls_connect_upstream(upstream, &host_lc, tls.upstream_config())
|
|
.await?;
|
|
|
|
if let Some(ref l7_config) = l7_config {
|
|
// L7 inspection on terminated TLS traffic.
|
|
let tunnel_engine = opa_engine.clone_engine_for_tunnel().unwrap_or_else(|e| {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Low)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.message(format!(
|
|
"Failed to clone OPA engine for L7, falling back to relay-only: {e}"
|
|
))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
regorus::Engine::new()
|
|
});
|
|
crate::l7::relay::relay_with_inspection(
|
|
l7_config,
|
|
std::sync::Mutex::new(tunnel_engine),
|
|
&mut tls_client,
|
|
&mut tls_upstream,
|
|
&ctx,
|
|
)
|
|
.await
|
|
} else {
|
|
// No L7 config — relay with credential injection only.
|
|
crate::l7::relay::relay_passthrough_with_credentials(
|
|
&mut tls_client,
|
|
&mut tls_upstream,
|
|
&ctx,
|
|
)
|
|
.await
|
|
}
|
|
};
|
|
if let Err(e) = 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(crate::ocsf_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 {
|
|
{
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Low)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.message(format!(
|
|
"TLS detected but TLS state not configured for {host_lc}:{port}, falling back to raw tunnel"
|
|
))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream)
|
|
.await
|
|
.into_diagnostic()?;
|
|
}
|
|
} else if is_http {
|
|
// Plaintext HTTP detected.
|
|
if let Some(ref l7_config) = l7_config {
|
|
let tunnel_engine = opa_engine.clone_engine_for_tunnel().unwrap_or_else(|e| {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Low)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.message(format!(
|
|
"Failed to clone OPA engine for L7, falling back to relay-only: {e}"
|
|
))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
regorus::Engine::new()
|
|
});
|
|
if let Err(e) = crate::l7::relay::relay_with_inspection(
|
|
l7_config,
|
|
std::sync::Mutex::new(tunnel_engine),
|
|
&mut client,
|
|
&mut upstream,
|
|
&ctx,
|
|
)
|
|
.await
|
|
{
|
|
if is_benign_relay_error(&e) {
|
|
debug!(host = %host_lc, port = port, error = %e, "L7 connection closed");
|
|
} else {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Low)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.message(format!("L7 relay error: {e}"))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
}
|
|
} else {
|
|
// Plaintext HTTP, no L7 config — relay with credential injection.
|
|
if let Err(e) = crate::l7::relay::relay_passthrough_with_credentials(
|
|
&mut client,
|
|
&mut upstream,
|
|
&ctx,
|
|
)
|
|
.await
|
|
{
|
|
if is_benign_relay_error(&e) {
|
|
debug!(host = %host_lc, port = port, error = %e, "HTTP relay closed");
|
|
} else {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Low)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.message(format!("HTTP relay error: {e}"))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Neither TLS nor HTTP — raw binary relay.
|
|
debug!(
|
|
host = %host_lc,
|
|
port = port,
|
|
"Non-TLS non-HTTP traffic detected, raw tunnel"
|
|
);
|
|
let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream)
|
|
.await
|
|
.into_diagnostic()?;
|
|
}
|
|
|
|
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 [`evaluate_opa_tcp`]
|
|
/// 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 [`ConnectDecision`] 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(&bin_path)
|
|
.map_err(|e| IdentityError {
|
|
reason: format!("binary integrity check failed: {e}"),
|
|
binary: Some(bin_path.clone()),
|
|
binary_pid: Some(owner_pid),
|
|
ancestors: vec![],
|
|
})?;
|
|
|
|
let ancestors = crate::procfs::collect_ancestor_binaries(owner_pid, entrypoint_pid);
|
|
|
|
for ancestor in &ancestors {
|
|
identity_cache
|
|
.verify_or_cache(ancestor)
|
|
.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,
|
|
})
|
|
}
|
|
|
|
/// 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 [`evaluate_opa_tcp`] extracted
|
|
/// into a standalone helper so it can be exercised by Linux-only regression
|
|
/// tests without a full OPA engine. The key invariant under test is that on
|
|
/// a hot-swap of the peer binary, the failure mode is
|
|
/// `"Binary integrity violation"` (from the identity cache) rather than
|
|
/// `"Failed to stat ... (deleted)"` (from the kernel-tainted path).
|
|
#[cfg(target_os = "linux")]
|
|
fn resolve_process_identity(
|
|
entrypoint_pid: u32,
|
|
peer_port: u16,
|
|
identity_cache: &BinaryIdentityCache,
|
|
) -> std::result::Result<ResolvedIdentity, IdentityError> {
|
|
let socket_owners = crate::procfs::resolve_tcp_peer_socket_owners(entrypoint_pid, peer_port)
|
|
.map_err(|e| IdentityError {
|
|
reason: format!("failed to resolve peer binary: {e}"),
|
|
binary: None,
|
|
binary_pid: None,
|
|
ancestors: vec![],
|
|
})?;
|
|
|
|
let mut identities = Vec::with_capacity(socket_owners.owners.len());
|
|
for owner in &socket_owners.owners {
|
|
identities.push(resolve_owner_identity(
|
|
owner.pid,
|
|
entrypoint_pid,
|
|
identity_cache,
|
|
)?);
|
|
}
|
|
|
|
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 mut identity = identities.swap_remove(0);
|
|
if let Some(lowest_pid) = socket_owners.owners.iter().map(|owner| owner.pid).min() {
|
|
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 evaluate_opa_tcp(
|
|
peer_addr: SocketAddr,
|
|
engine: &OpaEngine,
|
|
identity_cache: &BinaryIdentityCache,
|
|
entrypoint_pid: &AtomicU32,
|
|
host: &str,
|
|
port: u16,
|
|
) -> ConnectDecision {
|
|
use crate::opa::NetworkInput;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
let deny = |reason: String,
|
|
binary: Option<PathBuf>,
|
|
binary_pid: Option<u32>,
|
|
ancestors: Vec<PathBuf>,
|
|
cmdline_paths: Vec<PathBuf>|
|
|
-> ConnectDecision {
|
|
ConnectDecision {
|
|
action: NetworkAction::Deny { reason },
|
|
binary,
|
|
binary_pid,
|
|
ancestors,
|
|
cmdline_paths,
|
|
}
|
|
};
|
|
|
|
let pid = entrypoint_pid.load(Ordering::Acquire);
|
|
if pid == 0 {
|
|
return deny(
|
|
"entrypoint process not yet spawned".into(),
|
|
None,
|
|
None,
|
|
vec![],
|
|
vec![],
|
|
);
|
|
}
|
|
|
|
let total_start = std::time::Instant::now();
|
|
let peer_port = peer_addr.port();
|
|
|
|
let identity = match resolve_process_identity(pid, peer_port, identity_cache) {
|
|
Ok(id) => id,
|
|
Err(err) => {
|
|
return deny(
|
|
err.reason,
|
|
err.binary,
|
|
err.binary_pid,
|
|
err.ancestors,
|
|
vec![],
|
|
);
|
|
}
|
|
};
|
|
|
|
let ResolvedIdentity {
|
|
bin_path,
|
|
binary_pid,
|
|
ancestors,
|
|
cmdline_paths,
|
|
bin_hash,
|
|
} = identity;
|
|
|
|
let input = NetworkInput {
|
|
host: host.to_string(),
|
|
port,
|
|
binary_path: bin_path.clone(),
|
|
binary_sha256: bin_hash,
|
|
ancestors: ancestors.clone(),
|
|
cmdline_paths: cmdline_paths.clone(),
|
|
};
|
|
|
|
let result = match engine.evaluate_network_action(&input) {
|
|
Ok(action) => ConnectDecision {
|
|
action,
|
|
binary: Some(bin_path),
|
|
binary_pid: Some(binary_pid),
|
|
ancestors,
|
|
cmdline_paths,
|
|
},
|
|
Err(e) => deny(
|
|
format!("policy evaluation error: {e}"),
|
|
Some(bin_path),
|
|
Some(binary_pid),
|
|
ancestors,
|
|
cmdline_paths,
|
|
),
|
|
};
|
|
debug!(
|
|
"evaluate_opa_tcp TOTAL: {}ms host={host} port={port}",
|
|
total_start.elapsed().as_millis()
|
|
);
|
|
result
|
|
}
|
|
|
|
/// Non-Linux stub: OPA identity binding requires /proc.
|
|
#[cfg(not(target_os = "linux"))]
|
|
fn evaluate_opa_tcp(
|
|
_peer_addr: SocketAddr,
|
|
_engine: &OpaEngine,
|
|
_identity_cache: &BinaryIdentityCache,
|
|
_entrypoint_pid: &AtomicU32,
|
|
_host: &str,
|
|
_port: u16,
|
|
) -> ConnectDecision {
|
|
ConnectDecision {
|
|
action: NetworkAction::Deny {
|
|
reason: "identity binding unavailable on this platform".into(),
|
|
},
|
|
binary: None,
|
|
binary_pid: None,
|
|
ancestors: vec![],
|
|
cmdline_paths: vec![],
|
|
}
|
|
}
|
|
|
|
/// Maximum buffer size for inference request parsing (10 MiB).
|
|
const MAX_INFERENCE_BUF: usize = 10 * 1024 * 1024;
|
|
|
|
/// Initial buffer size for inference request parsing (64 KiB).
|
|
const INITIAL_INFERENCE_BUF: usize = 65536;
|
|
|
|
/// Handle an intercepted connection for inference routing.
|
|
///
|
|
/// TLS-terminates the client connection, parses HTTP requests, and executes
|
|
/// inference API calls locally via `openshell-router`.
|
|
/// Non-inference requests are denied with 403.
|
|
///
|
|
/// Returns [`InferenceOutcome::Routed`] if at least one request was successfully
|
|
/// routed, or [`InferenceOutcome::Denied`] with a reason for all denial cases.
|
|
async fn handle_inference_interception(
|
|
client: TcpStream,
|
|
host: &str,
|
|
port: u16,
|
|
tls_state: Option<&Arc<ProxyTlsState>>,
|
|
inference_ctx: Option<&Arc<InferenceContext>>,
|
|
) -> Result<InferenceOutcome> {
|
|
let Some(ctx) = inference_ctx else {
|
|
return Ok(InferenceOutcome::Denied {
|
|
reason: "cluster inference context not configured".to_string(),
|
|
});
|
|
};
|
|
|
|
let Some(tls) = tls_state else {
|
|
return Ok(InferenceOutcome::Denied {
|
|
reason: "missing TLS state".to_string(),
|
|
});
|
|
};
|
|
|
|
// TLS-terminate the client side (present a cert for the target host)
|
|
let mut tls_client = match crate::l7::tls::tls_terminate_client(client, tls, host).await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
return Ok(InferenceOutcome::Denied {
|
|
reason: format!("TLS handshake failed: {e}"),
|
|
});
|
|
}
|
|
};
|
|
|
|
process_inference_keepalive(&mut tls_client, ctx, port).await
|
|
}
|
|
|
|
/// Read and process HTTP requests from a TLS-terminated inference connection.
|
|
///
|
|
/// Each request is matched against inference patterns and routed locally.
|
|
/// Any non-inference request is immediately denied and the connection is closed,
|
|
/// even if previous requests on the same keep-alive connection were routed
|
|
/// successfully.
|
|
async fn process_inference_keepalive<S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin>(
|
|
stream: &mut S,
|
|
ctx: &InferenceContext,
|
|
port: u16,
|
|
) -> Result<InferenceOutcome> {
|
|
use crate::l7::inference::{ParseResult, format_http_response, try_parse_http_request};
|
|
|
|
let mut buf = vec![0u8; INITIAL_INFERENCE_BUF];
|
|
let mut used = 0usize;
|
|
let mut routed_any = false;
|
|
|
|
loop {
|
|
let n = match stream.read(&mut buf[used..]).await {
|
|
Ok(n) => n,
|
|
Err(e) => {
|
|
if routed_any {
|
|
break;
|
|
}
|
|
return Ok(InferenceOutcome::Denied {
|
|
reason: format!("I/O error: {e}"),
|
|
});
|
|
}
|
|
};
|
|
if n == 0 {
|
|
if routed_any {
|
|
break;
|
|
}
|
|
return Ok(InferenceOutcome::Denied {
|
|
reason: "client closed connection".to_string(),
|
|
});
|
|
}
|
|
used += n;
|
|
|
|
// Try to parse a complete HTTP request
|
|
match try_parse_http_request(&buf[..used]) {
|
|
ParseResult::Complete(request, consumed) => {
|
|
let was_routed = route_inference_request(&request, ctx, stream).await?;
|
|
if was_routed {
|
|
routed_any = true;
|
|
} else {
|
|
// Deny and close: a non-inference request must not be silently
|
|
// ignored on a keep-alive connection that previously routed
|
|
// inference traffic.
|
|
return Ok(InferenceOutcome::Denied {
|
|
reason: "connection not allowed by policy".to_string(),
|
|
});
|
|
}
|
|
|
|
// Shift buffer for next request
|
|
buf.copy_within(consumed..used, 0);
|
|
used -= consumed;
|
|
}
|
|
ParseResult::Incomplete => {
|
|
// Need more data — grow buffer if full
|
|
if used == buf.len() {
|
|
if buf.len() >= MAX_INFERENCE_BUF {
|
|
let response = format_http_response(413, &[], b"Payload Too Large");
|
|
write_all(stream, &response).await?;
|
|
if routed_any {
|
|
break;
|
|
}
|
|
return Ok(InferenceOutcome::Denied {
|
|
reason: "payload too large".to_string(),
|
|
});
|
|
}
|
|
buf.resize((buf.len() * 2).min(MAX_INFERENCE_BUF), 0);
|
|
}
|
|
}
|
|
ParseResult::Invalid(reason) => {
|
|
{
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Refuse)
|
|
.action(ActionId::Denied)
|
|
.disposition(DispositionId::Rejected)
|
|
.severity(SeverityId::Medium)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(INFERENCE_LOCAL_HOST, port))
|
|
.message(format!("Rejecting malformed inference request: {reason}"))
|
|
.status_detail(&reason)
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
let response = format_http_response(400, &[], b"Bad Request");
|
|
write_all(stream, &response).await?;
|
|
return Ok(InferenceOutcome::Denied { reason });
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(InferenceOutcome::Routed)
|
|
}
|
|
|
|
/// Route a parsed inference request locally via the sandbox router, or deny it.
|
|
///
|
|
/// Returns `Ok(true)` if the request was routed to an inference backend,
|
|
/// `Ok(false)` if it was denied as a non-inference request.
|
|
async fn route_inference_request(
|
|
request: &crate::l7::inference::ParsedHttpRequest,
|
|
ctx: &InferenceContext,
|
|
tls_client: &mut (impl tokio::io::AsyncWrite + Unpin),
|
|
) -> Result<bool> {
|
|
use crate::l7::inference::{detect_inference_pattern, format_http_response};
|
|
|
|
let normalized_path = normalize_inference_path(&request.path);
|
|
|
|
if let Some(pattern) =
|
|
detect_inference_pattern(&request.method, &normalized_path, &ctx.patterns)
|
|
{
|
|
{
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Open)
|
|
.action(ActionId::Allowed)
|
|
.disposition(DispositionId::Detected)
|
|
.severity(SeverityId::Informational)
|
|
.status(StatusId::Success)
|
|
.dst_endpoint(Endpoint::from_domain(INFERENCE_LOCAL_HOST, 443))
|
|
.message(format!(
|
|
"Intercepted inference request, routing locally: {} {} (protocol={}, kind={})",
|
|
request.method, normalized_path, pattern.protocol, pattern.kind
|
|
))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
|
|
let routes = ctx.routes.read().await;
|
|
|
|
if routes.is_empty() {
|
|
let body = serde_json::json!({
|
|
"error": "cluster inference is not configured",
|
|
"hint": "run: openshell cluster inference set --help"
|
|
});
|
|
let body_bytes = body.to_string();
|
|
let response = format_http_response(
|
|
503,
|
|
&[("content-type".to_string(), "application/json".to_string())],
|
|
body_bytes.as_bytes(),
|
|
);
|
|
write_all(tls_client, &response).await?;
|
|
return Ok(true);
|
|
}
|
|
|
|
match ctx
|
|
.router
|
|
.proxy_with_candidates_streaming(
|
|
&pattern.protocol,
|
|
&request.method,
|
|
&normalized_path,
|
|
request.headers.clone(),
|
|
bytes::Bytes::from(request.body.clone()),
|
|
&routes,
|
|
)
|
|
.await
|
|
{
|
|
Ok(mut resp) => {
|
|
use crate::l7::inference::{
|
|
format_chunk, format_chunk_terminator, format_http_response_header,
|
|
format_sse_error,
|
|
};
|
|
|
|
let resp_headers = sanitize_inference_response_headers(
|
|
std::mem::take(&mut resp.headers).into_iter().collect(),
|
|
);
|
|
|
|
// Write response headers immediately (chunked TE).
|
|
let header_bytes = format_http_response_header(resp.status, &resp_headers);
|
|
write_all(tls_client, &header_bytes).await?;
|
|
|
|
// Stream body chunks with byte cap and idle timeout.
|
|
//
|
|
// Each upstream chunk is wrapped in HTTP chunked framing and
|
|
// flushed immediately so SSE events reach the client without
|
|
// delay. Unlike the previous per-byte write_all+flush, we
|
|
// coalesce the framing header + data + trailer into a single
|
|
// write_all call, reducing the number of TLS records per chunk
|
|
// from 3 to 1 while preserving incremental delivery.
|
|
let mut total_bytes: usize = 0;
|
|
loop {
|
|
match tokio::time::timeout(CHUNK_IDLE_TIMEOUT, resp.next_chunk()).await {
|
|
Ok(Ok(Some(chunk))) => {
|
|
total_bytes += chunk.len();
|
|
if total_bytes > MAX_STREAMING_BODY {
|
|
warn!(
|
|
total_bytes = total_bytes,
|
|
limit = MAX_STREAMING_BODY,
|
|
"streaming response exceeded byte limit, truncating"
|
|
);
|
|
let err = format_sse_error(
|
|
"response truncated: exceeded maximum streaming body size",
|
|
);
|
|
let _ = write_all(tls_client, &format_chunk(&err)).await;
|
|
break;
|
|
}
|
|
let encoded = format_chunk(&chunk);
|
|
write_all(tls_client, &encoded).await?;
|
|
}
|
|
Ok(Ok(None)) => break,
|
|
Ok(Err(e)) => {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Medium)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(INFERENCE_LOCAL_HOST, 443))
|
|
.message(format!(
|
|
"error reading upstream response chunk after \
|
|
{total_bytes} bytes: {e}"
|
|
))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
let err = format_sse_error("response truncated: upstream read error");
|
|
let _ = write_all(tls_client, &format_chunk(&err)).await;
|
|
break;
|
|
}
|
|
Err(_) => {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Medium)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(INFERENCE_LOCAL_HOST, 443))
|
|
.message(format!(
|
|
"streaming response chunk idle timeout after \
|
|
{total_bytes} bytes, closing"
|
|
))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
let err =
|
|
format_sse_error("response truncated: chunk idle timeout exceeded");
|
|
let _ = write_all(tls_client, &format_chunk(&err)).await;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Terminate the chunked stream.
|
|
write_all(tls_client, format_chunk_terminator()).await?;
|
|
}
|
|
Err(e) => {
|
|
{
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Low)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(INFERENCE_LOCAL_HOST, 443))
|
|
.message(format!(
|
|
"inference endpoint detected but upstream service failed: {e}"
|
|
))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
let (status, msg) = router_error_to_http(&e);
|
|
let body = serde_json::json!({"error": msg});
|
|
let body_bytes = body.to_string();
|
|
let response = format_http_response(
|
|
status,
|
|
&[("content-type".to_string(), "application/json".to_string())],
|
|
body_bytes.as_bytes(),
|
|
);
|
|
write_all(tls_client, &response).await?;
|
|
}
|
|
}
|
|
Ok(true)
|
|
} else {
|
|
// Not an inference request — deny
|
|
{
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Open)
|
|
.action(ActionId::Denied)
|
|
.disposition(DispositionId::Blocked)
|
|
.severity(SeverityId::Medium)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(INFERENCE_LOCAL_HOST, 443))
|
|
.message(format!(
|
|
"connection not allowed by policy: {} {}",
|
|
request.method, normalized_path
|
|
))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
let body = serde_json::json!({"error": "connection not allowed by policy"});
|
|
let body_bytes = body.to_string();
|
|
let response = format_http_response(
|
|
403,
|
|
&[("content-type".to_string(), "application/json".to_string())],
|
|
body_bytes.as_bytes(),
|
|
);
|
|
write_all(tls_client, &response).await?;
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
/// Map router errors to HTTP status codes and sanitized messages.
|
|
///
|
|
/// Returns generic error messages instead of verbatim internal details.
|
|
/// Full error context (upstream URLs, hostnames, TLS details) is logged
|
|
/// server-side by the caller at `warn` level for debugging.
|
|
fn router_error_to_http(err: &openshell_router::RouterError) -> (u16, String) {
|
|
use openshell_router::RouterError;
|
|
match err {
|
|
RouterError::RouteNotFound(_) => (400, "no inference route configured".to_string()),
|
|
RouterError::NoCompatibleRoute(_) => {
|
|
(400, "no compatible inference route available".to_string())
|
|
}
|
|
RouterError::Unauthorized(_) => (401, "unauthorized".to_string()),
|
|
RouterError::UpstreamUnavailable(_) => (503, "inference service unavailable".to_string()),
|
|
RouterError::UpstreamProtocol(_) | RouterError::Internal(_) => {
|
|
(502, "inference service error".to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sanitize_inference_response_headers(headers: Vec<(String, String)>) -> Vec<(String, String)> {
|
|
headers
|
|
.into_iter()
|
|
.filter(|(name, _)| !should_strip_response_header(name))
|
|
.collect()
|
|
}
|
|
|
|
fn should_strip_response_header(name: &str) -> bool {
|
|
let name_lc = name.to_ascii_lowercase();
|
|
matches!(name_lc.as_str(), "content-length") || is_hop_by_hop_header(&name_lc)
|
|
}
|
|
|
|
fn is_hop_by_hop_header(name: &str) -> bool {
|
|
matches!(
|
|
name,
|
|
"connection"
|
|
| "keep-alive"
|
|
| "proxy-authenticate"
|
|
| "proxy-authorization"
|
|
| "proxy-connection"
|
|
| "te"
|
|
| "trailer"
|
|
| "transfer-encoding"
|
|
| "upgrade"
|
|
)
|
|
}
|
|
|
|
/// Write all bytes to an async writer.
|
|
async fn write_all(writer: &mut (impl tokio::io::AsyncWrite + Unpin), data: &[u8]) -> Result<()> {
|
|
use tokio::io::AsyncWriteExt;
|
|
writer.write_all(data).await.into_diagnostic()?;
|
|
writer.flush().await.into_diagnostic()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Query L7 endpoint config from the OPA engine for a matched CONNECT decision.
|
|
///
|
|
/// Returns `Some(L7EndpointConfig)` if the matched endpoint has L7 config (protocol field),
|
|
/// `None` for L4-only endpoints.
|
|
fn query_l7_config(
|
|
engine: &OpaEngine,
|
|
decision: &ConnectDecision,
|
|
host: &str,
|
|
port: u16,
|
|
) -> Option<crate::l7::L7EndpointConfig> {
|
|
// Only query if action is Allow (not Deny)
|
|
let has_policy = match &decision.action {
|
|
NetworkAction::Allow { matched_policy } => matched_policy.is_some(),
|
|
_ => false,
|
|
};
|
|
if !has_policy {
|
|
return None;
|
|
}
|
|
|
|
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(),
|
|
};
|
|
|
|
match engine.query_endpoint_config(&input) {
|
|
Ok(Some(val)) => crate::l7::parse_l7_config(&val),
|
|
Ok(None) => None,
|
|
Err(e) => {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Low)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(host, port))
|
|
.message(format!("Failed to query L7 endpoint config: {e}"))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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(
|
|
engine: &OpaEngine,
|
|
decision: &ConnectDecision,
|
|
host: &str,
|
|
port: u16,
|
|
) -> crate::l7::TlsMode {
|
|
let has_policy = match &decision.action {
|
|
NetworkAction::Allow { matched_policy } => matched_policy.is_some(),
|
|
_ => false,
|
|
};
|
|
if !has_policy {
|
|
return crate::l7::TlsMode::Auto;
|
|
}
|
|
|
|
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(),
|
|
};
|
|
|
|
match engine.query_endpoint_config(&input) {
|
|
Ok(Some(val)) => crate::l7::parse_tls_mode(&val),
|
|
_ => 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 {
|
|
host.strip_prefix('[')
|
|
.and_then(|trimmed| trimmed.strip_suffix(']'))
|
|
.unwrap_or(host)
|
|
}
|
|
|
|
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) }
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
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 lookup_host = normalize_host_lookup_key(host);
|
|
let addrs: Vec<SocketAddr> = tokio::net::lookup_host((lookup_host, port))
|
|
.await
|
|
.map_err(|e| format!("DNS resolution failed for {lookup_host}:{port}: {e}"))?
|
|
.collect();
|
|
|
|
if addrs.is_empty() {
|
|
return Err(format!(
|
|
"DNS resolution returned no addresses for {lookup_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(())
|
|
}
|
|
|
|
/// 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>, String> {
|
|
let addrs = resolve_socket_addrs(host, port, entrypoint_pid).await?;
|
|
reject_internal_resolved_addrs(host, &addrs)?;
|
|
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>, String> {
|
|
let addrs = resolve_socket_addrs(host, port, entrypoint_pid).await?;
|
|
validate_allowed_ips_for_resolved_addrs(host, port, &addrs, allowed_ips)?;
|
|
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 = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Other)
|
|
.severity(SeverityId::Medium)
|
|
.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("; "))
|
|
}
|
|
}
|
|
|
|
/// Query allowed_ips from the matched endpoint config for a CONNECT decision.
|
|
fn query_allowed_ips(
|
|
engine: &OpaEngine,
|
|
decision: &ConnectDecision,
|
|
host: &str,
|
|
port: u16,
|
|
) -> 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(),
|
|
_ => false,
|
|
};
|
|
if !has_policy {
|
|
return vec![];
|
|
}
|
|
|
|
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(),
|
|
};
|
|
|
|
match engine.query_allowed_ips(&input) {
|
|
Ok(ips) => ips,
|
|
Err(e) => {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Low)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(host, port))
|
|
.message(format!(
|
|
"Failed to query allowed_ips from endpoint config: {e}"
|
|
))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
vec![]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Canonicalize the request-target for inference pattern detection.
|
|
///
|
|
/// Falls back to the raw path on canonicalization error: the request is then
|
|
/// routed through the normal forward path, where `rest.rs::parse_http_request`
|
|
/// will reject it properly. Returning the raw path here prevents a crafted
|
|
/// target from bypassing inference routing without our detection logic having
|
|
/// to implement a second, duplicate error-response surface.
|
|
fn normalize_inference_path(path: &str) -> String {
|
|
match crate::l7::path::canonicalize_request_target(
|
|
path,
|
|
&crate::l7::path::CanonicalizeOptions::default(),
|
|
) {
|
|
Ok((canon, _)) => canon.path,
|
|
Err(_) => path.to_string(),
|
|
}
|
|
}
|
|
|
|
/// 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(|i| &uri[i + 3..]).unwrap_or(uri);
|
|
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(|i| &authority[..=i])
|
|
.unwrap_or(authority)
|
|
} 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 path
|
|
let (authority, path) = if rest.starts_with('[') {
|
|
// IPv6: [::1]:port/path
|
|
let bracket_end = rest
|
|
.find(']')
|
|
.ok_or_else(|| miette::miette!("Unclosed IPv6 bracket in URI: {uri}"))?;
|
|
let after_bracket = &rest[bracket_end + 1..];
|
|
if let Some(slash_pos) = after_bracket.find('/') {
|
|
(
|
|
&rest[..bracket_end + 1 + slash_pos],
|
|
&after_bracket[slash_pos..],
|
|
)
|
|
} else {
|
|
(&rest[..], "/")
|
|
}
|
|
} else if let Some(slash_pos) = rest.find('/') {
|
|
(&rest[..slash_pos], &rest[slash_pos..])
|
|
} else {
|
|
(rest, "/")
|
|
};
|
|
|
|
// 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}"));
|
|
}
|
|
|
|
let path = if path.is_empty() { "/" } else { path };
|
|
|
|
Ok((scheme, host, port, path.to_string()))
|
|
}
|
|
|
|
/// 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,
|
|
secret_resolver: Option<&SecretResolver>,
|
|
) -> Result<Vec<u8>, crate::secrets::UnresolvedPlaceholderError> {
|
|
let header_end = raw[..used]
|
|
.windows(4)
|
|
.position(|w| w == b"\r\n\r\n")
|
|
.map_or(used, |p| p + 4);
|
|
|
|
let header_str = String::from_utf8_lossy(&raw[..header_end]);
|
|
let lines = header_str.split("\r\n").collect::<Vec<_>>();
|
|
|
|
// Rebuild headers, stripping hop-by-hop and adding proxy headers
|
|
let mut output = Vec::with_capacity(header_end + 128);
|
|
let mut has_connection = false;
|
|
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(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 lower = line.to_ascii_lowercase();
|
|
|
|
// Strip proxy hop-by-hop headers
|
|
if lower.starts_with("proxy-connection:")
|
|
|| lower.starts_with("proxy-authorization:")
|
|
|| lower.starts_with("proxy-authenticate:")
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Replace Connection header
|
|
if lower.starts_with("connection:") {
|
|
has_connection = true;
|
|
output.extend_from_slice(b"Connection: close\r\n");
|
|
continue;
|
|
}
|
|
|
|
let rewritten_line = match secret_resolver {
|
|
Some(resolver) => rewrite_header_line(line, resolver),
|
|
None => line.to_string(),
|
|
};
|
|
|
|
output.extend_from_slice(rewritten_line.as_bytes());
|
|
output.extend_from_slice(b"\r\n");
|
|
|
|
if lower.starts_with("via:") {
|
|
has_via = true;
|
|
}
|
|
}
|
|
|
|
// Inject missing headers
|
|
if !has_connection {
|
|
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");
|
|
|
|
// Append any overflow body bytes from the original buffer
|
|
if header_end < used {
|
|
output.extend_from_slice(&raw[header_end..used]);
|
|
}
|
|
|
|
// Fail-closed: scan for any remaining unresolved placeholders
|
|
if secret_resolver.is_some() {
|
|
let output_str = String::from_utf8_lossy(&output);
|
|
if output_str.contains(crate::secrets::PLACEHOLDER_PREFIX_PUBLIC) {
|
|
return Err(crate::secrets::UnresolvedPlaceholderError { location: "header" });
|
|
}
|
|
}
|
|
|
|
Ok(output)
|
|
}
|
|
|
|
/// 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 response using `copy_bidirectional` for streaming.
|
|
async fn handle_forward_proxy(
|
|
method: &str,
|
|
target_uri: &str,
|
|
buf: &[u8],
|
|
used: usize,
|
|
client: &mut TcpStream,
|
|
opa_engine: Arc<OpaEngine>,
|
|
identity_cache: Arc<BinaryIdentityCache>,
|
|
entrypoint_pid: Arc<AtomicU32>,
|
|
secret_resolver: Option<Arc<SecretResolver>>,
|
|
denial_tx: Option<&mpsc::UnboundedSender<DenialEvent>>,
|
|
) -> Result<()> {
|
|
// 1. Parse the absolute-form URI. `path` is marked `mut` so that, when an
|
|
// L7 config applies, the canonicalized form produced below replaces it
|
|
// in-place — keeping OPA evaluation and the bytes written onto the wire
|
|
// in sync. See the L7 block below.
|
|
let (scheme, host, port, mut path) = match parse_proxy_uri(target_uri) {
|
|
Ok(parsed) => parsed,
|
|
Err(e) => {
|
|
let event = HttpActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Low)
|
|
.status(StatusId::Failure)
|
|
.message(format!("FORWARD parse error for {target_uri}: {e}"))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
let host_lc = host.to_ascii_lowercase();
|
|
|
|
// 2. Reject HTTPS — must use CONNECT for TLS
|
|
if scheme == "https" {
|
|
{
|
|
let event = HttpActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Refuse)
|
|
.action(ActionId::Denied)
|
|
.disposition(DispositionId::Rejected)
|
|
.severity(SeverityId::Informational)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.message(format!(
|
|
"FORWARD rejected: HTTPS requires CONNECT for {host_lc}:{port}"
|
|
))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
respond(
|
|
client,
|
|
b"HTTP/1.1 400 Bad Request\r\nContent-Length: 27\r\n\r\nUse CONNECT for HTTPS URLs",
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
|
|
// 3. Evaluate OPA policy (same identity binding as CONNECT)
|
|
let peer_addr = client.peer_addr().into_diagnostic()?;
|
|
let _local_addr = client.local_addr().into_diagnostic()?;
|
|
|
|
let opa_clone = opa_engine.clone();
|
|
let cache_clone = identity_cache.clone();
|
|
let pid_clone = entrypoint_pid.clone();
|
|
let host_clone = host_lc.clone();
|
|
let decision = tokio::task::spawn_blocking(move || {
|
|
evaluate_opa_tcp(
|
|
peer_addr,
|
|
&opa_clone,
|
|
&cache_clone,
|
|
&pid_clone,
|
|
&host_clone,
|
|
port,
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|e| miette::miette!("identity resolution task panicked: {e}"))?;
|
|
|
|
// 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 } => {
|
|
{
|
|
let event = HttpActivityBuilder::new(crate::ocsf_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, &path, port),
|
|
))
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port()))
|
|
.actor_process(
|
|
Process::from_bypass(&binary_str, &pid_str, &ancestors_str)
|
|
.with_cmd_line(&cmdline_str),
|
|
)
|
|
.firewall_rule("-", "opa")
|
|
.message(format!("FORWARD denied {method} {host_lc}:{port}{path}"))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
emit_denial_simple(
|
|
denial_tx,
|
|
&host_lc,
|
|
port,
|
|
&binary_str,
|
|
&decision,
|
|
reason,
|
|
"forward",
|
|
);
|
|
respond(
|
|
client,
|
|
&build_json_error_response(
|
|
403,
|
|
"Forbidden",
|
|
"policy_denied",
|
|
&format!("{method} {host_lc}:{port}{path} not permitted by policy"),
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
let policy_str = matched_policy.as_deref().unwrap_or("-");
|
|
let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire);
|
|
|
|
// 4b. If the endpoint has L7 config, evaluate the request against
|
|
// L7 policy. The forward proxy handles exactly one request per
|
|
// connection (Connection: close), so a single evaluation suffices.
|
|
if let Some(l7_config) = query_l7_config(&opa_engine, &decision, &host_lc, port) {
|
|
let tunnel_engine = opa_engine.clone_engine_for_tunnel().unwrap_or_else(|e| {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Low)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.message(format!("Failed to clone OPA engine for forward L7: {e}"))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
regorus::Engine::new()
|
|
});
|
|
let engine_mutex = std::sync::Mutex::new(tunnel_engine);
|
|
|
|
let l7_ctx = crate::l7::relay::L7EvalContext {
|
|
host: host_lc.clone(),
|
|
port,
|
|
policy_name: matched_policy.clone().unwrap_or_default(),
|
|
binary_path: decision
|
|
.binary
|
|
.as_ref()
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
.unwrap_or_default(),
|
|
ancestors: decision
|
|
.ancestors
|
|
.iter()
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
.collect(),
|
|
cmdline_paths: decision
|
|
.cmdline_paths
|
|
.iter()
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
.collect(),
|
|
secret_resolver: secret_resolver.clone(),
|
|
};
|
|
|
|
// Canonicalize the request-target. The canonical form is fed to OPA
|
|
// AND reassigned to the outer `path` variable so the later call to
|
|
// `rewrite_forward_request` writes canonical bytes to the upstream.
|
|
// This closes the policy/upstream parser-differential at this site;
|
|
// without this reassignment, OPA would evaluate the canonical form
|
|
// while the upstream re-normalizes the raw input and dispatches on a
|
|
// potentially different path.
|
|
let canonicalize_options = crate::l7::path::CanonicalizeOptions {
|
|
allow_encoded_slash: l7_config.allow_encoded_slash,
|
|
..Default::default()
|
|
};
|
|
let query_params =
|
|
match crate::l7::path::canonicalize_request_target(&path, &canonicalize_options) {
|
|
Ok((canon, query)) => {
|
|
let params = match query.as_deref() {
|
|
Some(q) => crate::l7::rest::parse_query_params(q).unwrap_or_default(),
|
|
None => std::collections::HashMap::new(),
|
|
};
|
|
path = canon.path;
|
|
params
|
|
}
|
|
Err(e) => {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Fail)
|
|
.severity(SeverityId::Medium)
|
|
.status(StatusId::Failure)
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.message(format!(
|
|
"FORWARD_L7 rejecting non-canonical request-target: {e}"
|
|
))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
respond(
|
|
client,
|
|
&build_json_error_response(
|
|
400,
|
|
"Bad Request",
|
|
"invalid_request_target",
|
|
"request-target must be canonical",
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
let request_info = crate::l7::L7RequestInfo {
|
|
action: method.to_string(),
|
|
target: path.clone(),
|
|
query_params,
|
|
};
|
|
|
|
let (allowed, reason) =
|
|
crate::l7::relay::evaluate_l7_request(&engine_mutex, &l7_ctx, &request_info)
|
|
.unwrap_or_else(|e| {
|
|
let event = NetworkActivityBuilder::new(crate::ocsf_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}"))
|
|
});
|
|
|
|
let decision_str = match (allowed, l7_config.enforcement) {
|
|
(true, _) => "allow",
|
|
(false, crate::l7::EnforcementMode::Audit) => "audit",
|
|
(false, crate::l7::EnforcementMode::Enforce) => "deny",
|
|
};
|
|
|
|
{
|
|
let (action_id, disposition_id, severity) = match decision_str {
|
|
"allow" => (
|
|
ActionId::Allowed,
|
|
DispositionId::Allowed,
|
|
SeverityId::Informational,
|
|
),
|
|
"deny" => (ActionId::Denied, DispositionId::Blocked, SeverityId::Medium),
|
|
"audit" => (
|
|
ActionId::Allowed,
|
|
DispositionId::Allowed,
|
|
SeverityId::Informational,
|
|
),
|
|
_ => (
|
|
ActionId::Other,
|
|
DispositionId::Other,
|
|
SeverityId::Informational,
|
|
),
|
|
};
|
|
let event = HttpActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Other)
|
|
.action(action_id)
|
|
.disposition(disposition_id)
|
|
.severity(severity)
|
|
.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(peer_addr.ip(), peer_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 {decision_str} {method} {host_lc}:{port}{path} reason={reason}"
|
|
))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
|
|
let effectively_denied =
|
|
!allowed && l7_config.enforcement == crate::l7::EnforcementMode::Enforce;
|
|
|
|
if effectively_denied {
|
|
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}{path} denied by L7 policy"),
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
// 5. DNS resolution + SSRF defence (mirrors the CONNECT path logic).
|
|
// - If allowed_ips is set: validate resolved IPs against the allowlist
|
|
// (this is the SSRF override for private IP destinations).
|
|
// - If allowed_ips is empty: 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.
|
|
let mut raw_allowed_ips = query_allowed_ips(&opa_engine, &decision, &host_lc, port);
|
|
if raw_allowed_ips.is_empty() {
|
|
raw_allowed_ips = implicit_allowed_ips_for_ip_host(&host);
|
|
}
|
|
|
|
let addrs =
|
|
if !raw_allowed_ips.is_empty() {
|
|
// allowed_ips mode: validate resolved IPs against CIDR allowlist.
|
|
match parse_allowed_ips(&raw_allowed_ips) {
|
|
Ok(nets) => {
|
|
match resolve_and_check_allowed_ips(&host, port, &nets, sandbox_entrypoint_pid)
|
|
.await
|
|
{
|
|
Ok(addrs) => addrs,
|
|
Err(reason) => {
|
|
{
|
|
let event = HttpActivityBuilder::new(crate::ocsf_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, &path, port),
|
|
))
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port()))
|
|
.actor_process(
|
|
Process::from_bypass(&binary_str, &pid_str, &ancestors_str)
|
|
.with_cmd_line(&cmdline_str),
|
|
)
|
|
.firewall_rule(policy_str, "ssrf")
|
|
.message(format!(
|
|
"FORWARD blocked: allowed_ips check failed for {host_lc}:{port}"
|
|
))
|
|
.status_detail(&reason)
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
emit_denial_simple(
|
|
denial_tx,
|
|
&host_lc,
|
|
port,
|
|
&binary_str,
|
|
&decision,
|
|
&reason,
|
|
"ssrf",
|
|
);
|
|
respond(
|
|
client,
|
|
&build_json_error_response(
|
|
403,
|
|
"Forbidden",
|
|
"ssrf_denied",
|
|
&format!("{method} {host_lc}:{port} blocked: allowed_ips check failed"),
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
Err(reason) => {
|
|
{
|
|
let event = HttpActivityBuilder::new(crate::ocsf_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, &path, port),
|
|
))
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port()))
|
|
.actor_process(
|
|
Process::from_bypass(&binary_str, &pid_str, &ancestors_str)
|
|
.with_cmd_line(&cmdline_str),
|
|
)
|
|
.firewall_rule(policy_str, "ssrf")
|
|
.message(format!(
|
|
"FORWARD blocked: invalid allowed_ips in policy for {host_lc}:{port}"
|
|
))
|
|
.status_detail(&reason)
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
emit_denial_simple(
|
|
denial_tx,
|
|
&host_lc,
|
|
port,
|
|
&binary_str,
|
|
&decision,
|
|
&reason,
|
|
"ssrf",
|
|
);
|
|
respond(
|
|
client,
|
|
&build_json_error_response(
|
|
403,
|
|
"Forbidden",
|
|
"ssrf_denied",
|
|
&format!(
|
|
"{method} {host_lc}:{port} blocked: invalid allowed_ips in policy"
|
|
),
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
} else {
|
|
// No allowed_ips: reject internal IPs, allow public IPs through.
|
|
match resolve_and_reject_internal(&host, port, sandbox_entrypoint_pid).await {
|
|
Ok(addrs) => addrs,
|
|
Err(reason) => {
|
|
{
|
|
let event = HttpActivityBuilder::new(crate::ocsf_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, &path, port),
|
|
))
|
|
.dst_endpoint(Endpoint::from_domain(&host_lc, port))
|
|
.src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port()))
|
|
.actor_process(
|
|
Process::from_bypass(&binary_str, &pid_str, &ancestors_str)
|
|
.with_cmd_line(&cmdline_str),
|
|
)
|
|
.firewall_rule(policy_str, "ssrf")
|
|
.message(format!(
|
|
"FORWARD blocked: internal IP without allowed_ips for {host_lc}:{port}"
|
|
))
|
|
.status_detail(&reason)
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
emit_denial_simple(
|
|
denial_tx,
|
|
&host_lc,
|
|
port,
|
|
&binary_str,
|
|
&decision,
|
|
&reason,
|
|
"ssrf",
|
|
);
|
|
respond(
|
|
client,
|
|
&build_json_error_response(
|
|
403,
|
|
"Forbidden",
|
|
"ssrf_denied",
|
|
&format!("{method} {host_lc}:{port} blocked: internal address"),
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
}
|
|
};
|
|
|
|
// 6. Connect upstream
|
|
let mut upstream = match TcpStream::connect(addrs.as_slice()).await {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
let event = HttpActivityBuilder::new(crate::ocsf_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(peer_addr.ip(), peer_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);
|
|
respond(
|
|
client,
|
|
&build_json_error_response(
|
|
502,
|
|
"Bad Gateway",
|
|
"upstream_unreachable",
|
|
&format!("connection to {host_lc}:{port} failed"),
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
// Log success
|
|
{
|
|
let event = HttpActivityBuilder::new(crate::ocsf_ctx())
|
|
.activity(ActivityId::Other)
|
|
.action(ActionId::Allowed)
|
|
.disposition(DispositionId::Allowed)
|
|
.severity(SeverityId::Informational)
|
|
.status(StatusId::Success)
|
|
.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(peer_addr.ip(), peer_addr.port()))
|
|
.actor_process(
|
|
Process::from_bypass(&binary_str, &pid_str, &ancestors_str)
|
|
.with_cmd_line(&cmdline_str),
|
|
)
|
|
.firewall_rule(policy_str, "opa")
|
|
.message(format!("FORWARD allowed {method} {host_lc}:{port}{path}"))
|
|
.build();
|
|
ocsf_emit!(event);
|
|
}
|
|
|
|
// 9. Rewrite request and forward to upstream
|
|
let rewritten = match rewrite_forward_request(buf, used, &path, 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"
|
|
);
|
|
respond(
|
|
client,
|
|
&build_json_error_response(
|
|
500,
|
|
"Internal Server Error",
|
|
"credential_injection_failed",
|
|
"unresolved credential placeholder in request",
|
|
),
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
};
|
|
upstream.write_all(&rewritten).await.into_diagnostic()?;
|
|
|
|
// 8. Relay remaining traffic bidirectionally (supports streaming)
|
|
let _ = tokio::io::copy_bidirectional(client, &mut upstream)
|
|
.await
|
|
.into_diagnostic()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_target(target: &str) -> Result<(String, u16)> {
|
|
let (host, port_str) = 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))
|
|
}
|
|
|
|
async fn respond(client: &mut TcpStream, bytes: &[u8]) -> Result<()> {
|
|
client.write_all(bytes).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()
|
|
}
|
|
|
|
/// 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)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
|
|
|
// -- 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_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());
|
|
}
|
|
|
|
#[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}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inference_interception_applies_router_header_allowlist() {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpListener;
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let upstream_addr = listener.local_addr().unwrap();
|
|
let upstream_task = tokio::spawn(async move {
|
|
use crate::l7::inference::{ParseResult, try_parse_http_request};
|
|
|
|
let (mut upstream, _) = listener.accept().await.unwrap();
|
|
let mut buf = Vec::new();
|
|
let mut chunk = [0u8; 4096];
|
|
|
|
loop {
|
|
let n = upstream.read(&mut chunk).await.unwrap();
|
|
assert!(n > 0, "upstream request closed before request completed");
|
|
buf.extend_from_slice(&chunk[..n]);
|
|
|
|
match try_parse_http_request(&buf) {
|
|
ParseResult::Complete(_, consumed) => {
|
|
upstream
|
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
|
|
.await
|
|
.unwrap();
|
|
return String::from_utf8_lossy(&buf[..consumed]).to_string();
|
|
}
|
|
ParseResult::Incomplete => continue,
|
|
ParseResult::Invalid(reason) => {
|
|
panic!("forwarded request should parse cleanly: {reason}");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let router = openshell_router::Router::new().unwrap();
|
|
let patterns = crate::l7::inference::default_patterns();
|
|
let ctx = InferenceContext::new(
|
|
patterns,
|
|
router,
|
|
vec![openshell_router::config::ResolvedRoute {
|
|
name: "inference.local".to_string(),
|
|
endpoint: format!("http://{upstream_addr}"),
|
|
model: "meta/llama-3.1-8b-instruct".to_string(),
|
|
api_key: "test-api-key".to_string(),
|
|
protocols: vec!["openai_chat_completions".to_string()],
|
|
auth: openshell_router::config::AuthHeader::Bearer,
|
|
default_headers: vec![],
|
|
passthrough_headers: vec![
|
|
"openai-organization".to_string(),
|
|
"x-model-id".to_string(),
|
|
],
|
|
timeout: openshell_router::config::DEFAULT_ROUTE_TIMEOUT,
|
|
}],
|
|
vec![],
|
|
);
|
|
|
|
let body = r#"{"model":"ignored","messages":[{"role":"user","content":"hi"}]}"#;
|
|
let request = format!(
|
|
"POST /v1/chat/completions HTTP/1.1\r\n\
|
|
Host: inference.local\r\n\
|
|
Content-Type: application/json\r\n\
|
|
OpenAI-Organization: org_123\r\n\
|
|
Authorization: Bearer client-key\r\n\
|
|
Cookie: session=abc\r\n\
|
|
Content-Length: {}\r\n\r\n{}",
|
|
body.len(),
|
|
body,
|
|
);
|
|
|
|
let (client, mut server) = tokio::io::duplex(65536);
|
|
let (mut client_read, mut client_write) = tokio::io::split(client);
|
|
|
|
let server_task =
|
|
tokio::spawn(async move { process_inference_keepalive(&mut server, &ctx, 443).await });
|
|
|
|
client_write.write_all(request.as_bytes()).await.unwrap();
|
|
client_write.shutdown().await.unwrap();
|
|
|
|
let mut response = Vec::new();
|
|
client_read.read_to_end(&mut response).await.unwrap();
|
|
let response_text = String::from_utf8_lossy(&response);
|
|
assert!(response_text.starts_with("HTTP/1.1 200"));
|
|
|
|
let outcome = server_task.await.unwrap().unwrap();
|
|
assert!(
|
|
matches!(outcome, InferenceOutcome::Routed),
|
|
"expected Routed outcome, got: {outcome:?}"
|
|
);
|
|
|
|
let forwarded = upstream_task.await.unwrap();
|
|
let forwarded_lc = forwarded.to_ascii_lowercase();
|
|
assert!(forwarded_lc.contains("openai-organization: org_123"));
|
|
assert!(forwarded_lc.contains("authorization: bearer test-api-key"));
|
|
assert!(!forwarded_lc.contains("authorization: bearer client-key"));
|
|
assert!(!forwarded_lc.contains("cookie:"));
|
|
}
|
|
|
|
// -- router_error_to_http --
|
|
|
|
#[test]
|
|
fn router_error_route_not_found_maps_to_400() {
|
|
let err = openshell_router::RouterError::RouteNotFound("local".into());
|
|
let (status, msg) = router_error_to_http(&err);
|
|
assert_eq!(status, 400);
|
|
assert_eq!(msg, "no inference route configured");
|
|
// SEC-008: must NOT leak the route hint to sandboxed code
|
|
assert!(!msg.contains("local"));
|
|
}
|
|
|
|
#[test]
|
|
fn router_error_no_compatible_route_maps_to_400() {
|
|
let err = openshell_router::RouterError::NoCompatibleRoute("anthropic_messages".into());
|
|
let (status, msg) = router_error_to_http(&err);
|
|
assert_eq!(status, 400);
|
|
assert_eq!(msg, "no compatible inference route available");
|
|
// SEC-008: must NOT leak the protocol name to sandboxed code
|
|
assert!(!msg.contains("anthropic_messages"));
|
|
}
|
|
|
|
#[test]
|
|
fn router_error_unauthorized_maps_to_401() {
|
|
let err =
|
|
openshell_router::RouterError::Unauthorized("bad token from 10.0.0.5:8080".into());
|
|
let (status, msg) = router_error_to_http(&err);
|
|
assert_eq!(status, 401);
|
|
assert_eq!(msg, "unauthorized");
|
|
// SEC-008: must NOT leak upstream details to sandboxed code
|
|
assert!(!msg.contains("10.0.0.5"));
|
|
}
|
|
|
|
#[test]
|
|
fn router_error_upstream_unavailable_maps_to_503() {
|
|
let err = openshell_router::RouterError::UpstreamUnavailable(
|
|
"connection refused to 10.0.0.5:8080".into(),
|
|
);
|
|
let (status, msg) = router_error_to_http(&err);
|
|
assert_eq!(status, 503);
|
|
assert_eq!(msg, "inference service unavailable");
|
|
// SEC-008: must NOT leak upstream address to sandboxed code
|
|
assert!(!msg.contains("10.0.0.5"));
|
|
}
|
|
|
|
#[test]
|
|
fn router_error_upstream_protocol_maps_to_502() {
|
|
let err = openshell_router::RouterError::UpstreamProtocol(
|
|
"TLS handshake failed for nim.internal.svc:443".into(),
|
|
);
|
|
let (status, msg) = router_error_to_http(&err);
|
|
assert_eq!(status, 502);
|
|
assert_eq!(msg, "inference service error");
|
|
// SEC-008: must NOT leak internal hostnames to sandboxed code
|
|
assert!(!msg.contains("nim.internal"));
|
|
}
|
|
|
|
#[test]
|
|
fn router_error_internal_maps_to_502() {
|
|
let err = openshell_router::RouterError::Internal(
|
|
"failed to read /etc/openshell/routes.json".into(),
|
|
);
|
|
let (status, msg) = router_error_to_http(&err);
|
|
assert_eq!(status, 502);
|
|
assert_eq!(msg, "inference service error");
|
|
// SEC-008: must NOT leak file paths to sandboxed code
|
|
assert!(!msg.contains("/etc/openshell"));
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_response_headers_strips_hop_by_hop() {
|
|
let headers = vec![
|
|
("transfer-encoding".to_string(), "chunked".to_string()),
|
|
("content-length".to_string(), "128".to_string()),
|
|
("connection".to_string(), "keep-alive".to_string()),
|
|
("content-type".to_string(), "text/event-stream".to_string()),
|
|
("cache-control".to_string(), "no-cache".to_string()),
|
|
];
|
|
|
|
let kept = sanitize_inference_response_headers(headers);
|
|
|
|
assert!(
|
|
kept.iter()
|
|
.all(|(k, _)| !k.eq_ignore_ascii_case("transfer-encoding")),
|
|
"transfer-encoding should be stripped"
|
|
);
|
|
assert!(
|
|
kept.iter()
|
|
.all(|(k, _)| !k.eq_ignore_ascii_case("content-length")),
|
|
"content-length should be stripped"
|
|
);
|
|
assert!(
|
|
kept.iter()
|
|
.all(|(k, _)| !k.eq_ignore_ascii_case("connection")),
|
|
"connection should be stripped"
|
|
);
|
|
assert!(
|
|
kept.iter()
|
|
.any(|(k, _)| k.eq_ignore_ascii_case("content-type")),
|
|
"content-type should be preserved"
|
|
);
|
|
assert!(
|
|
kept.iter()
|
|
.any(|(k, _)| k.eq_ignore_ascii_case("cache-control")),
|
|
"cache-control should be preserved"
|
|
);
|
|
}
|
|
|
|
// -- 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_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_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());
|
|
}
|
|
|
|
// --- rewrite_forward_request tests ---
|
|
|
|
#[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", 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 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", 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", 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_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", 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", 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, 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_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", 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"));
|
|
}
|
|
|
|
// --- 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}"
|
|
);
|
|
}
|
|
|
|
// -- 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());
|
|
}
|
|
|
|
/// Regression test: exercises the actual keep-alive interception loop to
|
|
/// verify that a non-inference request is denied even after a previous
|
|
/// inference request was successfully routed on the same connection.
|
|
///
|
|
/// Before the fix, `handle_inference_interception` used
|
|
/// `else if !routed_any` which silently dropped denials once `routed_any`
|
|
/// was true, allowing non-inference HTTP requests to piggyback on a
|
|
/// keep-alive connection that had previously handled inference traffic.
|
|
/// Regression test: exercises the actual keep-alive interception loop to
|
|
/// verify that a non-inference request is denied even after a previous
|
|
/// inference request was successfully routed on the same connection.
|
|
///
|
|
/// The server runs in a spawned task with empty routes (the inference
|
|
/// request gets a 503 "not configured" but is still recognized as
|
|
/// inference and returns Ok(true)). The client sends the inference
|
|
/// request, reads the 503 response, then sends a non-inference request
|
|
/// on the same connection. The server must return Denied.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_keepalive_denies_non_inference_after_routed() {
|
|
use openshell_router::Router;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
let router = Router::new().unwrap();
|
|
let patterns = crate::l7::inference::default_patterns();
|
|
// Empty routes: inference request gets 503 but returns Ok(true).
|
|
let ctx = InferenceContext::new(patterns, router, vec![], vec![]);
|
|
|
|
let body = r#"{"model":"test","messages":[{"role":"user","content":"hi"}]}"#;
|
|
let inference_req = format!(
|
|
"POST /v1/chat/completions HTTP/1.1\r\n\
|
|
Host: inference.local\r\n\
|
|
Content-Type: application/json\r\n\
|
|
Content-Length: {}\r\n\r\n{}",
|
|
body.len(),
|
|
body,
|
|
);
|
|
let non_inference_req = "GET /admin/config HTTP/1.1\r\nHost: inference.local\r\n\r\n";
|
|
|
|
let (client, mut server) = tokio::io::duplex(65536);
|
|
let (mut client_read, mut client_write) = tokio::io::split(client);
|
|
|
|
// Spawn the server task so it runs concurrently.
|
|
let server_task =
|
|
tokio::spawn(async move { process_inference_keepalive(&mut server, &ctx, 443).await });
|
|
|
|
// Client: send inference request, read response, send non-inference.
|
|
client_write
|
|
.write_all(inference_req.as_bytes())
|
|
.await
|
|
.unwrap();
|
|
|
|
// Read the 503 response so the server loops back to read.
|
|
let mut buf = vec![0u8; 4096];
|
|
let _ = client_read.read(&mut buf).await.unwrap();
|
|
|
|
// Send non-inference request on the same keep-alive connection.
|
|
client_write
|
|
.write_all(non_inference_req.as_bytes())
|
|
.await
|
|
.unwrap();
|
|
drop(client_write);
|
|
|
|
// Drain remaining response bytes.
|
|
tokio::spawn(async move {
|
|
let mut buf = vec![0u8; 4096];
|
|
loop {
|
|
match client_read.read(&mut buf).await {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(_) => continue,
|
|
}
|
|
}
|
|
});
|
|
|
|
let outcome = server_task.await.unwrap().unwrap();
|
|
|
|
assert!(
|
|
matches!(outcome, InferenceOutcome::Denied { .. }),
|
|
"expected Denied after non-inference request on keep-alive, got: {outcome:?}"
|
|
);
|
|
}
|
|
|
|
// -- 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 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 that
|
|
/// motivated `binary_path()` stripping the kernel's `" (deleted)"`
|
|
/// suffix (PR #844).
|
|
///
|
|
/// Before the strip, the identity-resolution chain inside
|
|
/// `evaluate_opa_tcp` failed with `"Failed to stat
|
|
/// /opt/openshell/bin/openshell-sandbox (deleted)"` because
|
|
/// `BinaryIdentityCache::verify_or_cache()` tried to `metadata()` the
|
|
/// tainted path. That masked the real security signal: a live process
|
|
/// was now bound to a *different* binary on disk than the one that was
|
|
/// TOFU-cached. After the strip, `binary_path()` returns a path that
|
|
/// stats fine, the cache rehashes the new bytes, and the hash mismatch
|
|
/// surfaces as a `Binary integrity violation` error — the contract this
|
|
/// PR is trying to establish.
|
|
///
|
|
/// 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.
|
|
/// 5. Accept the connection on the listener side and capture the peer's
|
|
/// ephemeral port — 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:
|
|
/// - the error reason contains `"Binary integrity violation"` (the
|
|
/// cache detected the tampered on-disk bytes), and
|
|
/// - the error reason does NOT contain `"Failed to stat"` or
|
|
/// `"(deleted)"` (the old pre-strip failure mode).
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn resolve_process_identity_surfaces_binary_integrity_violation_on_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 listener_port = listener.local_addr().unwrap().port();
|
|
|
|
// 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 sleeps to keep it open. The
|
|
// `read -t` blocks on stdin so the shell stays resident.
|
|
let script = format!("exec 3<>/dev/tcp/127.0.0.1/{listener_port}; sleep 30 <&3");
|
|
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, capture the peer port.
|
|
listener.set_nonblocking(false).expect("blocking listener");
|
|
let (mut stream, peer_addr) = match listener.accept() {
|
|
Ok(pair) => pair,
|
|
Err(e) => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
panic!("failed to accept child connection: {e}");
|
|
}
|
|
};
|
|
let peer_port = peer_addr.port();
|
|
// 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: we want "Binary integrity violation", not
|
|
// "Failed to stat ... (deleted)".
|
|
let test_pid = std::process::id();
|
|
let result = resolve_process_identity(test_pid, peer_port, &cache);
|
|
|
|
// 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(_) => panic!(
|
|
"resolve_process_identity unexpectedly succeeded after hot-swap; \
|
|
the cache should have detected the tampered on-disk bytes"
|
|
),
|
|
Err(err) => {
|
|
assert!(
|
|
err.reason.contains("Binary integrity violation"),
|
|
"expected 'Binary integrity violation' error, got: {}",
|
|
err.reason
|
|
);
|
|
assert!(
|
|
!err.reason.contains("Failed to stat"),
|
|
"pre-PR-#844 failure mode leaked: {}",
|
|
err.reason
|
|
);
|
|
assert!(
|
|
!err.reason.contains("(deleted)"),
|
|
"resolved path still contains '(deleted)' suffix: {}",
|
|
err.reason
|
|
);
|
|
// The binary field should be populated — we did resolve a
|
|
// path before failing.
|
|
assert!(
|
|
err.binary.is_some(),
|
|
"expected resolved binary path on integrity failure"
|
|
);
|
|
if let Some(path) = &err.binary {
|
|
assert!(
|
|
!path.to_string_lossy().contains("(deleted)"),
|
|
"resolved binary path still tainted: {}",
|
|
path.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
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};
|
|
|
|
if !std::path::Path::new("/bin/sleep").exists() {
|
|
eprintln!("skipping: /bin/sleep not available");
|
|
return;
|
|
}
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
|
let listener_port = listener.local_addr().unwrap().port();
|
|
let stream = TcpStream::connect(("127.0.0.1", listener_port)).expect("connect");
|
|
let peer_port = stream.local_addr().unwrap().port();
|
|
let (_accepted, _) = listener.accept().expect("accept");
|
|
|
|
let fd = stream.as_raw_fd();
|
|
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();
|
|
let child_pid = unsafe { libc::fork() };
|
|
assert!(child_pid >= 0, "fork failed");
|
|
if child_pid == 0 {
|
|
unsafe {
|
|
libc::execl(
|
|
sleep_path.as_ptr(),
|
|
arg0.as_ptr(),
|
|
arg1.as_ptr(),
|
|
std::ptr::null::<libc::c_char>(),
|
|
);
|
|
libc::_exit(127);
|
|
}
|
|
}
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(2);
|
|
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 2s"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
|
|
let cache = BinaryIdentityCache::new();
|
|
let result = resolve_process_identity(std::process::id(), peer_port, &cache);
|
|
|
|
unsafe {
|
|
libc::kill(child_pid, libc::SIGKILL);
|
|
libc::waitpid(child_pid, std::ptr::null_mut(), 0);
|
|
}
|
|
|
|
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(&std::process::id().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
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|