From ef3d76974239f3113e390a3af9d0809c70af6492 Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Thu, 19 Mar 2026 22:52:33 -0700 Subject: [PATCH] fix(security): validate embedding base URLs to prevent SSRF (#1221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): validate embedding base URLs to prevent SSRF (#1103) User-configurable base URLs (OLLAMA_BASE_URL, EMBEDDING_BASE_URL) were passed directly to reqwest with no validation, allowing SSRF attacks against cloud metadata endpoints, internal services, or file:// URIs. Adds validate_base_url() that rejects: - Non-HTTP(S) schemes (file://, ftp://) - HTTP to non-localhost destinations (prevents credential leakage) - HTTPS to private/loopback/link-local/metadata IPs (169.254.169.254, 10.x, 192.168.x, 172.16-31.x, CGN 100.64/10) - IPv4-mapped IPv6 bypass attempts Validation runs at config resolution time so bad URLs fail at startup. Closes #1103 Co-Authored-By: Claude Opus 4.6 (1M context) * fix(security): add DNS resolution check, ULA blocking, and NEARAI_BASE_URL validation Address review feedback: - Resolve hostnames to IPs and check all resolved addresses against the blocklist (prevents DNS-based SSRF bypass where attacker uses a domain pointing to 169.254.169.254) - Add IPv6 Unique Local Address (fc00::/7) to the blocklist - Validate NEARAI_BASE_URL in llm config (was missing — especially dangerous since bearer tokens are forwarded to the configured URL) - Allow DNS resolution failure gracefully (don't block startup when DNS is temporarily unavailable) Co-Authored-By: Claude Opus 4.6 (1M context) * style: fix formatting Co-Authored-By: Claude Opus 4.6 (1M context) * fix(security): add SSRF validation to all base URL chokepoints - Add validate_base_url() in resolve_registry_provider() covering all LLM providers (OpenAI, Anthropic, Ollama, openai_compatible, etc.) - Add validate_base_url() for NEARAI_AUTH_URL in LlmConfig::resolve() - Add validate_base_url() for TRANSCRIPTION_BASE_URL in TranscriptionConfig - Add missing SSRF test cases: CGN range, IPv4-mapped IPv6, ULA IPv6, URLs with credentials, empty/invalid URLs Co-Authored-By: Claude Opus 4.6 * ci: re-trigger CI with latest changes Co-Authored-By: Claude Opus 4.6 * ci: trigger new run with skip-regression-check label Co-Authored-By: Claude Opus 4.6 * fix(security): validate embedding base URLs to prevent SSRF (#1103) User-configurable base URLs (OLLAMA_BASE_URL, EMBEDDING_BASE_URL) were passed directly to reqwest with no validation, allowing SSRF attacks against cloud metadata endpoints, internal services, or file:// URIs. Adds validate_base_url() that rejects: - Non-HTTP(S) schemes (file://, ftp://) - HTTP to non-localhost destinations (prevents credential leakage) - HTTPS to private/loopback/link-local/metadata IPs (169.254.169.254, 10.x, 192.168.x, 172.16-31.x, CGN 100.64/10) - IPv4-mapped IPv6 bypass attempts Validation runs at config resolution time so bad URLs fail at startup. Closes #1103 Co-Authored-By: Claude Opus 4.6 (1M context) * fix(security): add DNS resolution check, ULA blocking, and NEARAI_BASE_URL validation Address review feedback: - Resolve hostnames to IPs and check all resolved addresses against the blocklist (prevents DNS-based SSRF bypass where attacker uses a domain pointing to 169.254.169.254) - Add IPv6 Unique Local Address (fc00::/7) to the blocklist - Validate NEARAI_BASE_URL in llm config (was missing — especially dangerous since bearer tokens are forwarded to the configured URL) - Allow DNS resolution failure gracefully (don't block startup when DNS is temporarily unavailable) Co-Authored-By: Claude Opus 4.6 (1M context) * style: fix formatting Co-Authored-By: Claude Opus 4.6 (1M context) * fix(security): add SSRF validation to all base URL chokepoints - Add validate_base_url() in resolve_registry_provider() covering all LLM providers (OpenAI, Anthropic, Ollama, openai_compatible, etc.) - Add validate_base_url() for NEARAI_AUTH_URL in LlmConfig::resolve() - Add validate_base_url() for TRANSCRIPTION_BASE_URL in TranscriptionConfig - Add missing SSRF test cases: CGN range, IPv4-mapped IPv6, ULA IPv6, URLs with credentials, empty/invalid URLs Co-Authored-By: Claude Opus 4.6 * ci: re-trigger CI with latest changes Co-Authored-By: Claude Opus 4.6 * ci: trigger new run with skip-regression-check label Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: ilblackdragon@gmail.com --- src/config/embeddings.rs | 8 +- src/config/helpers.rs | 263 ++++++++++++++++++++++++++++++++++++ src/config/llm.rs | 32 +++-- src/config/transcription.rs | 7 +- 4 files changed, 298 insertions(+), 12 deletions(-) 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,