mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
fix(security): validate embedding base URLs to prevent SSRF (#1221)
* 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) <[email protected]> * 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) <[email protected]> * style: fix formatting Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * 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 <[email protected]> * ci: re-trigger CI with latest changes Co-Authored-By: Claude Opus 4.6 <[email protected]> * ci: trigger new run with skip-regression-check label Co-Authored-By: Claude Opus 4.6 <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * style: fix formatting Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * 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 <[email protected]> * ci: re-trigger CI with latest changes Co-Authored-By: Claude Opus 4.6 <[email protected]> * ci: trigger new run with skip-regression-check label Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: [email protected] <[email protected]>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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::<IpAddr>() {
|
||||
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:[email protected]", "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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-10
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user