Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 16 additions & 4 deletions crates/openfang-api/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3798,7 +3798,10 @@ pub async fn install_hand_deps(
if !extra_paths.is_empty() {
let current_path = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{};{}", extra_paths.join(";"), current_path);
std::env::set_var("PATH", &new_path);
{
let _guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var("PATH", &new_path); }
}
tracing::info!(
added = extra_paths.len(),
"Refreshed PATH with winget/pip directories"
Expand Down Expand Up @@ -6654,7 +6657,10 @@ pub async fn set_provider_key(
}

// Set env var in current process so detect_auth picks it up
std::env::set_var(&env_var, &key);
{
let _guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var(&env_var, &key); }
}

// Refresh auth detection
state
Expand Down Expand Up @@ -6709,7 +6715,10 @@ pub async fn delete_provider_key(
}

// Remove from process environment
std::env::remove_var(&env_var);
{
let _guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::remove_var(&env_var); }
}

// Refresh auth detection
state
Expand Down Expand Up @@ -9854,7 +9863,10 @@ pub async fn copilot_oauth_poll(
}

// Set in current process
std::env::set_var("GITHUB_TOKEN", access_token.as_str());
{
let _guard = ENV_MUTEX.lock().unwrap();
unsafe { std::env::set_var("GITHUB_TOKEN", access_token.as_str()); }
}

// Refresh auth detection
state
Expand Down
48 changes: 42 additions & 6 deletions crates/openfang-kernel/src/kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,20 @@ impl DeliveryTracker {
let drain = entry.len() - Self::MAX_PER_AGENT;
entry.drain(..drain);
}
// Global cap: evict oldest agents' receipts if total exceeds limit
// Global cap: evict across buckets until total is within limit
drop(entry);
let total: usize = self.receipts.iter().map(|e| e.value().len()).sum();
if total > Self::MAX_RECEIPTS {
// Simple eviction: remove oldest entries from first agent found
if let Some(mut oldest) = self.receipts.iter_mut().next() {
let to_remove = total - Self::MAX_RECEIPTS;
let drain = to_remove.min(oldest.value().len());
oldest.value_mut().drain(..drain);
let mut remaining = total - Self::MAX_RECEIPTS;
for mut bucket in self.receipts.iter_mut() {
if remaining == 0 {
break;
}
let drain = remaining.min(bucket.value().len());
if drain > 0 {
bucket.value_mut().drain(..drain);
remaining -= drain;
}
}
}
}
Expand Down Expand Up @@ -5709,4 +5714,35 @@ mod tests {
.iter()
.any(|c| matches!(c, Capability::ToolInvoke(name) if name == "shell_exec")));
}

#[test]
fn test_receipt_eviction_respects_global_cap() {
let tracker = DeliveryTracker::new();
let max = DeliveryTracker::MAX_RECEIPTS;
let per_agent = 50;
let num_agents = (max / per_agent) + 20;

for i in 0..num_agents {
let agent_id = AgentId(uuid::Uuid::new_v4());
for _ in 0..per_agent {
tracker.record(
agent_id,
openfang_channels::types::DeliveryReceipt {
message_id: String::new(),
channel: "test".to_string(),
recipient: format!("agent-{i}"),
status: openfang_channels::types::DeliveryStatus::Sent,
timestamp: chrono::Utc::now(),
error: None,
},
);
}
}

let total: usize = tracker.receipts.iter().map(|e| e.value().len()).sum();
assert!(
total <= max,
"Total receipts ({total}) should be <= MAX_RECEIPTS ({max})"
);
}
}
1 change: 1 addition & 0 deletions crates/openfang-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ hex = { workspace = true }
zeroize = { workspace = true }
dashmap = { workspace = true }
regex-lite = { workspace = true }
url = { workspace = true }
tokio-tungstenite = "0.24"

[dev-dependencies]
Expand Down
123 changes: 4 additions & 119 deletions crates/openfang-runtime/src/host_functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use crate::sandbox::GuestState;
use openfang_types::capability::{capability_matches, Capability};
use serde_json::json;
use std::net::ToSocketAddrs;
use std::path::{Component, Path};
use tracing::debug;

Expand Down Expand Up @@ -120,60 +119,7 @@ fn safe_resolve_parent(path: &str) -> Result<std::path::PathBuf, serde_json::Val
// SSRF protection
// ---------------------------------------------------------------------------

/// SSRF protection: check if a hostname resolves to a private/internal IP.
/// This defeats DNS rebinding by checking the RESOLVED address, not the hostname.
fn is_ssrf_target(url: &str) -> Result<(), serde_json::Value> {
// Only allow http:// and https:// schemes (block file://, gopher://, ftp://)
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(json!({"error": "Only http:// and https:// URLs are allowed"}));
}

let host = extract_host_from_url(url);
let hostname = host.split(':').next().unwrap_or(&host);

// Check hostname-based blocklist first (catches metadata endpoints)
let blocked_hostnames = [
"localhost",
"metadata.google.internal",
"metadata.aws.internal",
"instance-data",
"169.254.169.254",
];
if blocked_hostnames.contains(&hostname) {
return Err(json!({"error": format!("SSRF blocked: {hostname} is a restricted hostname")}));
}

// 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 ip.is_loopback() || ip.is_unspecified() || is_private_ip(&ip) {
return Err(json!({"error": format!(
"SSRF blocked: {hostname} resolves to private IP {ip}"
)}));
}
}
}
Ok(())
}

fn is_private_ip(ip: &std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
let octets = v4.octets();
matches!(
octets,
[10, ..] | [172, 16..=31, ..] | [192, 168, ..] | [169, 254, ..]
)
}
std::net::IpAddr::V6(v6) => {
let segments = v6.segments();
(segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80
}
}
}
// SSRF protection — delegates to unified crate::ssrf module

// ---------------------------------------------------------------------------
// Always-allowed functions
Expand Down Expand Up @@ -280,12 +226,12 @@ fn host_net_fetch(state: &GuestState, params: &serde_json::Value) -> serde_json:
let body = params.get("body").and_then(|b| b.as_str()).unwrap_or("");

// SECURITY: SSRF protection — check resolved IP against private ranges
if let Err(e) = is_ssrf_target(url) {
if let Err(e) = crate::ssrf::check_ssrf_json(url) {
return e;
}

// Extract host:port from URL for capability check
let host = extract_host_from_url(url);
let host = crate::ssrf::extract_host_for_capability(url);
if let Err(e) = check_capability(&state.capabilities, &Capability::NetConnect(host)) {
return e;
}
Expand All @@ -311,21 +257,6 @@ fn host_net_fetch(state: &GuestState, params: &serde_json::Value) -> serde_json:
})
}

/// Extract host:port from a URL for capability checking.
fn extract_host_from_url(url: &str) -> String {
if let Some(after_scheme) = url.split("://").nth(1) {
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
if host_port.contains(':') {
host_port.to_string()
} else if url.starts_with("https") {
format!("{host_port}:443")
} else {
format!("{host_port}:80")
}
} else {
url.to_string()
}
}

// ---------------------------------------------------------------------------
// Shell (capability-checked)
Expand Down Expand Up @@ -618,51 +549,5 @@ mod tests {
assert!(safe_resolve_parent("/tmp/../../etc/shadow").is_err());
}

#[test]
fn test_ssrf_private_ips_blocked() {
assert!(is_ssrf_target("http://127.0.0.1:8080/secret").is_err());
assert!(is_ssrf_target("http://localhost:3000/api").is_err());
assert!(is_ssrf_target("http://169.254.169.254/metadata").is_err());
assert!(is_ssrf_target("http://metadata.google.internal/v1/instance").is_err());
}

#[test]
fn test_ssrf_public_ips_allowed() {
assert!(is_ssrf_target("https://api.openai.com/v1/chat").is_ok());
assert!(is_ssrf_target("https://google.com").is_ok());
}

#[test]
fn test_ssrf_scheme_validation() {
assert!(is_ssrf_target("file:///etc/passwd").is_err());
assert!(is_ssrf_target("gopher://evil.com").is_err());
assert!(is_ssrf_target("ftp://example.com").is_err());
}

#[test]
fn test_is_private_ip() {
use std::net::IpAddr;
assert!(is_private_ip(&"10.0.0.1".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(&"172.16.0.1".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(&"192.168.1.1".parse::<IpAddr>().unwrap()));
assert!(is_private_ip(&"169.254.169.254".parse::<IpAddr>().unwrap()));
assert!(!is_private_ip(&"8.8.8.8".parse::<IpAddr>().unwrap()));
assert!(!is_private_ip(&"1.1.1.1".parse::<IpAddr>().unwrap()));
}

#[test]
fn test_extract_host_from_url() {
assert_eq!(
extract_host_from_url("https://api.openai.com/v1/chat"),
"api.openai.com:443"
);
assert_eq!(
extract_host_from_url("http://localhost:8080/api"),
"localhost:8080"
);
assert_eq!(
extract_host_from_url("http://example.com"),
"example.com:80"
);
}
// SSRF and host extraction tests are now in crate::ssrf::tests
}
1 change: 1 addition & 0 deletions crates/openfang-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub mod routing;
pub mod sandbox;
pub mod session_repair;
pub mod shell_bleed;
pub mod ssrf;
pub mod str_utils;
pub mod subprocess_sandbox;
pub mod tool_policy;
Expand Down
Loading