mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-29 22:22:35 +00:00
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.
This commit is contained in:
Generated
+14
-14
@@ -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"
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -877,7 +877,7 @@ pub async fn tool_browser_navigate(
|
||||
agent_id: &str,
|
||||
) -> Result<String, String> {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2477,7 +2477,7 @@ async fn tool_a2a_discover(input: &serde_json::Value) -> Result<String, String>
|
||||
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()
|
||||
|
||||
@@ -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::<IpAddr>(), prefix_str.parse::<u32>())
|
||||
{
|
||||
if ip_in_cidr(ip, &base_ip, prefix_len) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if let Ok(entry_ip) = entry.parse::<IpAddr>() {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user