use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, warn};
use crate::config::{BackendConfig, BackendValidationConfig, FrontendType, SsrfProtection};
use crate::error::{ProxyError, ProxyResult};
use crate::proxy::{AtomicMetrics, BackendConnector, BackendTransport, ProxyService};
use ipnetwork::IpNetwork;
mod security;
pub use security::{OriginAllowlist, build_cors_layer, origin_guard};
pub const MAX_REQUEST_SIZE: usize = 10 * 1024 * 1024;
pub const DEFAULT_TIMEOUT_MS: u64 = 30_000;
pub const MAX_TIMEOUT_MS: u64 = 300_000;
pub const ALLOWED_COMMANDS: &[&str] = &["python", "python3", "node", "deno", "uv", "npx", "bun"];
pub const DEFAULT_BIND_ADDRESS: &str = "127.0.0.1:3000";
#[derive(Debug)]
pub struct RuntimeProxyBuilder {
backend_config: Option<BackendConfig>,
frontend_type: Option<FrontendType>,
bind_address: Option<String>,
request_size_limit: usize,
timeout_ms: u64,
enable_metrics: bool,
validation_config: BackendValidationConfig,
allowed_origins: Vec<String>,
}
impl RuntimeProxyBuilder {
#[must_use]
pub fn new() -> Self {
Self {
backend_config: None,
frontend_type: None,
bind_address: Some(DEFAULT_BIND_ADDRESS.to_string()),
request_size_limit: MAX_REQUEST_SIZE,
timeout_ms: DEFAULT_TIMEOUT_MS,
enable_metrics: true,
validation_config: BackendValidationConfig::default(),
allowed_origins: Vec::new(),
}
}
#[must_use]
pub fn with_allowed_origins<I, S>(mut self, origins: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.allowed_origins = origins.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_stdio_backend(mut self, command: impl Into<String>, args: Vec<String>) -> Self {
self.backend_config = Some(BackendConfig::Stdio {
command: command.into(),
args,
working_dir: None,
});
self
}
#[must_use]
pub fn with_stdio_backend_and_dir(
mut self,
command: impl Into<String>,
args: Vec<String>,
working_dir: impl Into<String>,
) -> Self {
self.backend_config = Some(BackendConfig::Stdio {
command: command.into(),
args,
working_dir: Some(working_dir.into()),
});
self
}
#[must_use]
pub fn with_http_backend(mut self, url: impl Into<String>, auth_token: Option<String>) -> Self {
self.backend_config = Some(BackendConfig::Http {
url: url.into(),
endpoint_path: None,
auth_token,
});
self
}
#[must_use]
pub fn with_http_backend_path(
mut self,
url: impl Into<String>,
endpoint_path: impl Into<String>,
auth_token: Option<String>,
) -> Self {
self.backend_config = Some(BackendConfig::Http {
url: url.into(),
endpoint_path: Some(endpoint_path.into()),
auth_token,
});
self
}
#[must_use]
pub fn with_websocket_backend(mut self, url: impl Into<String>) -> Self {
self.backend_config = Some(BackendConfig::WebSocket { url: url.into() });
self
}
#[must_use]
pub fn with_tcp_backend(mut self, host: impl Into<String>, port: u16) -> Self {
self.backend_config = Some(BackendConfig::Tcp {
host: host.into(),
port,
});
self
}
#[cfg(unix)]
#[must_use]
pub fn with_unix_backend(mut self, path: impl Into<String>) -> Self {
self.backend_config = Some(BackendConfig::Unix { path: path.into() });
self
}
#[must_use]
pub fn with_http_frontend(mut self, bind: impl Into<String>) -> Self {
self.frontend_type = Some(FrontendType::Http);
self.bind_address = Some(bind.into());
self
}
#[must_use]
pub fn with_stdio_frontend(mut self) -> Self {
self.frontend_type = Some(FrontendType::Stdio);
self
}
#[must_use]
pub fn with_websocket_frontend(mut self, bind: impl Into<String>) -> Self {
self.frontend_type = Some(FrontendType::WebSocket);
self.bind_address = Some(bind.into());
self
}
#[must_use]
pub fn with_request_size_limit(mut self, limit: usize) -> Self {
self.request_size_limit = limit;
self
}
pub fn with_timeout(mut self, timeout_ms: u64) -> ProxyResult<Self> {
if timeout_ms > MAX_TIMEOUT_MS {
return Err(ProxyError::configuration_with_key(
format!("Timeout {timeout_ms}ms exceeds maximum {MAX_TIMEOUT_MS}ms"),
"timeout_ms",
));
}
self.timeout_ms = timeout_ms;
Ok(self)
}
#[must_use]
pub fn with_metrics(mut self, enable: bool) -> Self {
self.enable_metrics = enable;
self
}
#[must_use]
pub fn with_backend_validation(mut self, config: BackendValidationConfig) -> Self {
self.validation_config = config;
self
}
pub async fn build(self) -> ProxyResult<RuntimeProxy> {
let backend_config = self
.backend_config
.as_ref()
.ok_or_else(|| ProxyError::configuration("Backend configuration is required"))?;
let frontend_type = self
.frontend_type
.ok_or_else(|| ProxyError::configuration("Frontend type is required"))?;
Self::validate_command(backend_config)?;
Self::validate_url(backend_config, &self.validation_config).await?;
Self::validate_working_dir(backend_config)?;
let backend_config = self.backend_config.unwrap();
let transport = match &backend_config {
BackendConfig::Stdio {
command,
args,
working_dir,
} => BackendTransport::Stdio {
command: command.clone(),
args: args.clone(),
working_dir: working_dir.clone(),
},
BackendConfig::Http {
url,
endpoint_path,
auth_token,
} => BackendTransport::Http {
url: url.clone(),
endpoint_path: endpoint_path.clone(),
auth_token: auth_token.clone().map(secrecy::SecretString::from),
},
BackendConfig::Tcp { host, port } => BackendTransport::Tcp {
host: host.clone(),
port: *port,
},
#[cfg(unix)]
BackendConfig::Unix { path } => BackendTransport::Unix { path: path.clone() },
BackendConfig::WebSocket { url } => BackendTransport::WebSocket { url: url.clone() },
};
let connector_config = crate::proxy::backend::BackendConfig {
transport,
client_name: "turbomcp-proxy".to_string(),
client_version: crate::VERSION.to_string(),
};
let backend = BackendConnector::new(connector_config).await?;
let metrics = if self.enable_metrics {
Some(Arc::new(AtomicMetrics::new()))
} else {
None
};
Ok(RuntimeProxy {
backend,
frontend_type,
bind_address: self.bind_address,
request_size_limit: self.request_size_limit,
timeout_ms: self.timeout_ms,
metrics,
origin_allowlist: OriginAllowlist::new(self.allowed_origins),
})
}
fn validate_command(config: &BackendConfig) -> ProxyResult<()> {
if let BackendConfig::Stdio { command, .. } = config
&& !ALLOWED_COMMANDS.contains(&command.as_str())
{
return Err(ProxyError::configuration_with_key(
format!("Command '{command}' not in allowlist. Allowed: {ALLOWED_COMMANDS:#?}"),
"command",
));
}
Ok(())
}
async fn validate_url(
config: &BackendConfig,
validation_config: &BackendValidationConfig,
) -> ProxyResult<()> {
let (BackendConfig::Http { url: url_str, .. } | BackendConfig::WebSocket { url: url_str }) =
config
else {
return Ok(()); };
let parsed = url::Url::parse(url_str)
.map_err(|e| ProxyError::configuration_with_key(format!("Invalid URL: {e}"), "url"))?;
if !validation_config
.allowed_schemes
.contains(&parsed.scheme().to_string())
{
return Err(ProxyError::configuration_with_key(
format!(
"Scheme '{}' not allowed. Allowed schemes: {}",
parsed.scheme(),
validation_config.allowed_schemes.join(", ")
),
"url",
));
}
if parsed.scheme() == "http" || parsed.scheme() == "ws" {
let host = parsed.host_str().unwrap_or("");
if !is_localhost(host) {
let secure_scheme = if parsed.scheme() == "http" {
"https"
} else {
"wss"
};
return Err(ProxyError::configuration_with_key(
format!(
"Secure protocol required for non-localhost URLs. Use {} instead of {}",
secure_scheme,
parsed.scheme()
),
"url",
));
}
}
if let Some(host) = parsed.host_str() {
let port = parsed.port_or_known_default().ok_or_else(|| {
ProxyError::configuration_with_key(
format!(
"URL is missing a usable port for scheme '{}'",
parsed.scheme()
),
"url",
)
})?;
Self::validate_host(host, port, validation_config).await?;
}
Ok(())
}
async fn validate_host(
host: &str,
port: u16,
validation_config: &BackendValidationConfig,
) -> ProxyResult<()> {
if validation_config.blocked_hosts.contains(&host.to_string()) {
return Err(ProxyError::configuration_with_key(
format!("Host '{host}' is blocked by custom blocklist"),
"url",
));
}
match &validation_config.ssrf_protection {
SsrfProtection::Disabled => {
warn!("SSRF protection disabled for host: {}", host);
Ok(())
}
SsrfProtection::Strict => Self::validate_host_strict(host, port).await,
SsrfProtection::Balanced {
allowed_private_networks,
} => Self::validate_host_balanced(host, port, allowed_private_networks).await,
}
}
async fn validate_host_strict(host: &str, port: u16) -> ProxyResult<()> {
Self::validate_host_addresses(host, port, |ip| match classify_ip(ip) {
IpClass::Loopback | IpClass::Public => Ok(()),
IpClass::Metadata => Err(metadata_blocked(ip)),
IpClass::Private => Err(ProxyError::configuration_with_key(
format!(
"Private {} address blocked: {ip}. \
For internal proxies, configure:\n \
SsrfProtection::Balanced {{ \
allowed_private_networks: vec![IpNetwork::from_str(\"10.0.0.0/8\")?] }}",
match canonical_ip(ip) {
IpAddr::V4(_) => "IPv4",
IpAddr::V6(_) => "IPv6",
}
),
"url",
)),
})
.await
}
async fn validate_host_balanced(
host: &str,
port: u16,
allowed_networks: &[IpNetwork],
) -> ProxyResult<()> {
Self::validate_host_addresses(host, port, |ip| {
Self::validate_ip_balanced(ip, allowed_networks)
})
.await
}
fn validate_ip_balanced(ip: IpAddr, allowed_networks: &[IpNetwork]) -> ProxyResult<()> {
match classify_ip(ip) {
IpClass::Loopback | IpClass::Public => Ok(()),
IpClass::Metadata => Err(metadata_blocked(ip)),
IpClass::Private => {
let canonical = canonical_ip(ip);
if allowed_networks
.iter()
.any(|net| net.contains(ip) || net.contains(canonical))
{
debug!("Private IP {} allowed by configured network", ip);
Ok(())
} else {
Err(ProxyError::configuration_with_key(
format!(
"Private IP {ip} not in allowed networks. Allowed networks: {allowed_networks:?}"
),
"url",
))
}
}
}
}
async fn validate_host_addresses<F>(
host: &str,
port: u16,
mut validate_ip: F,
) -> ProxyResult<()>
where
F: FnMut(IpAddr) -> ProxyResult<()>,
{
if matches!(host, "metadata.google.internal" | "metadata") {
return Err(ProxyError::configuration_with_key(
format!("Cloud metadata endpoint blocked: {host}"),
"url",
));
}
let literal = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = literal.parse::<IpAddr>() {
return validate_ip(ip);
}
let resolved = tokio::net::lookup_host((host, port)).await.map_err(|e| {
ProxyError::configuration_with_key(
format!("Failed to resolve host '{host}': {e}"),
"url",
)
})?;
let mut saw_ip = false;
for addr in resolved {
saw_ip = true;
validate_ip(addr.ip())?;
}
if !saw_ip {
return Err(ProxyError::configuration_with_key(
format!("Host '{host}' resolved to no addresses"),
"url",
));
}
Ok(())
}
fn validate_working_dir(config: &BackendConfig) -> ProxyResult<()> {
if let BackendConfig::Stdio {
working_dir: Some(wd),
..
} = config
{
let path = PathBuf::from(wd);
if !path.exists() {
return Err(ProxyError::configuration_with_key(
format!("Working directory does not exist: {wd}"),
"working_dir",
));
}
let canonical = path.canonicalize().map_err(|e| {
ProxyError::configuration_with_key(
format!("Failed to canonicalize working directory: {e}"),
"working_dir",
)
})?;
if !canonical.is_dir() {
return Err(ProxyError::configuration_with_key(
format!("Working directory is not a directory: {wd}"),
"working_dir",
));
}
}
Ok(())
}
}
impl Default for RuntimeProxyBuilder {
fn default() -> Self {
Self::new()
}
}
fn is_localhost(host: &str) -> bool {
let normalized = host
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host);
matches!(normalized, "localhost" | "127.0.0.1" | "::1")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IpClass {
Loopback,
Metadata,
Private,
Public,
}
fn canonical_ip(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return IpAddr::V4(v4);
}
let segments = v6.segments();
if segments[..6] == [0x64, 0xff9b, 0, 0, 0, 0] {
let [a, b] = segments[6].to_be_bytes();
let [c, d] = segments[7].to_be_bytes();
return IpAddr::V4(Ipv4Addr::new(a, b, c, d));
}
ip
}
IpAddr::V4(_) => ip,
}
}
fn classify_ip(ip: IpAddr) -> IpClass {
match canonical_ip(ip) {
IpAddr::V4(v4) => {
let octets = v4.octets();
if v4.is_loopback() {
IpClass::Loopback
} else if v4 == Ipv4Addr::new(169, 254, 169, 254)
|| v4 == Ipv4Addr::new(168, 63, 129, 16)
{
IpClass::Metadata
} else if v4.is_private()
|| v4.is_link_local()
|| (octets[0] == 100 && octets[1] & 0xc0 == 64)
|| octets[0] == 0
|| v4.is_broadcast()
{
IpClass::Private
} else {
IpClass::Public
}
}
IpAddr::V6(v6) => {
if v6.is_loopback() {
IpClass::Loopback
} else if v6 == Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x254) {
IpClass::Metadata
} else if v6.is_unspecified()
|| v6.segments()[0] & 0xfe00 == 0xfc00
|| v6.segments()[0] & 0xffc0 == 0xfe80
{
IpClass::Private
} else {
IpClass::Public
}
}
}
}
fn metadata_blocked(ip: IpAddr) -> ProxyError {
ProxyError::configuration_with_key(
format!(
"Cloud metadata endpoint blocked: {ip}. \
For internal proxies, use SsrfProtection::Balanced with allowed networks."
),
"url",
)
}
#[derive(Debug)]
pub struct RuntimeProxy {
backend: BackendConnector,
frontend_type: FrontendType,
bind_address: Option<String>,
request_size_limit: usize,
timeout_ms: u64,
metrics: Option<Arc<AtomicMetrics>>,
origin_allowlist: OriginAllowlist,
}
impl RuntimeProxy {
pub async fn run(&mut self) -> ProxyResult<()> {
match self.frontend_type {
FrontendType::Http => {
let bind = self
.bind_address
.as_ref()
.ok_or_else(|| {
ProxyError::configuration("Bind address required for HTTP frontend")
})?
.clone();
self.run_http(&bind).await
}
FrontendType::Stdio => self.run_stdio().await,
FrontendType::WebSocket => {
let bind = self
.bind_address
.as_ref()
.ok_or_else(|| {
ProxyError::configuration("Bind address required for WebSocket frontend")
})?
.clone();
self.run_websocket(&bind).await
}
}
}
#[must_use]
pub fn backend(&self) -> &BackendConnector {
&self.backend
}
#[must_use]
pub fn metrics(&self) -> Option<crate::proxy::metrics::ProxyMetrics> {
self.metrics.as_ref().map(|m| m.snapshot())
}
async fn run_http(&mut self, bind: &str) -> ProxyResult<()> {
use axum::{http::StatusCode, middleware};
use std::time::Duration;
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::timeout::TimeoutLayer;
use turbomcp_server::McpServerExt;
debug!("Starting HTTP frontend on {}", bind);
let spec = self.backend.introspect().await?;
debug!(
"Backend introspection complete: {} tools, {} resources, {} prompts",
spec.tools.len(),
spec.resources.len(),
spec.prompts.len()
);
let service = ProxyService::new(self.backend.clone(), spec);
let allowlist = self.origin_allowlist.clone();
let server_config = turbomcp_server::ServerConfig::builder()
.max_message_size(self.request_size_limit)
.allow_any_origin(true)
.build();
let mut app = service
.builder()
.with_config(server_config)
.into_axum_router()
.layer(middleware::from_fn_with_state(
allowlist.clone(),
origin_guard,
))
.layer(RequestBodyLimitLayer::new(self.request_size_limit))
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_millis(self.timeout_ms),
));
if let Some(cors) = build_cors_layer(&allowlist) {
app = app.layer(cors);
}
let listener = tokio::net::TcpListener::bind(bind).await.map_err(|e| {
ProxyError::backend_connection(format!("Failed to bind to {bind}: {e}"))
})?;
debug!("HTTP frontend listening on {}", bind);
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.map_err(|e| ProxyError::backend(format!("Axum serve error: {e}")))?;
Ok(())
}
async fn run_websocket(&mut self, bind: &str) -> ProxyResult<()> {
debug!("Starting WebSocket frontend on {}", bind);
let spec = self.backend.introspect().await?;
debug!(
"Backend introspection complete: {} tools, {} resources, {} prompts",
spec.tools.len(),
spec.resources.len(),
spec.prompts.len()
);
let service = ProxyService::new(self.backend.clone(), spec);
let mut config_builder = turbomcp_server::ServerConfig::builder()
.max_message_size(self.request_size_limit)
.allow_localhost_origins(false);
for origin in self
.origin_allowlist
.header_values()
.filter_map(|origin| origin.to_str().ok())
{
config_builder = config_builder.allow_origin(origin.to_owned());
}
let server_config = config_builder.build();
debug!("WebSocket frontend listening on {}", bind);
turbomcp_server::transport::websocket::run_with_config(&service, bind, &server_config)
.await
.map_err(|e| ProxyError::backend(format!("WebSocket server error: {e}")))?;
Ok(())
}
async fn run_stdio(&mut self) -> ProxyResult<()> {
debug!("Starting STDIO frontend");
let spec = self.backend.introspect().await?;
let service = ProxyService::new(self.backend.clone(), spec)
.with_request_timeout(Duration::from_millis(self.timeout_ms));
let server_config = turbomcp_server::ServerConfig::builder()
.max_message_size(self.request_size_limit)
.build();
crate::proxy::StdioFrontend::new(service, server_config)
.run()
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_creation() {
let builder = RuntimeProxyBuilder::new();
assert_eq!(builder.request_size_limit, MAX_REQUEST_SIZE);
assert_eq!(builder.timeout_ms, DEFAULT_TIMEOUT_MS);
assert!(builder.enable_metrics);
}
#[test]
fn test_builder_with_stdio_backend() {
let builder =
RuntimeProxyBuilder::new().with_stdio_backend("python", vec!["server.py".to_string()]);
assert!(matches!(
builder.backend_config,
Some(BackendConfig::Stdio { .. })
));
}
#[test]
fn test_builder_with_http_backend() {
let builder = RuntimeProxyBuilder::new().with_http_backend("https://api.example.com", None);
assert!(matches!(
builder.backend_config,
Some(BackendConfig::Http { .. })
));
}
#[test]
fn test_builder_with_tcp_backend() {
let builder = RuntimeProxyBuilder::new().with_tcp_backend("localhost", 5000);
assert!(matches!(
builder.backend_config,
Some(BackendConfig::Tcp {
host: _,
port: 5000
})
));
}
#[cfg(unix)]
#[test]
fn test_builder_with_unix_backend() {
let builder = RuntimeProxyBuilder::new().with_unix_backend("/tmp/mcp.sock");
assert!(matches!(
builder.backend_config,
Some(BackendConfig::Unix { path: _ })
));
}
#[test]
fn test_builder_with_frontends() {
let http_builder = RuntimeProxyBuilder::new().with_http_frontend("0.0.0.0:3000");
assert_eq!(http_builder.frontend_type, Some(FrontendType::Http));
let stdio_builder = RuntimeProxyBuilder::new().with_stdio_frontend();
assert_eq!(stdio_builder.frontend_type, Some(FrontendType::Stdio));
}
#[test]
fn test_builder_with_timeout() {
let result = RuntimeProxyBuilder::new().with_timeout(60_000);
assert!(result.is_ok());
assert_eq!(result.unwrap().timeout_ms, 60_000);
}
#[test]
fn test_builder_timeout_exceeds_max() {
let result = RuntimeProxyBuilder::new().with_timeout(MAX_TIMEOUT_MS + 1);
assert!(result.is_err());
match result {
Err(ProxyError::Configuration { key, .. }) => {
assert_eq!(key, Some("timeout_ms".to_string()));
}
_ => panic!("Expected Configuration error"),
}
}
#[test]
fn test_validate_command_allowed() {
let config = BackendConfig::Stdio {
command: "python".to_string(),
args: vec![],
working_dir: None,
};
assert!(RuntimeProxyBuilder::validate_command(&config).is_ok());
}
#[test]
fn test_validate_command_not_allowed() {
let config = BackendConfig::Stdio {
command: "malicious_command".to_string(),
args: vec![],
working_dir: None,
};
let result = RuntimeProxyBuilder::validate_command(&config);
assert!(result.is_err());
match result {
Err(ProxyError::Configuration { message, key }) => {
assert!(message.contains("not in allowlist"));
assert_eq!(key, Some("command".to_string()));
}
_ => panic!("Expected Configuration error"),
}
}
#[tokio::test]
async fn test_validate_url_https_required() {
let config = BackendConfig::Http {
url: "http://api.example.com".to_string(),
endpoint_path: None,
auth_token: None,
};
let validation_config = BackendValidationConfig::default();
let result = RuntimeProxyBuilder::validate_url(&config, &validation_config).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_validate_url_localhost_http_allowed() {
let config = BackendConfig::Http {
url: "http://localhost:3000".to_string(),
endpoint_path: None,
auth_token: None,
};
let validation_config = BackendValidationConfig::default();
assert!(
RuntimeProxyBuilder::validate_url(&config, &validation_config)
.await
.is_ok()
);
}
#[tokio::test]
async fn test_validate_url_https_allowed() {
let config = BackendConfig::Http {
url: "https://8.8.8.8".to_string(),
endpoint_path: None,
auth_token: None,
};
let validation_config = BackendValidationConfig::default();
assert!(
RuntimeProxyBuilder::validate_url(&config, &validation_config)
.await
.is_ok()
);
}
#[tokio::test]
async fn test_validate_host_blocks_metadata() {
let validation_config = BackendValidationConfig::default();
assert!(
RuntimeProxyBuilder::validate_host("169.254.169.254", 443, &validation_config)
.await
.is_err()
);
assert!(
RuntimeProxyBuilder::validate_host("metadata.google.internal", 443, &validation_config)
.await
.is_err()
);
}
#[tokio::test]
async fn test_validate_host_blocks_private_ips() {
let validation_config = BackendValidationConfig::default();
assert!(
RuntimeProxyBuilder::validate_host("192.168.1.1", 443, &validation_config)
.await
.is_err()
);
assert!(
RuntimeProxyBuilder::validate_host("10.0.0.1", 443, &validation_config)
.await
.is_err()
);
assert!(
RuntimeProxyBuilder::validate_host("172.16.0.1", 443, &validation_config)
.await
.is_err()
);
}
#[tokio::test]
async fn test_validate_host_allows_loopback() {
let validation_config = BackendValidationConfig::default();
assert!(
RuntimeProxyBuilder::validate_host("127.0.0.1", 443, &validation_config)
.await
.is_ok()
);
}
#[tokio::test]
async fn strict_mode_refuses_every_spelling_of_an_internal_address() {
let validation_config = BackendValidationConfig::default();
for host in [
"[::ffff:10.0.0.1]",
"[::ffff:169.254.169.254]",
"[64:ff9b::a9fe:a9fe]",
"[64:ff9b::a00:1]",
"100.64.0.1",
"100.127.255.254",
"0.0.0.0",
"0.1.2.3",
"[::]",
"[fd00:ec2::254]",
] {
assert!(
RuntimeProxyBuilder::validate_host(host, 443, &validation_config)
.await
.is_err(),
"{host} must be refused"
);
}
for host in ["8.8.8.8", "100.128.0.1", "[2606:4700::1111]", "[::1]"] {
assert!(
RuntimeProxyBuilder::validate_host(host, 443, &validation_config)
.await
.is_ok(),
"{host} must be allowed"
);
}
}
#[tokio::test]
async fn balanced_mode_compares_addresses_not_spellings() {
let validation_config = BackendValidationConfig {
ssrf_protection: SsrfProtection::Balanced {
allowed_private_networks: vec![
"10.0.0.0/8".parse().unwrap(),
"169.254.0.0/16".parse().unwrap(),
],
},
..Default::default()
};
assert!(
RuntimeProxyBuilder::validate_host("[::ffff:10.1.2.3]", 443, &validation_config)
.await
.is_ok()
);
for metadata in [
"169.254.169.254",
"[::ffff:a9fe:a9fe]",
"[64:ff9b::a9fe:a9fe]",
] {
assert!(
RuntimeProxyBuilder::validate_host(metadata, 443, &validation_config)
.await
.is_err(),
"{metadata} is metadata and must stay blocked"
);
}
assert!(
RuntimeProxyBuilder::validate_host("100.64.0.1", 443, &validation_config)
.await
.is_err()
);
}
#[test]
fn test_is_localhost() {
assert!(is_localhost("localhost"));
assert!(is_localhost("127.0.0.1"));
assert!(is_localhost("::1"));
assert!(is_localhost("[::1]"));
assert!(!is_localhost("example.com"));
assert!(!is_localhost("192.168.1.1"));
}
#[tokio::test]
async fn test_builder_requires_backend() {
let result = RuntimeProxyBuilder::new()
.with_http_frontend("127.0.0.1:3000")
.build()
.await;
assert!(result.is_err());
match result {
Err(ProxyError::Configuration { message, .. }) => {
assert!(message.contains("Backend configuration is required"));
}
_ => panic!("Expected Configuration error"),
}
}
#[tokio::test]
async fn test_builder_requires_frontend() {
let result = RuntimeProxyBuilder::new()
.with_stdio_backend("python", vec!["server.py".to_string()])
.build()
.await;
assert!(result.is_err());
match result {
Err(ProxyError::Configuration { message, .. }) => {
assert!(message.contains("Frontend type is required"));
}
_ => panic!("Expected Configuration error"),
}
}
#[test]
fn test_validate_working_dir_nonexistent() {
let config = BackendConfig::Stdio {
command: "python".to_string(),
args: vec![],
working_dir: Some("/nonexistent/path/that/does/not/exist".to_string()),
};
let result = RuntimeProxyBuilder::validate_working_dir(&config);
assert!(result.is_err());
}
#[test]
fn test_constants() {
assert_eq!(MAX_REQUEST_SIZE, 10 * 1024 * 1024);
assert_eq!(DEFAULT_TIMEOUT_MS, 30_000);
assert_eq!(MAX_TIMEOUT_MS, 300_000);
assert_eq!(DEFAULT_BIND_ADDRESS, "127.0.0.1:3000");
assert!(ALLOWED_COMMANDS.contains(&"python"));
assert!(ALLOWED_COMMANDS.contains(&"node"));
}
}