diff --git a/Cargo.lock b/Cargo.lock index bf9e9a6..c271ad2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,10 +129,43 @@ name = "agentos-channel-email" version = "0.0.1" dependencies = [ "anyhow", + "hex", "iii-sdk", "lettre", "serde", "serde_json", + "sha2", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "agentos-channel-linkedin" +version = "0.0.1" +dependencies = [ + "anyhow", + "hex", + "hmac", + "iii-sdk", + "reqwest", + "serde", + "serde_json", + "sha2", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "agentos-channel-reddit" +version = "0.0.1" +dependencies = [ + "anyhow", + "iii-sdk", + "reqwest", + "serde", + "serde_json", "tokio", "tracing", "tracing-subscriber", @@ -183,6 +216,23 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "agentos-channel-twitch" +version = "0.0.1" +dependencies = [ + "anyhow", + "hex", + "hmac", + "iii-sdk", + "reqwest", + "serde", + "serde_json", + "sha2", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "agentos-channel-webex" version = "0.0.1" @@ -202,10 +252,13 @@ name = "agentos-channel-whatsapp" version = "0.0.1" dependencies = [ "anyhow", + "hex", + "hmac", "iii-sdk", "reqwest", "serde", "serde_json", + "sha2", "tokio", "tracing", "tracing-subscriber", @@ -609,7 +662,6 @@ version = "0.0.1" dependencies = [ "anyhow", "iii-sdk", - "reqwest", "serde", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index d913e4f..47fad20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,12 @@ members = [ "workers/approval-tiers", "workers/bridge", "workers/channel-email", + "workers/channel-linkedin", + "workers/channel-reddit", "workers/channel-signal", "workers/channel-slack", "workers/channel-teams", + "workers/channel-twitch", "workers/channel-webex", "workers/channel-whatsapp", "workers/coordination", @@ -55,6 +58,7 @@ members = [ version = "0.0.1" edition = "2024" license = "Apache-2.0" +rust-version = "1.88" [workspace.dependencies] iii-sdk = "=0.11.4-next.4" @@ -64,6 +68,7 @@ serde_json = "1" sha2 = "0.10" hmac = "0.12" hex = "0.4" +subtle = "2" uuid = { version = "1", features = ["v4"] } anyhow = "1" tracing = "0.1" diff --git a/workers/channel-email/Cargo.toml b/workers/channel-email/Cargo.toml index d259c14..396ddea 100644 --- a/workers/channel-email/Cargo.toml +++ b/workers/channel-email/Cargo.toml @@ -3,6 +3,7 @@ name = "agentos-channel-email" version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true [[bin]] name = "agentos-channel-email" @@ -16,4 +17,6 @@ serde_json.workspace = true tracing.workspace = true tracing-subscriber.workspace = true anyhow.workspace = true +sha2.workspace = true +hex.workspace = true lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] } diff --git a/workers/channel-email/src/main.rs b/workers/channel-email/src/main.rs index 68c4340..eeb044a 100644 --- a/workers/channel-email/src/main.rs +++ b/workers/channel-email/src/main.rs @@ -5,6 +5,33 @@ use lettre::transport::smtp::authentication::Credentials; use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor}; use serde_json::{Value, json}; +/// Get a secret from `vault::get` first, falling back to env var, mirroring +/// the pattern used by the other channel adapters. +async fn get_secret(iii: &III, key: &str) -> String { + let result = iii + .trigger(TriggerRequest { + function_id: "vault::get".to_string(), + payload: json!({ "key": key }), + action: None, + timeout_ms: None, + }) + .await; + if let Ok(value) = result + && let Some(v) = value.get("value").and_then(|v| v.as_str()) + && !v.is_empty() + { + return v.to_string(); + } + std::env::var(key).unwrap_or_default() +} + +/// Hash a user identifier so logs keep correlation context without leaking PII. +fn redact(value: &str) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(value.as_bytes()); + format!("sha256:{}", hex::encode(&digest[..8])) +} + /// Resolve which agent should handle a given email recipient. /// Mirrors `resolveAgent(sdk, "email", to)` from src/channels/email.ts. async fn resolve_agent(iii: &III, channel: &str, channel_id: &str) -> String { @@ -25,16 +52,20 @@ async fn resolve_agent(iii: &III, channel: &str, channel_id: &str) -> String { "default".to_string() } -/// Build an SMTP transport from env (SMTP_HOST/SMTP_PORT/SMTP_SECURE/SMTP_USER/SMTP_PASS). -fn build_transport() -> Result, IIIError> { - let host = std::env::var("SMTP_HOST").unwrap_or_else(|_| "localhost".to_string()); - let port: u16 = std::env::var("SMTP_PORT") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(587); - let secure = std::env::var("SMTP_SECURE").map(|s| s == "true").unwrap_or(false); - let user = std::env::var("SMTP_USER").unwrap_or_default(); - let pass = std::env::var("SMTP_PASS").unwrap_or_default(); +/// Build an SMTP transport, reading SMTP_* values from the vault first and +/// falling back to env. Returns the transport plus the resolved sender (`from`). +async fn build_transport( + iii: &III, +) -> Result<(AsyncSmtpTransport, String), IIIError> { + let host = { + let v = get_secret(iii, "SMTP_HOST").await; + if v.is_empty() { "localhost".to_string() } else { v } + }; + let port_raw = get_secret(iii, "SMTP_PORT").await; + let port: u16 = port_raw.parse().unwrap_or(587); + let secure = get_secret(iii, "SMTP_SECURE").await == "true"; + let user = get_secret(iii, "SMTP_USER").await; + let pass = get_secret(iii, "SMTP_PASS").await; let mut builder = if secure { AsyncSmtpTransport::::relay(&host) @@ -44,13 +75,13 @@ fn build_transport() -> Result, IIIError> { }; builder = builder.port(port); if !user.is_empty() { - builder = builder.credentials(Credentials::new(user, pass)); + builder = builder.credentials(Credentials::new(user.clone(), pass)); } - Ok(builder.build()) + Ok((builder.build(), user)) } -async fn send_mail(to: &str, subject: &str, text: &str) -> Result<(), IIIError> { - let from = std::env::var("SMTP_USER").unwrap_or_default(); +async fn send_mail(iii: &III, to: &str, subject: &str, text: &str) -> Result<(), IIIError> { + let (transport, from) = build_transport(iii).await?; if from.is_empty() { return Err(IIIError::Handler("SMTP_USER not configured".into())); } @@ -60,7 +91,6 @@ async fn send_mail(to: &str, subject: &str, text: &str) -> Result<(), IIIError> .subject(subject) .body(text.to_string()) .map_err(|e| IIIError::Handler(format!("message build: {e}")))?; - let transport = build_transport()?; transport .send(email) .await @@ -102,8 +132,14 @@ async fn handle_webhook(iii: &III, req: Value) -> Result { let reply = chat.get("content").and_then(|v| v.as_str()).unwrap_or(""); let reply_subject = format!("Re: {subject}"); - if let Err(e) = send_mail(from, &reply_subject, reply).await { - tracing::error!(to = %from, error = %e, "failed to send email reply"); + if !reply.trim().is_empty() + && let Err(e) = send_mail(iii, from, &reply_subject, reply).await + { + tracing::error!( + to_hash = %redact(from), + error = %e, + "failed to send email reply" + ); } // Fire-and-forget audit (mirrors TriggerAction.Void()). diff --git a/workers/channel-linkedin/Cargo.toml b/workers/channel-linkedin/Cargo.toml index 642f8cb..5a863da 100644 --- a/workers/channel-linkedin/Cargo.toml +++ b/workers/channel-linkedin/Cargo.toml @@ -3,6 +3,7 @@ name = "agentos-channel-linkedin" version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true [[bin]] name = "agentos-channel-linkedin" @@ -16,4 +17,7 @@ serde_json.workspace = true tracing.workspace = true tracing-subscriber.workspace = true anyhow.workspace = true +hmac.workspace = true +sha2.workspace = true +hex.workspace = true reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } diff --git a/workers/channel-linkedin/src/main.rs b/workers/channel-linkedin/src/main.rs index 2bf198f..de8cb9e 100644 --- a/workers/channel-linkedin/src/main.rs +++ b/workers/channel-linkedin/src/main.rs @@ -1,11 +1,52 @@ +use hmac::{Hmac, Mac}; use iii_sdk::error::IIIError; use iii_sdk::protocol::TriggerAction; use iii_sdk::{III, InitOptions, RegisterFunction, RegisterTriggerInput, TriggerRequest, register_worker}; use serde_json::{Value, json}; +use sha2::Sha256; +use std::time::Duration; + +type HmacSha256 = Hmac; const LINKEDIN_API: &str = "https://api.linkedin.com/v2"; const MAX_MESSAGE_LEN: usize = 4096; const MESSAGE_EVENT_KEY: &str = "com.linkedin.voyager.messaging.event.MessageEvent"; +const HTTP_TIMEOUT: Duration = Duration::from_secs(10); +const NOTIFICATION_DEDUPE_TTL_SECS: u64 = 24 * 60 * 60; + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff: u8 = 0; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +/// Compute hex-encoded HMAC-SHA256 over `body` using `secret` as the key. +fn hmac_sha256_hex(secret: &str, body: &[u8]) -> Result { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|e| format!("HMAC init error: {e}"))?; + mac.update(body); + Ok(hex::encode(mac.finalize().into_bytes())) +} + +/// Verify LinkedIn `X-LI-Signature` header per +/// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/webhook-validation +/// Header value is `hmacsha256={hex}`. +fn verify_linkedin_signature(secret: &str, raw_body: &str, signature: &str) -> Result<(), String> { + if secret.is_empty() { + return Err("LinkedIn client secret not configured".into()); + } + let computed = hmac_sha256_hex(secret, raw_body.as_bytes())?; + let expected = format!("hmacsha256={computed}"); + if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) { + return Err("Invalid LinkedIn signature".into()); + } + Ok(()) +} fn split_message(text: &str, max_len: usize) -> Vec { if text.chars().count() <= max_len { @@ -121,40 +162,59 @@ fn extract_message_text(msg_event: &Value) -> Option { }) } -async fn webhook_handler( +/// Check whether `notification_id` was already processed via the `state::*` +/// dedup scope. Returns true if newly recorded, false if duplicate. +async fn record_notification_id(iii: &III, notification_id: &str) -> bool { + if notification_id.is_empty() { + return true; + } + let key = format!("linkedin:{notification_id}"); + let existing = iii + .trigger(TriggerRequest { + function_id: "state::get".to_string(), + payload: json!({ "scope": "channel_dedupe", "key": key }), + action: None, + timeout_ms: None, + }) + .await; + if let Ok(v) = existing + && v.get("seen").and_then(|s| s.as_bool()) == Some(true) + { + return false; + } + let _ = iii + .trigger(TriggerRequest { + function_id: "state::set".to_string(), + payload: json!({ + "scope": "channel_dedupe", + "key": key, + "value": { "seen": true }, + "ttl": NOTIFICATION_DEDUPE_TTL_SECS, + }), + action: Some(TriggerAction::Void), + timeout_ms: None, + }) + .await; + true +} + +async fn process_element( iii: &III, client: &reqwest::Client, - input: Value, -) -> Result { - let body = input.get("body").cloned().unwrap_or(input); - - let element = body - .get("elements") - .and_then(|e| e.as_array()) - .and_then(|arr| arr.first()) - .cloned(); - - let Some(element) = element else { - return Ok(json!({ "status_code": 200, "body": { "ok": true } })); - }; - + element: &Value, +) -> Result<(), IIIError> { let msg_event = element .get("event") - .and_then(|e| e.get(MESSAGE_EVENT_KEY)) - .cloned(); - + .and_then(|e| e.get(MESSAGE_EVENT_KEY)); let Some(msg_event) = msg_event else { - return Ok(json!({ "status_code": 200, "body": { "ok": true } })); + return Ok(()); }; - - let Some(text) = extract_message_text(&msg_event) else { - return Ok(json!({ "status_code": 200, "body": { "ok": true } })); + let Some(text) = extract_message_text(msg_event) else { + return Ok(()); }; - if text.is_empty() { - return Ok(json!({ "status_code": 200, "body": { "ok": true } })); + return Ok(()); } - let thread_id = element .get("entityUrn") .and_then(|v| v.as_str()) @@ -165,6 +225,14 @@ async fn webhook_handler( .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); + let notification_id = element + .get("notificationId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !record_notification_id(iii, notification_id).await { + tracing::info!(notification_id, "linkedin: skipping duplicate notification"); + return Ok(()); + } let agent_id = resolve_agent(iii, "linkedin", &thread_id).await; @@ -211,6 +279,106 @@ async fn webhook_handler( timeout_ms: None, }) .await; + Ok(()) +} + +async fn webhook_handler( + iii: &III, + client: &reqwest::Client, + input: Value, +) -> Result { + let method = input + .get("method") + .and_then(|v| v.as_str()) + .unwrap_or("POST") + .to_uppercase(); + let query = input.get("query").cloned().unwrap_or_else(|| json!({})); + + // GET challenge handshake. + if method == "GET" { + let challenge = query + .get("challengeCode") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if challenge.is_empty() { + return Ok(json!({ + "status_code": 400, + "body": { "error": "Missing challengeCode" } + })); + } + let secret = get_secret(iii, "LINKEDIN_CLIENT_SECRET").await; + if secret.is_empty() { + return Ok(json!({ + "status_code": 500, + "body": { "error": "LINKEDIN_CLIENT_SECRET not configured" } + })); + } + let response = match hmac_sha256_hex(&secret, challenge.as_bytes()) { + Ok(hex) => hex, + Err(e) => { + tracing::error!(error = %e, "linkedin: challenge HMAC failed"); + return Ok(json!({ + "status_code": 500, + "body": { "error": "Challenge HMAC failed" } + })); + } + }; + return Ok(json!({ + "status_code": 200, + "body": { + "challengeCode": challenge, + "challengeResponse": response, + } + })); + } + + // Verify X-LI-Signature on POST. + let raw_body = input.get("rawBody").and_then(|v| v.as_str()); + let signature = input + .get("headers") + .and_then(|h| h.get("x-li-signature")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if let Some(raw) = raw_body { + let secret = get_secret(iii, "LINKEDIN_CLIENT_SECRET").await; + if secret.is_empty() { + return Ok(json!({ + "status_code": 500, + "body": { "error": "LINKEDIN_CLIENT_SECRET not configured" } + })); + } + if signature.is_empty() { + return Ok(json!({ + "status_code": 401, + "body": { "error": "Missing X-LI-Signature header" } + })); + } + if let Err(e) = verify_linkedin_signature(&secret, raw, signature) { + tracing::warn!(error = %e, "linkedin signature rejected"); + return Ok(json!({ + "status_code": 401, + "body": { "error": "Invalid LinkedIn signature" } + })); + } + } + + let body = input.get("body").cloned().unwrap_or(input); + + let elements: Vec = body + .get("elements") + .and_then(|e| e.as_array()) + .cloned() + .unwrap_or_default(); + + if elements.is_empty() { + return Ok(json!({ "status_code": 200, "body": { "ok": true } })); + } + + for element in &elements { + if let Err(e) = process_element(iii, client, element).await { + tracing::error!(error = %e, "failed to process LinkedIn element"); + } + } Ok(json!({ "status_code": 200, "body": { "ok": true } })) } @@ -221,7 +389,9 @@ async fn main() -> Result<(), Box> { let ws_url = std::env::var("III_WS_URL").unwrap_or_else(|_| "ws://localhost:49134".to_string()); let iii = register_worker(&ws_url, InitOptions::default()); - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .build()?; let iii_clone = iii.clone(); let client_clone = client.clone(); @@ -240,6 +410,12 @@ async fn main() -> Result<(), Box> { config: json!({ "http_method": "POST", "api_path": "webhook/linkedin" }), metadata: None, })?; + iii.register_trigger(RegisterTriggerInput { + trigger_type: "http".to_string(), + function_id: "channel::linkedin::webhook".to_string(), + config: json!({ "http_method": "GET", "api_path": "webhook/linkedin" }), + metadata: None, + })?; tracing::info!("channel-linkedin worker started"); tokio::signal::ctrl_c().await?; diff --git a/workers/channel-reddit/Cargo.toml b/workers/channel-reddit/Cargo.toml index 3dc7022..9b5f391 100644 --- a/workers/channel-reddit/Cargo.toml +++ b/workers/channel-reddit/Cargo.toml @@ -3,6 +3,7 @@ name = "agentos-channel-reddit" version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true [[bin]] name = "agentos-channel-reddit" diff --git a/workers/channel-reddit/src/main.rs b/workers/channel-reddit/src/main.rs index 05b2e63..e616631 100644 --- a/workers/channel-reddit/src/main.rs +++ b/workers/channel-reddit/src/main.rs @@ -113,7 +113,7 @@ async fn send_message( } let url = format!("{REDDIT_API_BASE}/api/comment"); - let res = client + let mut res = client .post(&url) .bearer_auth(&token) .form(&[("thing_id", parent_name), ("text", text)]) @@ -121,6 +121,21 @@ async fn send_message( .await .map_err(|e| IIIError::Handler(format!("Reddit comment error: {e}")))?; + if res.status() == reqwest::StatusCode::UNAUTHORIZED { + token = refresh_access_token(iii, client).await?; + { + let mut guard = token_cache.lock().await; + *guard = token.clone(); + } + res = client + .post(&url) + .bearer_auth(&token) + .form(&[("thing_id", parent_name), ("text", text)]) + .send() + .await + .map_err(|e| IIIError::Handler(format!("Reddit comment error: {e}")))?; + } + if !res.status().is_success() { let status = res.status(); let body = res.text().await.unwrap_or_default(); diff --git a/workers/channel-signal/Cargo.toml b/workers/channel-signal/Cargo.toml index 7c63574..dc94000 100644 --- a/workers/channel-signal/Cargo.toml +++ b/workers/channel-signal/Cargo.toml @@ -3,6 +3,7 @@ name = "agentos-channel-signal" version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true [[bin]] name = "agentos-channel-signal" diff --git a/workers/channel-signal/src/main.rs b/workers/channel-signal/src/main.rs index 83dc6ca..d7e69e4 100644 --- a/workers/channel-signal/src/main.rs +++ b/workers/channel-signal/src/main.rs @@ -166,16 +166,21 @@ async fn handle_webhook( if reply.is_empty() { tracing::warn!(channel_key = %channel_key, "signal: agent returned empty response"); return Ok(json!({ - "status_code": 500, - "body": { "error": "Empty agent response" } + "status_code": 200, + "body": { "ok": false, "error": "Empty agent response" } })); } let api_url = get_secret(iii, "SIGNAL_API_URL").await; let phone = get_secret(iii, "SIGNAL_PHONE").await; - if let Err(e) = send_message(client, &api_url, &phone, &source, reply, group_id.as_deref()).await + if let Err(e) = + send_message(client, &api_url, &phone, &source, reply, group_id.as_deref()).await { tracing::error!(channel_key = %channel_key, error = %e, "failed to send Signal reply"); + return Ok(json!({ + "status_code": 200, + "body": { "ok": false, "error": format!("{e}") } + })); } let audit_iii = iii.clone(); diff --git a/workers/channel-teams/Cargo.toml b/workers/channel-teams/Cargo.toml index 09fafe2..71364dc 100644 --- a/workers/channel-teams/Cargo.toml +++ b/workers/channel-teams/Cargo.toml @@ -3,6 +3,7 @@ name = "agentos-channel-teams" version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true [[bin]] name = "agentos-channel-teams" diff --git a/workers/channel-teams/src/main.rs b/workers/channel-teams/src/main.rs index 6a3d476..d918efe 100644 --- a/workers/channel-teams/src/main.rs +++ b/workers/channel-teams/src/main.rs @@ -1,9 +1,50 @@ use iii_sdk::error::IIIError; use iii_sdk::{III, InitOptions, RegisterFunction, RegisterTriggerInput, TriggerRequest, register_worker}; use serde_json::{Value, json}; +use std::time::Duration; const AUTH_URL: &str = "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token"; const MAX_MESSAGE_LEN: usize = 4096; +const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const HTTP_TIMEOUT: Duration = Duration::from_secs(15); + +/// Allowed Bot Framework `serviceUrl` hosts. Outbound replies are only sent +/// when the inbound activity's `serviceUrl` resolves to one of these. Operators +/// can extend the list via `TEAMS_ALLOWED_SERVICE_URLS` (comma-separated). +const DEFAULT_ALLOWED_SERVICE_URL_SUFFIXES: &[&str] = &[ + ".botframework.com", + ".botframework.azure.us", + ".botframework.cn", +]; + +fn is_allowed_service_url(url: &str, extra: &[String]) -> bool { + let Ok(parsed) = reqwest::Url::parse(url) else { + return false; + }; + if parsed.scheme() != "https" { + return false; + } + let Some(host) = parsed.host_str() else { + return false; + }; + if DEFAULT_ALLOWED_SERVICE_URL_SUFFIXES + .iter() + .any(|suffix| host.ends_with(suffix)) + { + return true; + } + extra.iter().any(|allowed| { + let allowed = allowed.trim(); + if allowed.is_empty() { + return false; + } + if let Ok(allowed_url) = reqwest::Url::parse(allowed) { + allowed_url.host_str() == Some(host) + } else { + host == allowed || host.ends_with(&format!(".{}", allowed.trim_start_matches('.'))) + } + }) +} async fn get_secret(iii: &III, key: &str) -> String { let result = iii @@ -63,7 +104,11 @@ fn split_message(text: &str, max_len: usize) -> Vec { _ => cutoff, }; chunks.push(remaining[..split_at].to_string()); - remaining = remaining[split_at..].to_string(); + remaining = if split_at < cutoff && remaining.as_bytes().get(split_at) == Some(&b'\n') { + remaining[split_at + 1..].to_string() + } else { + remaining[split_at..].to_string() + }; } chunks } @@ -189,6 +234,22 @@ async fn handle_webhook( let reply = chat.get("content").and_then(|v| v.as_str()).unwrap_or(""); if !reply.is_empty() { + let allowed_extra: Vec = get_secret(iii, "TEAMS_ALLOWED_SERVICE_URLS") + .await + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if !is_allowed_service_url(&service_url, &allowed_extra) { + tracing::warn!( + service_url = %service_url, + "rejecting Teams activity with untrusted serviceUrl" + ); + return Ok(json!({ + "status_code": 401, + "body": { "error": "Untrusted serviceUrl" } + })); + } let app_id = get_secret(iii, "TEAMS_APP_ID").await; let app_password = get_secret(iii, "TEAMS_APP_PASSWORD").await; match get_token(client, &app_id, &app_password).await { @@ -245,7 +306,10 @@ async fn main() -> Result<(), Box> { let ws_url = std::env::var("III_WS_URL").unwrap_or_else(|_| "ws://localhost:49134".to_string()); let iii = register_worker(&ws_url, InitOptions::default()); - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .timeout(HTTP_TIMEOUT) + .build()?; let iii_clone = iii.clone(); let client_clone = client.clone(); @@ -315,6 +379,32 @@ mod tests { assert_eq!(chunks.concat(), text); } + #[test] + fn allows_known_botframework_hosts() { + assert!(is_allowed_service_url( + "https://smba.trafficmanager.net.botframework.com/amer/", + &[] + )); + } + + #[test] + fn rejects_arbitrary_https_serviceurl() { + assert!(!is_allowed_service_url("https://attacker.example.com/", &[])); + } + + #[test] + fn rejects_non_https_serviceurl() { + assert!(!is_allowed_service_url("http://smba.botframework.com/", &[])); + } + + #[test] + fn allows_extra_configured_host() { + assert!(is_allowed_service_url( + "https://intranet.example.com/api/messages", + &["intranet.example.com".to_string()] + )); + } + #[test] fn url_with_trailing_slash_is_normalized() { let svc = "https://service.test.com/"; diff --git a/workers/channel-twitch/Cargo.toml b/workers/channel-twitch/Cargo.toml index e6953a3..2bd2be9 100644 --- a/workers/channel-twitch/Cargo.toml +++ b/workers/channel-twitch/Cargo.toml @@ -3,6 +3,7 @@ name = "agentos-channel-twitch" version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true [[bin]] name = "agentos-channel-twitch" @@ -16,4 +17,7 @@ serde_json.workspace = true tracing.workspace = true tracing-subscriber.workspace = true anyhow.workspace = true +hmac.workspace = true +sha2.workspace = true +hex.workspace = true reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } diff --git a/workers/channel-twitch/src/main.rs b/workers/channel-twitch/src/main.rs index fbf36de..6dd9d37 100644 --- a/workers/channel-twitch/src/main.rs +++ b/workers/channel-twitch/src/main.rs @@ -1,7 +1,11 @@ +use hmac::{Hmac, Mac}; use iii_sdk::error::IIIError; use iii_sdk::protocol::TriggerAction; use iii_sdk::{III, InitOptions, RegisterFunction, RegisterTriggerInput, TriggerRequest, register_worker}; use serde_json::{Value, json}; +use sha2::Sha256; + +type HmacSha256 = Hmac; const TWITCH_API: &str = "https://api.twitch.tv/helix"; const MAX_MESSAGE_LEN: usize = 500; @@ -68,6 +72,43 @@ async fn resolve_agent(iii: &III, channel: &str, channel_id: &str) -> String { } } +/// Constant-time signature comparison to defeat timing attacks. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff: u8 = 0; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +/// Verify Twitch EventSub signature per +/// https://dev.twitch.tv/docs/eventsub/handling-webhook-events +/// The HMAC input is `message_id + timestamp + raw_body`. +fn verify_eventsub_signature( + secret: &str, + message_id: &str, + timestamp: &str, + raw_body: &str, + signature: &str, +) -> Result<(), String> { + if secret.is_empty() { + return Err("Twitch EventSub secret not configured".into()); + } + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|e| format!("HMAC init error: {e}"))?; + mac.update(message_id.as_bytes()); + mac.update(timestamp.as_bytes()); + mac.update(raw_body.as_bytes()); + let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); + if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) { + return Err("Invalid Twitch EventSub signature".into()); + } + Ok(()) +} + async fn send_message( iii: &III, client: &reqwest::Client, @@ -82,6 +123,10 @@ async fn send_message( if client_id.is_empty() { return Err(IIIError::Handler("TWITCH_CLIENT_ID not configured".into())); } + let bot_id = get_secret(iii, "TWITCH_BOT_USER_ID").await; + if bot_id.is_empty() { + return Err(IIIError::Handler("TWITCH_BOT_USER_ID not configured".into())); + } for chunk in split_message(text, MAX_MESSAGE_LEN) { let url = format!("{TWITCH_API}/chat/messages"); let res = client @@ -91,7 +136,7 @@ async fn send_message( .header("Content-Type", "application/json") .json(&json!({ "broadcaster_id": broadcaster_id, - "sender_id": broadcaster_id, + "sender_id": bot_id, "message": chunk, })) .send() @@ -114,6 +159,43 @@ async fn webhook_handler( client: &reqwest::Client, input: Value, ) -> Result { + let raw_body = input + .get("rawBody") + .and_then(|v| v.as_str()) + .map(String::from); + let headers = input.get("headers").cloned().unwrap_or_else(|| json!({})); + let message_id = headers + .get("twitch-eventsub-message-id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let timestamp = headers + .get("twitch-eventsub-message-timestamp") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let signature = headers + .get("twitch-eventsub-message-signature") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let secret = get_secret(iii, "TWITCH_EVENTSUB_SECRET").await; + if !secret.is_empty() + && let Some(raw) = raw_body.as_deref() + { + if message_id.is_empty() || timestamp.is_empty() || signature.is_empty() { + return Ok(json!({ + "status_code": 401, + "body": { "error": "Missing Twitch EventSub headers" } + })); + } + if let Err(e) = verify_eventsub_signature(&secret, message_id, timestamp, raw, signature) { + tracing::warn!(error = %e, "twitch eventsub signature rejected"); + return Ok(json!({ + "status_code": 401, + "body": { "error": "Invalid Twitch EventSub signature" } + })); + } + } + let body = input.get("body").cloned().unwrap_or(input); // EventSub challenge handshake. diff --git a/workers/channel-webex/Cargo.toml b/workers/channel-webex/Cargo.toml index ce2d395..067de05 100644 --- a/workers/channel-webex/Cargo.toml +++ b/workers/channel-webex/Cargo.toml @@ -3,6 +3,7 @@ name = "agentos-channel-webex" version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true [[bin]] name = "agentos-channel-webex" diff --git a/workers/channel-webex/src/main.rs b/workers/channel-webex/src/main.rs index 19ef1b4..8df8aae 100644 --- a/workers/channel-webex/src/main.rs +++ b/workers/channel-webex/src/main.rs @@ -1,10 +1,16 @@ use iii_sdk::error::IIIError; use iii_sdk::{III, InitOptions, RegisterFunction, RegisterTriggerInput, TriggerRequest, register_worker}; use serde_json::{Value, json}; +use std::sync::Arc; +use tokio::sync::RwLock; const API_URL: &str = "https://webexapis.com/v1"; const MAX_MESSAGE_LEN: usize = 7439; +/// Process-local cache of the bot's own `personId`, populated lazily so we do +/// not hit `/v1/people/me` on every webhook delivery. +type BotIdCache = Arc>>; + async fn get_secret(iii: &III, key: &str) -> String { let result = iii .trigger(TriggerRequest { @@ -63,7 +69,11 @@ fn split_message(text: &str, max_len: usize) -> Vec { _ => cutoff, }; chunks.push(remaining[..split_at].to_string()); - remaining = remaining[split_at..].to_string(); + remaining = if split_at < cutoff && remaining.as_bytes().get(split_at) == Some(&b'\n') { + remaining[split_at + 1..].to_string() + } else { + remaining[split_at..].to_string() + }; } chunks } @@ -80,7 +90,12 @@ async fn fetch_message( .await .map_err(|e| IIIError::Handler(format!("Webex fetch error: {e}")))?; if !resp.status().is_success() { - return Ok(None); + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(IIIError::Handler(format!( + "Webex fetch failed ({status}): {}", + body.chars().take(300).collect::() + ))); } let body: Value = resp .json() @@ -92,6 +107,23 @@ async fn fetch_message( .map(String::from)) } +/// Fetch the bot's own personId from `/v1/people/me` so we can drop self-posted +/// webhook events. Returns `None` on any failure so the caller can skip the +/// guard and continue processing. +async fn fetch_bot_person_id(client: &reqwest::Client, token: &str) -> Option { + let resp = client + .get(format!("{API_URL}/people/me")) + .bearer_auth(token) + .send() + .await + .ok()?; + if !resp.status().is_success() { + return None; + } + let body: Value = resp.json().await.ok()?; + body.get("id").and_then(|v| v.as_str()).map(String::from) +} + async fn send_message( client: &reqwest::Client, token: &str, @@ -121,6 +153,7 @@ async fn send_message( async fn handle_webhook( iii: &III, client: &reqwest::Client, + bot_id_cache: &BotIdCache, req: Value, ) -> Result { let body = req.get("body").cloned().unwrap_or_else(|| req.clone()); @@ -155,6 +188,29 @@ async fn handle_webhook( })); } + // Drop self-posted messages so the bot does not loop on its own replies. + let bot_id = { + let cached = bot_id_cache.read().await.clone(); + match cached { + Some(id) => Some(id), + None => { + let fetched = fetch_bot_person_id(client, &webex_token).await; + if let Some(id) = &fetched { + let mut guard = bot_id_cache.write().await; + if guard.is_none() { + *guard = Some(id.clone()); + } + } + fetched + } + } + }; + if let Some(id) = bot_id.as_deref() + && id == person_id + { + return Ok(json!({ "status_code": 200, "body": { "ok": true } })); + } + let text = match fetch_message(client, &webex_token, &message_id).await? { Some(t) if !t.is_empty() => t, _ => return Ok(json!({ "status_code": 200, "body": { "ok": true } })), @@ -217,14 +273,17 @@ async fn main() -> Result<(), Box> { std::env::var("III_WS_URL").unwrap_or_else(|_| "ws://localhost:49134".to_string()); let iii = register_worker(&ws_url, InitOptions::default()); let client = reqwest::Client::new(); + let bot_id_cache: BotIdCache = Arc::new(RwLock::new(None)); let iii_clone = iii.clone(); let client_clone = client.clone(); + let cache_clone = bot_id_cache.clone(); iii.register_function( RegisterFunction::new_async("channel::webex::webhook", move |input: Value| { let iii = iii_clone.clone(); let client = client_clone.clone(); - async move { handle_webhook(&iii, &client, input).await } + let cache = cache_clone.clone(); + async move { handle_webhook(&iii, &client, &cache, input).await } }) .description("Handle Cisco Webex webhook"), ); diff --git a/workers/channel-whatsapp/Cargo.toml b/workers/channel-whatsapp/Cargo.toml index 984425f..00dcead 100644 --- a/workers/channel-whatsapp/Cargo.toml +++ b/workers/channel-whatsapp/Cargo.toml @@ -3,6 +3,7 @@ name = "agentos-channel-whatsapp" version.workspace = true edition.workspace = true license.workspace = true +rust-version.workspace = true [[bin]] name = "agentos-channel-whatsapp" @@ -16,4 +17,7 @@ serde_json.workspace = true tracing.workspace = true tracing-subscriber.workspace = true anyhow.workspace = true +hmac.workspace = true +sha2.workspace = true +hex.workspace = true reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } diff --git a/workers/channel-whatsapp/src/main.rs b/workers/channel-whatsapp/src/main.rs index 250a077..f1323aa 100644 --- a/workers/channel-whatsapp/src/main.rs +++ b/workers/channel-whatsapp/src/main.rs @@ -1,10 +1,49 @@ +use hmac::{Hmac, Mac}; use iii_sdk::error::IIIError; use iii_sdk::{III, InitOptions, RegisterFunction, RegisterTriggerInput, TriggerRequest, register_worker}; use serde_json::{Value, json}; +use sha2::Sha256; + +type HmacSha256 = Hmac; const WHATSAPP_API_BASE: &str = "https://graph.facebook.com/v18.0"; const MAX_MESSAGE_LEN: usize = 4096; +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff: u8 = 0; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +/// Verify Meta's `X-Hub-Signature-256` header +/// (https://developers.facebook.com/docs/graph-api/webhooks/getting-started) +/// using HMAC-SHA256 of the raw body keyed by the WhatsApp app secret. +fn verify_meta_signature(secret: &str, raw_body: &str, header: &str) -> Result<(), String> { + if secret.is_empty() { + return Err("WHATSAPP_APP_SECRET not configured".into()); + } + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|e| format!("HMAC init error: {e}"))?; + mac.update(raw_body.as_bytes()); + let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); + if !constant_time_eq(expected.as_bytes(), header.as_bytes()) { + return Err("Invalid X-Hub-Signature-256".into()); + } + Ok(()) +} + +/// SHA256(prefix-8) hash of a phone number for non-identifying error logs. +fn redact(value: &str) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(value.as_bytes()); + format!("sha256:{}", hex::encode(&digest[..8])) +} + /// Get a secret from `vault::get` first, falling back to env var. async fn get_secret(iii: &III, key: &str) -> String { let result = iii @@ -65,7 +104,11 @@ fn split_message(text: &str, max_len: usize) -> Vec { _ => cutoff, }; chunks.push(remaining[..split_at].to_string()); - remaining = remaining[split_at..].to_string(); + remaining = if split_at < cutoff && remaining.as_bytes().get(split_at) == Some(&b'\n') { + remaining[split_at + 1..].to_string() + } else { + remaining[split_at..].to_string() + }; } chunks } @@ -114,6 +157,70 @@ async fn handle_webhook( client: &reqwest::Client, req: Value, ) -> Result { + let method = req + .get("method") + .and_then(|v| v.as_str()) + .unwrap_or("POST") + .to_uppercase(); + + // Meta hub.challenge verification on GET. + if method == "GET" { + let query = req.get("query").cloned().unwrap_or_else(|| json!({})); + let mode = query.get("hub.mode").and_then(|v| v.as_str()).unwrap_or(""); + let token = query + .get("hub.verify_token") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let challenge = query + .get("hub.challenge") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let expected = get_secret(iii, "WHATSAPP_VERIFY_TOKEN").await; + if mode == "subscribe" + && !expected.is_empty() + && constant_time_eq(token.as_bytes(), expected.as_bytes()) + { + return Ok(json!({ + "status_code": 200, + "body": challenge, + })); + } + return Ok(json!({ + "status_code": 403, + "body": { "error": "Verification failed" } + })); + } + + // Verify X-Hub-Signature-256 on POST when raw body is available. + let raw_body = req.get("rawBody").and_then(|v| v.as_str()); + let signature = req + .get("headers") + .and_then(|h| h.get("x-hub-signature-256")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if let Some(raw) = raw_body { + let secret = get_secret(iii, "WHATSAPP_APP_SECRET").await; + if secret.is_empty() { + return Ok(json!({ + "status_code": 500, + "body": { "error": "WHATSAPP_APP_SECRET not configured" } + })); + } + if signature.is_empty() { + return Ok(json!({ + "status_code": 401, + "body": { "error": "Missing X-Hub-Signature-256 header" } + })); + } + if let Err(e) = verify_meta_signature(&secret, raw, signature) { + tracing::warn!(error = %e, "whatsapp signature rejected"); + return Ok(json!({ + "status_code": 401, + "body": { "error": "Invalid X-Hub-Signature-256" } + })); + } + } + let body = req.get("body").cloned().unwrap_or_else(|| req.clone()); if body.get("object").and_then(|v| v.as_str()) != Some("whatsapp_business_account") { @@ -149,7 +256,7 @@ async fn handle_webhook( .to_string(); let agent_id = resolve_agent(iii, "whatsapp", &from).await; - let chat = iii + let chat_result = iii .trigger(TriggerRequest { function_id: "agent::chat".to_string(), payload: json!({ @@ -160,15 +267,34 @@ async fn handle_webhook( action: None, timeout_ms: None, }) - .await - .map_err(|e| IIIError::Handler(format!("agent::chat failed: {e}")))?; + .await; + + let reply = match &chat_result { + Ok(chat) => chat + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + Err(e) => { + tracing::error!( + agent = %agent_id, + from_hash = %redact(&from), + error = %e, + "agent::chat failed" + ); + String::new() + } + }; - let reply = chat.get("content").and_then(|v| v.as_str()).unwrap_or(""); if !reply.is_empty() { let token = get_secret(iii, "WHATSAPP_TOKEN").await; let phone_id = get_secret(iii, "WHATSAPP_PHONE_ID").await; - if let Err(e) = send_message(client, &token, &phone_id, &from, reply).await { - tracing::error!(to = %from, error = %e, "failed to send WhatsApp reply"); + if let Err(e) = send_message(client, &token, &phone_id, &from, &reply).await { + tracing::error!( + to_hash = %redact(&from), + error = %e, + "failed to send WhatsApp reply" + ); } } @@ -219,6 +345,12 @@ async fn main() -> Result<(), Box> { config: json!({ "http_method": "POST", "api_path": "webhook/whatsapp" }), metadata: None, })?; + iii.register_trigger(RegisterTriggerInput { + trigger_type: "http".to_string(), + function_id: "channel::whatsapp::webhook".to_string(), + config: json!({ "http_method": "GET", "api_path": "webhook/whatsapp" }), + metadata: None, + })?; tracing::info!("channel-whatsapp worker started"); tokio::signal::ctrl_c().await?;