From 618e83714c353f51707c8daeb1e5bfbc4e65e047 Mon Sep 17 00:00:00 2001 From: jaberjaber23 Date: Mon, 30 Mar 2026 21:30:48 +0300 Subject: [PATCH] fix: version bump to 0.5.5, SSRF allowlist, Ollama context, embedding detection - Bump workspace version and Tauri config to 0.5.5 (fixes users stuck on 0.5.1) - Add ssrf_allowed_hosts config for self-hosted K8s environments (Jerry Jaz) - Raise Ollama discovered model defaults to 128K context / 16K output (Cureator) - Expand embedding auto-detection: OpenAI, Groq, Mistral, Together, Fireworks, Cohere, then local providers (Thunder Guardian) All tests passing. 9 files changed, 272 insertions. --- Cargo.lock | 28 +-- Cargo.toml | 2 +- crates/openfang-desktop/tauri.conf.json | 2 +- crates/openfang-kernel/src/kernel.rs | 100 +++++++--- crates/openfang-runtime/src/browser.rs | 2 +- crates/openfang-runtime/src/model_catalog.rs | 4 +- crates/openfang-runtime/src/tool_runner.rs | 4 +- crates/openfang-runtime/src/web_fetch.rs | 185 +++++++++++++++++-- crates/openfang-types/src/config.rs | 10 + 9 files changed, 272 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index df329192..3650d653 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3955,7 +3955,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.5.1" +version = "0.5.5" dependencies = [ "async-trait", "axum", @@ -3995,7 +3995,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.5.1" +version = "0.5.5" dependencies = [ "aes", "async-trait", @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.5.1" +version = "0.5.5" dependencies = [ "clap", "clap_complete", @@ -4062,7 +4062,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.5.1" +version = "0.5.5" dependencies = [ "axum", "open", @@ -4088,7 +4088,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.5.1" +version = "0.5.5" dependencies = [ "aes-gcm", "argon2", @@ -4116,7 +4116,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.5.1" +version = "0.5.5" dependencies = [ "chrono", "dashmap", @@ -4133,7 +4133,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.5.1" +version = "0.5.5" dependencies = [ "async-trait", "chrono", @@ -4171,7 +4171,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.5.1" +version = "0.5.5" dependencies = [ "async-trait", "chrono", @@ -4191,7 +4191,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.5.1" +version = "0.5.5" dependencies = [ "chrono", "dirs 6.0.0", @@ -4210,7 +4210,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.5.1" +version = "0.5.5" dependencies = [ "anyhow", "async-trait", @@ -4246,7 +4246,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.5.1" +version = "0.5.5" dependencies = [ "chrono", "hex", @@ -4269,7 +4269,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.5.1" +version = "0.5.5" dependencies = [ "async-trait", "chrono", @@ -4288,7 +4288,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.5.1" +version = "0.5.5" dependencies = [ "async-trait", "chrono", @@ -9325,7 +9325,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.5.1" +version = "0.5.5" [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index a4fa4af1..cc9672a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.5.1" +version = "0.5.5" edition = "2021" license = "Apache-2.0 OR MIT" repository = "https://github.com/RightNow-AI/openfang" diff --git a/crates/openfang-desktop/tauri.conf.json b/crates/openfang-desktop/tauri.conf.json index 1f0c59ec..0aff766c 100644 --- a/crates/openfang-desktop/tauri.conf.json +++ b/crates/openfang-desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenFang", - "version": "0.1.0", + "version": "0.5.5", "identifier": "ai.openfang.desktop", "build": {}, "app": { diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index f449adda..95ae67f8 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -877,40 +877,77 @@ impl OpenFangKernel { None } } - } else if std::env::var("OPENAI_API_KEY").is_ok() { - let model = if configured_model == "all-MiniLM-L6-v2" { - default_embedding_model_for_provider("openai") - } else { - configured_model.as_str() - }; - let openai_url = config.provider_urls.get("openai").map(|s| s.as_str()); - match create_embedding_driver("openai", model, "OPENAI_API_KEY", openai_url) { - Ok(d) => { - info!(model = %model, "Embedding driver auto-detected: OpenAI"); - Some(Arc::from(d)) - } - Err(e) => { - warn!(error = %e, "OpenAI embedding auto-detect failed"); - None - } - } } else { - // Try Ollama (local, no key needed) - let model = if configured_model == "all-MiniLM-L6-v2" { - default_embedding_model_for_provider("ollama") + // Auto-detect embedding provider by checking API key env vars in + // priority order. First match wins. + const API_KEY_PROVIDERS: &[(&str, &str)] = &[ + ("OPENAI_API_KEY", "openai"), + ("GROQ_API_KEY", "groq"), + ("MISTRAL_API_KEY", "mistral"), + ("TOGETHER_API_KEY", "together"), + ("FIREWORKS_API_KEY", "fireworks"), + ("COHERE_API_KEY", "cohere"), + ]; + + let detected_from_key = API_KEY_PROVIDERS + .iter() + .find(|(env_var, _)| std::env::var(env_var).is_ok()) + .and_then(|(env_var, provider)| { + let model = if configured_model == "all-MiniLM-L6-v2" { + default_embedding_model_for_provider(provider) + } else { + configured_model.as_str() + }; + let custom_url = config.provider_urls.get(*provider).map(|s| s.as_str()); + match create_embedding_driver(provider, model, env_var, custom_url) { + Ok(d) => { + info!(provider = %provider, model = %model, "Embedding driver auto-detected via {}", env_var); + Some(Arc::from(d)) + } + Err(e) => { + warn!(provider = %provider, error = %e, "Embedding auto-detect failed for {}", provider); + None + } + } + }); + + if detected_from_key.is_some() { + detected_from_key } else { - configured_model.as_str() - }; - let ollama_url = config.provider_urls.get("ollama").map(|s| s.as_str()); - match create_embedding_driver("ollama", model, "", ollama_url) { - Ok(d) => { - info!(model = %model, "Embedding driver auto-detected: Ollama (local)"); - Some(Arc::from(d)) + // No API key found — try local providers in order: + // Ollama, vLLM, LM Studio (no key needed). + const LOCAL_PROVIDERS: &[&str] = &["ollama", "vllm", "lmstudio"]; + + let mut local_result = None; + for provider in LOCAL_PROVIDERS { + let model = if configured_model == "all-MiniLM-L6-v2" { + default_embedding_model_for_provider(provider) + } else { + configured_model.as_str() + }; + let custom_url = config.provider_urls.get(*provider).map(|s| s.as_str()); + match create_embedding_driver(provider, model, "", custom_url) { + Ok(d) => { + info!(provider = %provider, model = %model, "Embedding driver auto-detected: {} (local)", provider); + local_result = Some(Arc::from(d)); + break; + } + Err(e) => { + debug!(provider = %provider, error = %e, "Local embedding provider {} not available", provider); + } + } } - Err(e) => { - debug!("No embedding driver available (Ollama probe failed: {e}) — using text search fallback"); - None + + if local_result.is_none() { + warn!( + "No embedding provider available. Memory recall will use text search only. \ + Configure [memory] embedding_provider in config.toml or set an API key \ + (OPENAI_API_KEY, GROQ_API_KEY, MISTRAL_API_KEY, TOGETHER_API_KEY, \ + FIREWORKS_API_KEY, COHERE_API_KEY)." + ); } + + local_result } } }; @@ -5663,7 +5700,10 @@ fn apply_budget_defaults( fn default_embedding_model_for_provider(provider: &str) -> &'static str { match provider { "openai" => "text-embedding-3-small", + "groq" => "nomic-embed-text", "mistral" => "mistral-embed", + "together" => "togethercomputer/m2-bert-80M-8k-retrieval", + "fireworks" => "nomic-ai/nomic-embed-text-v1.5", "cohere" => "embed-english-v3.0", // Local providers use nomic-embed-text as a good default "ollama" | "vllm" | "lmstudio" => "nomic-embed-text", diff --git a/crates/openfang-runtime/src/browser.rs b/crates/openfang-runtime/src/browser.rs index 1e0c9db1..1bc6c8f7 100644 --- a/crates/openfang-runtime/src/browser.rs +++ b/crates/openfang-runtime/src/browser.rs @@ -877,7 +877,7 @@ pub async fn tool_browser_navigate( agent_id: &str, ) -> Result { let url = input["url"].as_str().ok_or("Missing 'url' parameter")?; - crate::web_fetch::check_ssrf(url)?; + crate::web_fetch::check_ssrf(url, &[])?; let resp = mgr .send_command( diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 62b81c74..c97bfb5c 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -372,8 +372,8 @@ impl ModelCatalog { display_name: display, provider: provider.to_string(), tier: ModelTier::Local, - context_window: 32_768, - max_output_tokens: 4_096, + context_window: 131_072, + max_output_tokens: 16_384, input_cost_per_m: 0.0, output_cost_per_m: 0.0, supports_tools: true, diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 664735c8..8045ad9b 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -2477,7 +2477,7 @@ async fn tool_a2a_discover(input: &serde_json::Value) -> Result let url = input["url"].as_str().ok_or("Missing 'url' parameter")?; // SSRF protection: block private/metadata IPs - if crate::web_fetch::check_ssrf(url).is_err() { + if crate::web_fetch::check_ssrf(url, &[]).is_err() { return Err("SSRF blocked: URL resolves to a private or metadata address".to_string()); } @@ -2500,7 +2500,7 @@ async fn tool_a2a_send( // Resolve agent URL: either directly provided or looked up by name let url = if let Some(url) = input["agent_url"].as_str() { // SSRF protection - if crate::web_fetch::check_ssrf(url).is_err() { + if crate::web_fetch::check_ssrf(url, &[]).is_err() { return Err("SSRF blocked: URL resolves to a private or metadata address".to_string()); } url.to_string() diff --git a/crates/openfang-runtime/src/web_fetch.rs b/crates/openfang-runtime/src/web_fetch.rs index d230ffe1..81021aef 100644 --- a/crates/openfang-runtime/src/web_fetch.rs +++ b/crates/openfang-runtime/src/web_fetch.rs @@ -53,7 +53,7 @@ impl WebFetchEngine { let method_upper = method.to_uppercase(); // Step 1: SSRF protection — BEFORE any network I/O - check_ssrf(url)?; + check_ssrf(url, &self.config.ssrf_allowed_hosts)?; // Step 2: Cache lookup (only for GET) let cache_key = format!("fetch:{}:{}", method_upper, url); @@ -185,7 +185,14 @@ fn is_html(content_type: &str, body: &str) -> bool { /// Check if a URL targets a private/internal network resource. /// Blocks localhost, metadata endpoints, and private IPs. /// Must run BEFORE any network I/O. -pub(crate) fn check_ssrf(url: &str) -> Result<(), String> { +/// +/// The `allowed_hosts` slice lets self-hosted deployments bypass the +/// private-IP check for specific hosts. Entries can be exact hostnames +/// (`"n8n.local"`), wildcard domains (`"*.olares.com"`), or CIDR ranges +/// (`"10.0.0.0/8"`). +/// +/// **Cloud metadata endpoints are NEVER allowed regardless of the allowlist.** +pub(crate) fn check_ssrf(url: &str, allowed_hosts: &[String]) -> Result<(), String> { // Only allow http:// and https:// schemes if !url.starts_with("http://") && !url.starts_with("https://") { return Err("Only http:// and https:// URLs are allowed".to_string()); @@ -200,6 +207,7 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> { }; // Hostname-based blocklist (catches metadata endpoints) + // These are UNCONDITIONALLY blocked — no allowlist can override them. let blocked = [ "localhost", "ip6-localhost", @@ -217,13 +225,28 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> { return Err(format!("SSRF blocked: {hostname} is a restricted hostname")); } + // Check if the hostname is explicitly allowed before doing DNS resolution. + if is_host_allowed(hostname, allowed_hosts) { + return Ok(()); + } + // Resolve DNS and check every returned IP let port = if url.starts_with("https") { 443 } else { 80 }; let socket_addr = format!("{hostname}:{port}"); if let Ok(addrs) = socket_addr.to_socket_addrs() { for addr in addrs { let ip = addr.ip(); + if is_metadata_ip(&ip) { + // Metadata IPs are NEVER allowed, even via allowlist. + return Err(format!( + "SSRF blocked: {hostname} resolves to metadata IP {ip}" + )); + } if ip.is_loopback() || ip.is_unspecified() || is_private_ip(&ip) { + // Check if the resolved IP matches a CIDR in the allowlist. + if is_ip_allowed(&ip, allowed_hosts) { + continue; + } return Err(format!( "SSRF blocked: {hostname} resolves to private IP {ip}" )); @@ -234,6 +257,97 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> { Ok(()) } +/// Returns true if an IP is a cloud metadata endpoint address. +fn is_metadata_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + let octets = v4.octets(); + // 169.254.169.254 (AWS/GCP/Azure IMDS) + octets == [169, 254, 169, 254] + // 100.100.100.200 (Alibaba Cloud IMDS) + || octets == [100, 100, 100, 200] + // 192.0.0.192 (Azure IMDS alternative) + || octets == [192, 0, 0, 192] + } + _ => false, + } +} + +/// Check if a hostname matches any entry in the allowlist. +/// Supports exact match and wildcard domains (`*.example.com`). +fn is_host_allowed(hostname: &str, allowed_hosts: &[String]) -> bool { + let lower = hostname.to_lowercase(); + for entry in allowed_hosts { + let entry_lower = entry.to_lowercase(); + // Exact match + if entry_lower == lower { + return true; + } + // Wildcard domain: *.example.com matches sub.example.com + if let Some(suffix) = entry_lower.strip_prefix("*.") { + if lower.ends_with(&format!(".{suffix}")) || lower == suffix { + return true; + } + } + } + false +} + +/// Check if an IP address matches any CIDR entry in the allowlist. +fn is_ip_allowed(ip: &IpAddr, allowed_hosts: &[String]) -> bool { + for entry in allowed_hosts { + if let Some(pos) = entry.find('/') { + // Parse as CIDR: base_ip/prefix_len + let base_str = &entry[..pos]; + let prefix_str = &entry[pos + 1..]; + if let (Ok(base_ip), Ok(prefix_len)) = + (base_str.parse::(), prefix_str.parse::()) + { + if ip_in_cidr(ip, &base_ip, prefix_len) { + return true; + } + } + } else if let Ok(entry_ip) = entry.parse::() { + // Exact IP match + if *ip == entry_ip { + return true; + } + } + } + false +} + +/// Check if `ip` falls within the CIDR block `base/prefix_len`. +fn ip_in_cidr(ip: &IpAddr, base: &IpAddr, prefix_len: u32) -> bool { + match (ip, base) { + (IpAddr::V4(ip4), IpAddr::V4(base4)) => { + if prefix_len > 32 { + return false; + } + if prefix_len == 0 { + return true; + } + let ip_bits = u32::from_be_bytes(ip4.octets()); + let base_bits = u32::from_be_bytes(base4.octets()); + let mask = !0u32 << (32 - prefix_len); + (ip_bits & mask) == (base_bits & mask) + } + (IpAddr::V6(ip6), IpAddr::V6(base6)) => { + if prefix_len > 128 { + return false; + } + if prefix_len == 0 { + return true; + } + let ip_bits = u128::from_be_bytes(ip6.octets()); + let base_bits = u128::from_be_bytes(base6.octets()); + let mask = !0u128 << (128 - prefix_len); + (ip_bits & mask) == (base_bits & mask) + } + _ => false, // mismatched families + } +} + /// Check if an IP address is in a private range. fn is_private_ip(ip: &IpAddr) -> bool { match ip { @@ -308,8 +422,8 @@ mod tests { #[test] fn test_ssrf_blocks_localhost() { - assert!(check_ssrf("http://localhost/admin").is_err()); - assert!(check_ssrf("http://localhost:8080/api").is_err()); + assert!(check_ssrf("http://localhost/admin", &[]).is_err()); + assert!(check_ssrf("http://localhost:8080/api", &[]).is_err()); } #[test] @@ -323,8 +437,8 @@ mod tests { #[test] fn test_ssrf_blocks_metadata() { - assert!(check_ssrf("http://169.254.169.254/latest/meta-data/").is_err()); - assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/").is_err()); + assert!(check_ssrf("http://169.254.169.254/latest/meta-data/", &[]).is_err()); + assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/", &[]).is_err()); } #[test] @@ -339,28 +453,28 @@ mod tests { #[test] fn test_ssrf_blocks_non_http() { - assert!(check_ssrf("file:///etc/passwd").is_err()); - assert!(check_ssrf("ftp://internal.corp/data").is_err()); - assert!(check_ssrf("gopher://evil.com").is_err()); + assert!(check_ssrf("file:///etc/passwd", &[]).is_err()); + assert!(check_ssrf("ftp://internal.corp/data", &[]).is_err()); + assert!(check_ssrf("gopher://evil.com", &[]).is_err()); } #[test] fn test_ssrf_blocks_cloud_metadata() { // Alibaba Cloud IMDS - assert!(check_ssrf("http://100.100.100.200/latest/meta-data/").is_err()); + assert!(check_ssrf("http://100.100.100.200/latest/meta-data/", &[]).is_err()); // Azure IMDS alternative - assert!(check_ssrf("http://192.0.0.192/metadata/instance").is_err()); + assert!(check_ssrf("http://192.0.0.192/metadata/instance", &[]).is_err()); } #[test] fn test_ssrf_blocks_zero_ip() { - assert!(check_ssrf("http://0.0.0.0/").is_err()); + assert!(check_ssrf("http://0.0.0.0/", &[]).is_err()); } #[test] fn test_ssrf_blocks_ipv6_localhost() { - assert!(check_ssrf("http://[::1]/admin").is_err()); - assert!(check_ssrf("http://[::1]:8080/api").is_err()); + assert!(check_ssrf("http://[::1]/admin", &[]).is_err()); + assert!(check_ssrf("http://[::1]:8080/api", &[]).is_err()); } #[test] @@ -374,4 +488,47 @@ mod tests { let h3 = extract_host("http://[::1]/path"); assert_eq!(h3, "[::1]:80"); } + + // ── SSRF allowlist tests ───────────────────────────────────────────── + + #[test] + fn test_ssrf_allowlist_permits_private_ip() { + // A CIDR allowlist entry should permit an otherwise-blocked private IP. + let allow = vec!["10.0.0.0/8".to_string()]; + assert!(check_ssrf("http://10.1.2.3", &allow).is_ok()); + } + + #[test] + fn test_ssrf_allowlist_still_blocks_metadata() { + // Even if the allowlist covers the entire link-local range, + // cloud metadata endpoints must NEVER be permitted. + let allow = vec!["169.254.0.0/16".to_string()]; + assert!(check_ssrf("http://169.254.169.254/latest/meta-data/", &allow).is_err()); + // Also verify hostname-based metadata blocks + let allow2 = vec!["metadata.google.internal".to_string()]; + assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/", &allow2).is_err()); + } + + #[test] + fn test_ssrf_allowlist_wildcard_domain() { + let allow = vec!["*.example.com".to_string()]; + assert!(check_ssrf("http://api.example.com", &allow).is_ok()); + // Non-matching domain should still go through normal checks + assert!(is_host_allowed("other.net", &allow) == false); + } + + #[test] + fn test_ssrf_allowlist_exact_hostname() { + let allow = vec!["n8n.local".to_string()]; + assert!(check_ssrf("http://n8n.local/webhook", &allow).is_ok()); + } + + #[test] + fn test_cidr_matching() { + let ip_in: IpAddr = "10.1.2.3".parse().unwrap(); + let ip_out: IpAddr = "11.0.0.1".parse().unwrap(); + let base: IpAddr = "10.0.0.0".parse().unwrap(); + assert!(ip_in_cidr(&ip_in, &base, 8)); + assert!(!ip_in_cidr(&ip_out, &base, 8)); + } } diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 2c85ae35..6bbe3342 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -293,6 +293,15 @@ pub struct WebFetchConfig { pub timeout_secs: u64, /// Enable HTML→Markdown readability extraction. pub readability: bool, + /// SSRF allowlist for self-hosted environments. + /// + /// Entries can be exact hostnames (`"n8n.local"`), wildcard domains + /// (`"*.olares.com"`), or CIDR ranges (`"10.0.0.0/8"`). + /// + /// Allowlisted hosts bypass the private-IP check but **never** bypass + /// cloud metadata endpoint blocking (169.254.169.254, metadata.google.internal, etc.). + #[serde(default)] + pub ssrf_allowed_hosts: Vec, } impl Default for WebFetchConfig { @@ -302,6 +311,7 @@ impl Default for WebFetchConfig { max_response_bytes: 10 * 1024 * 1024, // 10 MB timeout_secs: 30, readability: true, + ssrf_allowed_hosts: Vec::new(), } } }