use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; use crate::error::ConfigError; use crate::config::INJECTED_VARS; /// Crate-wide mutex for tests that mutate process environment variables. /// /// The process environment is global state shared across all threads. /// Per-module mutexes do NOT prevent races between modules running in /// parallel. Every `unsafe { set_var / remove_var }` call in tests /// MUST hold this single lock. #[cfg(test)] pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// Acquire the env-var mutex, recovering from poison. /// /// A poisoned mutex means a previous test panicked while holding the lock. /// The env state might be slightly stale, but cascading every subsequent /// test into a `PoisonError` panic is far worse. Recover and carry on. #[cfg(test)] pub(crate) fn lock_env() -> std::sync::MutexGuard<'static, ()> { ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()) } /// Thread-safe mutable overlay for env vars set at runtime. /// /// Unlike `INJECTED_VARS` (which is set once at startup from the secrets /// store), this map supports writes at any point during the process /// lifetime. It replaces unsafe `std::env::set_var` calls that would /// otherwise be UB in multi-threaded programs (Rust 1.82+). /// /// Priority: real env vars > `RUNTIME_ENV_OVERRIDES` > `INJECTED_VARS`. static RUNTIME_ENV_OVERRIDES: OnceLock>> = OnceLock::new(); fn runtime_overrides() -> &'static Mutex> { RUNTIME_ENV_OVERRIDES.get_or_init(|| Mutex::new(HashMap::new())) } /// Set a runtime environment override (thread-safe alternative to `std::env::set_var`). /// /// Values set here are visible to `optional_env()`, `env_or_override()`, and /// all config resolution that goes through those helpers. This avoids the UB /// of `std::env::set_var` in multi-threaded programs. pub fn set_runtime_env(key: &str, value: &str) { runtime_overrides() .lock() .unwrap_or_else(|e| e.into_inner()) .insert(key.to_string(), value.to_string()); } /// Read an env var, checking the real environment first, then runtime overrides. /// /// Priority: real env vars > runtime overrides > `INJECTED_VARS`. /// Empty values are treated as unset at every layer for consistency with /// `optional_env()`. /// /// Use this instead of `std::env::var()` when the value might have been set /// via `set_runtime_env()` (e.g., `NEARAI_API_KEY` during interactive login). pub fn env_or_override(key: &str) -> Option { // Real env vars always win if let Ok(val) = std::env::var(key) && !val.is_empty() { return Some(val); } // Check runtime overrides (skip empty values for consistency with optional_env) if let Some(val) = runtime_overrides() .lock() .unwrap_or_else(|e| e.into_inner()) .get(key) .filter(|v| !v.is_empty()) .cloned() { return Some(val); } // Check INJECTED_VARS (secrets from DB, set once at startup) if let Some(val) = INJECTED_VARS .lock() .unwrap_or_else(|e| e.into_inner()) .get(key) .filter(|v| !v.is_empty()) .cloned() { return Some(val); } None } pub(crate) fn optional_env(key: &str) -> Result, ConfigError> { // Check real env vars first (always win over injected secrets) match std::env::var(key) { Ok(val) if val.is_empty() => {} Ok(val) => return Ok(Some(val)), Err(std::env::VarError::NotPresent) => {} Err(e) => { return Err(ConfigError::ParseError(format!( "failed to read {key}: {e}" ))); } } // Fall back to runtime overrides (set via set_runtime_env) if let Some(val) = runtime_overrides() .lock() .unwrap_or_else(|e| e.into_inner()) .get(key) .filter(|v| !v.is_empty()) .cloned() { return Ok(Some(val)); } // Fall back to thread-safe overlay (secrets injected from DB) if let Some(val) = INJECTED_VARS .lock() .unwrap_or_else(|p| p.into_inner()) .get(key) .cloned() { return Ok(Some(val)); } Ok(None) } pub(crate) fn parse_optional_env(key: &str, default: T) -> Result where T: std::str::FromStr, T::Err: std::fmt::Display, { optional_env(key)? .map(|s| { s.parse().map_err(|e| ConfigError::InvalidValue { key: key.to_string(), message: format!("{e}"), }) }) .transpose() .map(|opt| opt.unwrap_or(default)) } /// Parse a boolean from an env var with a default. /// /// Accepts "true"/"1" as true, "false"/"0" as false. pub(crate) fn parse_bool_env(key: &str, default: bool) -> Result { match optional_env(key)? { Some(s) => match s.to_lowercase().as_str() { "true" | "1" => Ok(true), "false" | "0" => Ok(false), _ => Err(ConfigError::InvalidValue { key: key.to_string(), message: format!("must be 'true' or 'false', got '{s}'"), }), }, None => Ok(default), } } /// Parse an env var into `Option` — returns `None` when unset, /// `Some(parsed)` when set to a valid value. pub(crate) fn parse_option_env(key: &str) -> Result, ConfigError> where T: std::str::FromStr, T::Err: std::fmt::Display, { optional_env(key)? .map(|s| { s.parse().map_err(|e| ConfigError::InvalidValue { key: key.to_string(), message: format!("{e}"), }) }) .transpose() } /// Parse a string from an env var with a default. pub(crate) fn parse_string_env( key: &str, default: impl Into, ) -> Result { 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::*; #[test] fn runtime_env_override_is_visible_to_env_or_override() { // Use a unique key that won't collide with real env vars. let key = "IRONCLAW_TEST_RUNTIME_OVERRIDE_42"; // Not set initially assert!(env_or_override(key).is_none()); // Set via the thread-safe overlay set_runtime_env(key, "test_value"); // Now visible assert_eq!(env_or_override(key), Some("test_value".to_string())); } #[test] fn runtime_env_override_is_visible_to_optional_env() { let key = "IRONCLAW_TEST_OPTIONAL_ENV_OVERRIDE_42"; assert_eq!(optional_env(key).unwrap(), None); set_runtime_env(key, "hello"); assert_eq!(optional_env(key).unwrap(), Some("hello".to_string())); } #[test] fn real_env_var_takes_priority_over_runtime_override() { let _guard = lock_env(); let key = "IRONCLAW_TEST_ENV_PRIORITY_42"; // Set runtime override set_runtime_env(key, "override_value"); // Set real env var (should win) // SAFETY: test runs under ENV_MUTEX unsafe { std::env::set_var(key, "real_value") }; assert_eq!(env_or_override(key), Some("real_value".to_string())); // Clean up unsafe { std::env::remove_var(key) }; // Now the runtime override is visible again assert_eq!(env_or_override(key), Some("override_value".to_string())); } // --- lock_env poison recovery (regression for env mutex cascade) --- #[test] fn lock_env_recovers_from_poisoned_mutex() { // Simulate a poisoned mutex: spawn a thread that panics while holding the lock. let _ = std::thread::spawn(|| { let _guard = ENV_MUTEX.lock().unwrap(); panic!("intentional poison"); }) .join(); // The mutex is now poisoned. lock_env() should recover, not cascade. assert!(ENV_MUTEX.lock().is_err(), "mutex should be poisoned"); let _guard = lock_env(); // must not panic drop(_guard); // Clean up so this test doesn't leave ENV_MUTEX permanently poisoned. ENV_MUTEX.clear_poison(); } // --- 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}" ); } }