diff --git a/src/config/embeddings.rs b/src/config/embeddings.rs index 813cbf7b..4f99dab4 100644 --- a/src/config/embeddings.rs +++ b/src/config/embeddings.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use secrecy::{ExposeSecret, SecretString}; -use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env}; +use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env, validate_base_url}; use crate::error::ConfigError; use crate::llm::SessionManager; use crate::settings::Settings; @@ -90,6 +90,12 @@ impl EmbeddingsConfig { let openai_base_url = optional_env("EMBEDDING_BASE_URL")?; + // Validate base URLs to prevent SSRF attacks (#1103). + validate_base_url(&ollama_base_url, "OLLAMA_BASE_URL")?; + if let Some(ref url) = openai_base_url { + validate_base_url(url, "EMBEDDING_BASE_URL")?; + } + let cache_size = parse_optional_env("EMBEDDING_CACHE_SIZE", DEFAULT_EMBEDDING_CACHE_SIZE)?; if cache_size == 0 { diff --git a/src/config/helpers.rs b/src/config/helpers.rs index ce6ce092..dc40fc9f 100644 --- a/src/config/helpers.rs +++ b/src/config/helpers.rs @@ -176,6 +176,151 @@ pub(crate) fn parse_string_env( Ok(optional_env(key)?.unwrap_or_else(|| default.into())) } +/// Validate a user-configurable base URL to prevent SSRF attacks (#1103). +/// +/// Rejects: +/// - Non-HTTP(S) schemes (file://, ftp://, etc.) +/// - HTTPS URLs pointing at private/loopback/link-local IPs +/// - HTTP URLs pointing at anything other than localhost/127.0.0.1/::1 +/// +/// This is intended for config-time validation of base URLs like +/// `OLLAMA_BASE_URL`, `EMBEDDING_BASE_URL`, `NEARAI_BASE_URL`, etc. +pub(crate) fn validate_base_url(url: &str, field_name: &str) -> Result<(), ConfigError> { + use std::net::{IpAddr, Ipv4Addr}; + + let parsed = reqwest::Url::parse(url).map_err(|e| ConfigError::InvalidValue { + key: field_name.to_string(), + message: format!("invalid URL '{}': {}", url, e), + })?; + + let scheme = parsed.scheme(); + if scheme != "http" && scheme != "https" { + return Err(ConfigError::InvalidValue { + key: field_name.to_string(), + message: format!("only http/https URLs are allowed, got '{}'", scheme), + }); + } + + let host = parsed.host_str().ok_or_else(|| ConfigError::InvalidValue { + key: field_name.to_string(), + message: "URL is missing a host".to_string(), + })?; + + let host_lower = host.to_lowercase(); + + // For HTTP (non-TLS), only allow localhost — remote HTTP endpoints + // risk credential leakage (e.g. NEAR AI bearer tokens sent over plaintext). + if scheme == "http" { + let is_localhost = host_lower == "localhost" + || host_lower == "127.0.0.1" + || host_lower == "::1" + || host_lower == "[::1]" + || host_lower.ends_with(".localhost"); + if !is_localhost { + return Err(ConfigError::InvalidValue { + key: field_name.to_string(), + message: format!( + "HTTP (non-TLS) is only allowed for localhost, got '{}'. \ + Use HTTPS for remote endpoints.", + host + ), + }); + } + return Ok(()); + } + + // Check whether an IP is in a blocked range (private, loopback, + // link-local, multicast, metadata, CGN, ULA). + let is_dangerous_ip = |ip: &IpAddr| -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_private() + || v4.is_loopback() + || v4.is_link_local() + || v4.is_multicast() + || v4.is_unspecified() + || *v4 == Ipv4Addr::new(169, 254, 169, 254) + || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) // CGN + } + IpAddr::V6(v6) => { + if let Some(v4) = v6.to_ipv4_mapped() { + v4.is_private() + || v4.is_loopback() + || v4.is_link_local() + || v4.is_multicast() + || v4.is_unspecified() + || v4 == Ipv4Addr::new(169, 254, 169, 254) + || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) // CGN + } else { + v6.is_loopback() + || v6.is_unspecified() + || (v6.octets()[0] & 0xfe) == 0xfc // ULA (fc00::/7) + || (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local (fe80::/10) + || v6.octets()[0] == 0xff // multicast (ff00::/8) + } + } + } + }; + + // For HTTPS, reject private/loopback/link-local/metadata IPs. + // Check both IP literals and resolved hostnames to prevent DNS-based SSRF. + if let Ok(ip) = host.parse::() { + if is_dangerous_ip(&ip) { + return Err(ConfigError::InvalidValue { + key: field_name.to_string(), + message: format!( + "URL points to a private/internal IP '{}'. \ + This is blocked to prevent SSRF attacks.", + ip + ), + }); + } + } else { + // Hostname — resolve and check all resulting IPs as defense-in-depth. + // NOTE: This does NOT fully prevent DNS rebinding attacks (the hostname + // could resolve to a different IP at request time). Full protection + // would require pinning the resolved IP in the HTTP client's connector. + // This validation catches the common case of misconfigured or malicious URLs. + // + // NOTE: `to_socket_addrs()` performs blocking DNS resolution. This is + // acceptable because `validate_base_url` runs at config-load time only, + // before the async runtime is fully driving I/O. If this ever moves to + // a hot path, wrap in `tokio::task::spawn_blocking` or use + // `tokio::net::lookup_host`. + use std::net::ToSocketAddrs; + let port = parsed.port().unwrap_or(443); + match (host, port).to_socket_addrs() { + Ok(addrs) => { + for addr in addrs { + if is_dangerous_ip(&addr.ip()) { + return Err(ConfigError::InvalidValue { + key: field_name.to_string(), + message: format!( + "hostname '{}' resolves to private/internal IP '{}'. \ + This is blocked to prevent SSRF attacks.", + host, + addr.ip() + ), + }); + } + } + } + Err(e) => { + return Err(ConfigError::InvalidValue { + key: field_name.to_string(), + message: format!( + "failed to resolve hostname '{}': {}. \ + Base URLs must be resolvable at config time.", + host, e + ), + }); + } + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -226,4 +371,122 @@ mod tests { // Now the runtime override is visible again assert_eq!(env_or_override(key), Some("override_value".to_string())); } + + // --- validate_base_url tests (regression for #1103) --- + + #[test] + fn validate_base_url_allows_https() { + // Use IP literals to avoid DNS resolution in sandboxed test environments. + assert!(validate_base_url("https://8.8.8.8", "TEST").is_ok()); + assert!(validate_base_url("https://8.8.8.8/v1", "TEST").is_ok()); + } + + #[test] + fn validate_base_url_allows_http_localhost() { + assert!(validate_base_url("http://localhost:11434", "TEST").is_ok()); + assert!(validate_base_url("http://127.0.0.1:11434", "TEST").is_ok()); + assert!(validate_base_url("http://[::1]:11434", "TEST").is_ok()); + } + + #[test] + fn validate_base_url_rejects_http_remote() { + assert!(validate_base_url("http://evil.example.com", "TEST").is_err()); + assert!(validate_base_url("http://192.168.1.1", "TEST").is_err()); + } + + #[test] + fn validate_base_url_rejects_non_http_schemes() { + assert!(validate_base_url("file:///etc/passwd", "TEST").is_err()); + assert!(validate_base_url("ftp://evil.com", "TEST").is_err()); + } + + #[test] + fn validate_base_url_rejects_cloud_metadata() { + assert!(validate_base_url("https://169.254.169.254", "TEST").is_err()); + } + + #[test] + fn validate_base_url_rejects_private_ips() { + assert!(validate_base_url("https://10.0.0.1", "TEST").is_err()); + assert!(validate_base_url("https://192.168.1.1", "TEST").is_err()); + assert!(validate_base_url("https://172.16.0.1", "TEST").is_err()); + } + + #[test] + fn validate_base_url_rejects_cgn_range() { + // Carrier-grade NAT: 100.64.0.0/10 + assert!(validate_base_url("https://100.64.0.1", "TEST").is_err()); + assert!(validate_base_url("https://100.127.255.254", "TEST").is_err()); + } + + #[test] + fn validate_base_url_rejects_ipv4_mapped_ipv6() { + // ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to private IP + assert!(validate_base_url("https://[::ffff:10.0.0.1]", "TEST").is_err()); + assert!(validate_base_url("https://[::ffff:169.254.169.254]", "TEST").is_err()); + } + + #[test] + fn validate_base_url_rejects_ula_ipv6() { + // fc00::/7 — unique local addresses + assert!(validate_base_url("https://[fc00::1]", "TEST").is_err()); + assert!(validate_base_url("https://[fd12:3456:789a::1]", "TEST").is_err()); + } + + #[test] + fn validate_base_url_handles_url_with_credentials() { + // URLs with embedded credentials — validate_base_url checks the host, + // not the credentials. Use IP literal to avoid DNS in sandboxed envs. + let result = validate_base_url("https://user:pass@8.8.8.8", "TEST"); + assert!(result.is_ok()); + } + + #[test] + fn validate_base_url_rejects_empty_and_invalid() { + assert!(validate_base_url("", "TEST").is_err()); + assert!(validate_base_url("not-a-url", "TEST").is_err()); + assert!(validate_base_url("://missing-scheme", "TEST").is_err()); + } + + #[test] + fn validate_base_url_rejects_unspecified_ipv4() { + assert!(validate_base_url("https://0.0.0.0", "TEST").is_err()); + } + + #[test] + fn validate_base_url_rejects_ipv6_loopback_https() { + // IPv6 loopback is allowed over HTTP (localhost equivalent), + // but must be rejected over HTTPS as a dangerous IP. + assert!(validate_base_url("https://[::1]", "TEST").is_err()); + } + + #[test] + fn validate_base_url_rejects_ipv6_link_local() { + // fe80::/10 — link-local addresses + assert!(validate_base_url("https://[fe80::1]", "TEST").is_err()); + } + + #[test] + fn validate_base_url_rejects_ipv6_multicast() { + // ff00::/8 — multicast addresses + assert!(validate_base_url("https://[ff02::1]", "TEST").is_err()); + } + + #[test] + fn validate_base_url_rejects_ipv6_unspecified() { + // :: — unspecified address + assert!(validate_base_url("https://[::]", "TEST").is_err()); + } + + #[test] + fn validate_base_url_rejects_dns_failure() { + // .invalid TLD is guaranteed to never resolve (RFC 6761) + let result = validate_base_url("https://ssrf-test.invalid", "TEST"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("failed to resolve"), + "Expected DNS resolution failure, got: {err}" + ); + } } diff --git a/src/config/llm.rs b/src/config/llm.rs index d0f4ba8d..37fd9c47 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use secrecy::SecretString; use crate::bootstrap::ironclaw_base_dir; -use crate::config::helpers::{optional_env, parse_optional_env}; +use crate::config::helpers::{optional_env, parse_optional_env, validate_base_url}; use crate::error::ConfigError; use crate::llm::config::*; use crate::llm::registry::{ProviderProtocol, ProviderRegistry}; @@ -81,9 +81,11 @@ impl LlmConfig { } // Session config (used by NearAI provider for OAuth/session-token auth) + let nearai_auth_url = optional_env("NEARAI_AUTH_URL")? + .unwrap_or_else(|| "https://private.near.ai".to_string()); + validate_base_url(&nearai_auth_url, "NEARAI_AUTH_URL")?; let session = SessionConfig { - auth_base_url: optional_env("NEARAI_AUTH_URL")? - .unwrap_or_else(|| "https://private.near.ai".to_string()), + auth_base_url: nearai_auth_url, session_path: optional_env("NEARAI_SESSION_PATH")? .map(PathBuf::from) .unwrap_or_else(default_session_path), @@ -94,13 +96,17 @@ impl LlmConfig { let nearai = NearAiConfig { model: Self::resolve_model("NEARAI_MODEL", settings, crate::llm::DEFAULT_MODEL)?, cheap_model: optional_env("NEARAI_CHEAP_MODEL")?, - base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| { - if nearai_api_key.is_some() { - "https://cloud-api.near.ai".to_string() - } else { - "https://private.near.ai".to_string() - } - }), + base_url: { + let url = optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| { + if nearai_api_key.is_some() { + "https://cloud-api.near.ai".to_string() + } else { + "https://private.near.ai".to_string() + } + }); + validate_base_url(&url, "NEARAI_BASE_URL")?; + url + }, api_key: nearai_api_key, fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?, max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?, @@ -325,6 +331,12 @@ impl LlmConfig { }); } + // Validate base URL to prevent SSRF (#1103). + if !base_url.is_empty() { + let field = base_url_env.unwrap_or("LLM_BASE_URL"); + validate_base_url(&base_url, field)?; + } + // Resolve model let model = Self::resolve_model(model_env, settings, default_model)?; diff --git a/src/config/transcription.rs b/src/config/transcription.rs index da2bac25..fc296c9a 100644 --- a/src/config/transcription.rs +++ b/src/config/transcription.rs @@ -1,6 +1,6 @@ use secrecy::SecretString; -use crate::config::helpers::{optional_env, parse_bool_env}; +use crate::config::helpers::{optional_env, parse_bool_env, validate_base_url}; use crate::error::ConfigError; use crate::settings::Settings; @@ -60,6 +60,11 @@ impl TranscriptionConfig { let base_url = optional_env("TRANSCRIPTION_BASE_URL")?; + // Validate base URL to prevent SSRF (#1103). + if let Some(ref url) = base_url { + validate_base_url(url, "TRANSCRIPTION_BASE_URL")?; + } + Ok(Self { enabled, provider,