ZeptoClaw: Generic webhook channel trusts caller-supplied identity fields; allowlist is checked against untrusted payload data
Description
ZeptoClaw is a personal AI assistant. Prior to 0.7.6, the generic webhook channel trusts caller-supplied identity fields (sender, chat_id) from the request body and applies authorization checks to those untrusted values. Because authentication is optional and defaults to disabled (auth_token: None), an attacker who can reach POST /webhook can spoof an allowlisted sender and choose arbitrary chat_id values, enabling high-risk message spoofing and potential IDOR-style session/chat routing abuse. This vulnerability is fixed in 0.7.6.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
ZeptoClaw prior to 0.7.6 trusts webhook caller-supplied identity fields; spoofing and IDOR attacks possible.
Vulnerability
Details The vulnerability in ZeptoClaw prior to version 0.7.6 lies in the generic webhook channel, which trusts identity fields (sender, chat_id) supplied by the caller in the request body. The system applies authorization checks against these untrusted values, and authentication is optional, defaulting to disabled (auth_token: None). This means an attacker who can reach the POST /webhook endpoint can spoof an allowlisted sender and choose arbitrary chat_id values, as described in the CVE and security advisory [2][4].
Exploitation
Scenario Attackers can exploit this by sending a crafted HTTP POST request to the webhook endpoint without any authorization header, using a payload that sets sender to a value present in the allow_from list and any desired chat_id. The PoC in the advisory demonstrates this with a simple curl command [4]. The attack vector requires network access to the webhook endpoint; exposure to the internet or untrusted networks makes exploitation trivial.
Impact
Successful exploitation enables high-risk message spoofing, where the attacker can impersonate trusted senders and inject commands or content. Additionally, because chat_id is attacker-controlled, IDOR-style session or chat routing abuse is possible, potentially allowing the attacker to read or write messages in arbitrary conversations [2][4].
Mitigation
The vulnerability is fixed in version 0.7.6. The fix in commit bf004a20 introduces proper authentication enforcement and validates identity fields against the configured allowlist [3]. Users should upgrade immediately or, as a workaround, ensure the webhook endpoint is not exposed to untrusted networks and configure a strong auth_token.
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
zeptoclawcrates.io | < 0.7.6 | 0.7.6 |
Affected products
2- qhkm/zeptoclawv5Range: < 0.7.6
Patches
1bf004a20d368fix: harden inbound auth and filesystem boundaries (#324)
17 files changed · +1280 −102
AGENTS.md+4 −1 modified@@ -21,6 +21,9 @@ Project-level guidance for coding agents working in this repository. - Channel dispatch: avoids holding the channels map `RwLock` across async `send()` awaits - Channel supervisor: polling (15s) detects dead channels, restarts with 60s cooldown, max 5 restarts - Channel panic isolation: Slack/Discord/Webhook/WhatsApp/WhatsApp Web/WhatsApp Cloud/Lark/Email/MQTT/Serial spawned tasks are wrapped with `catch_unwind` and panic logging +- Webhook auth hardening: generic webhook supports optional HMAC-SHA256 body signatures plus fixed server-side sender/chat identity by default (`trust_payload_identity` is an explicit legacy escape hatch); WhatsApp Cloud verifies `X-Hub-Signature-256` when `app_secret` is configured +- Telegram allowlist hardening: numeric user IDs are the safe default for new setups; legacy username matching remains available only through `channels.telegram.allow_usernames` for compatibility and emits warnings when non-numeric allowlist entries are present +- Email allowlist limitation surfaced: `channels.email.allowed_senders` matches the parsed `From` header only and now emits config/runtime warnings so authenticated-mail enforcement is pushed upstream - Telegram outbound formatting: sends HTML parse mode with `||spoiler||` → `<tg-spoiler>` conversion - Discord outbound delivery: supports reply references and thread-create metadata (`discord_thread_*`) in `OutboundMessage` - Cron scheduling hardening: dispatch timeout + exponential error backoff + one-shot delete-after-run only on success @@ -30,7 +33,7 @@ Project-level guidance for coding agents working in this repository. - Memory injection: per-message query-matched injection via shared LTM on `AgentLoop` (startup static injection removed) - Tool execution convergence: agent loop and MCP server both route through `kernel::execute_tool()` (shared safety scan + taint checks + single metrics recording) - Tool composition: natural language tool creation with `{{param}}` template interpolation -- Lightweight mount validator hardening: `validate_mount_not_blocked` now checks unresolved + canonical host paths and rejects Unix regular-file mounts with multiple hard links +- Filesystem hardening: filesystem write/edit tools now create parent directories one component at a time inside the workspace and use secure no-follow writes; mount validation rejects Unix regular-file mounts with multiple hard links in both blocked-path and allowlist flows - Gateway startup guard: degrade after N crashes to prevent crash loops - Loop guard: SHA256 tool-call repetition detection with warn + circuit-breaker stop - Tool execution hardening: per-tool-call timeout + panic capture in both `process_message` and `process_message_streaming` tool `join_all` paths
Cargo.lock+1 −0 modified@@ -7129,6 +7129,7 @@ dependencies = [ "jsonwebtoken", "landlock", "lettre", + "libc", "lopdf", "mail-parser", "mockall",
Cargo.toml+2 −0 modified@@ -89,6 +89,8 @@ subtle = "2.5" hex = "0.4" # HMAC-SHA256 for CSRF token generation/validation ring = "0.17" +# Platform-specific filesystem flags such as O_NOFOLLOW +libc = "0.2" # ============================================================================= # ASYNC UTILITIES
CLAUDE.md+10 −9 modified@@ -264,12 +264,12 @@ src/ │ ├── manager.rs # Channel lifecycle management │ ├── model_switch.rs # /model command parsing + model registry + persistence │ ├── persona_switch.rs # /persona command parsing + preset registry + LTM persistence -│ ├── telegram.rs # Telegram bot channel (HTML parse mode + ||spoiler|| support) +│ ├── telegram.rs # Telegram bot channel (HTML parse mode + ||spoiler|| support, numeric-ID allowlist default for new setups) │ ├── slack.rs # Slack outbound channel │ ├── discord.rs # Discord Gateway WebSocket + REST (reply + thread create) -│ ├── webhook.rs # Generic HTTP webhook inbound +│ ├── webhook.rs # Generic HTTP webhook inbound with optional Bearer + HMAC auth and fixed server-side identity │ ├── whatsapp_web.rs # WhatsApp Web via wa-rs native (feature: whatsapp-web) -│ ├── whatsapp_cloud.rs # WhatsApp Cloud API (official webhook + REST) +│ ├── whatsapp_cloud.rs # WhatsApp Cloud API (official signed webhook + REST) │ ├── lark.rs # Lark/Feishu messaging (WS long-connection) │ ├── email_channel.rs # Email channel (IMAP IDLE + SMTP) │ ├── mqtt.rs # MQTT channel for IoT device messaging (feature: mqtt) @@ -336,7 +336,7 @@ src/ │ ├── binary_plugin.rs # Binary plugin adapter (JSON-RPC 2.0 stdin/stdout) │ ├── shell.rs # Shell execution with runtime isolation │ ├── diff.rs # Unified diff parser/applier (used by edit_file) -│ ├── filesystem.rs # Read, write, list, edit files (4 tools: read, write, list, edit + diff mode) +│ ├── filesystem.rs # Read, write, list, edit files with secure parent-dir creation and no-follow writes │ ├── find.rs # File discovery by glob pattern (FindTool) │ ├── grep.rs # Codebase search by regex pattern (GrepTool) │ ├── web.rs # Web search (Brave + DuckDuckGo + SearXNG) and fetch with SSRF protection @@ -449,12 +449,13 @@ OAuth support with PKCE, CSRF state validation, encrypted token persistence, and ### Channels (`src/channels/`) Message input channels via `Channel` trait: -- `TelegramChannel` - Telegram bot integration +- `TelegramChannel` - Telegram bot integration with numeric-ID allowlists by default for new setups and legacy username matching behind `allow_usernames` - `SlackChannel` - Slack outbound messaging - `DiscordChannel` - Discord Gateway WebSocket + REST API messaging (replies + thread creation) -- `WebhookChannel` - Generic HTTP POST inbound with optional Bearer auth +- `WebhookChannel` - Generic HTTP POST inbound with optional Bearer auth, HMAC-SHA256 body signing, and fixed server-side sender/chat identity by default - `WhatsAppWebChannel` - WhatsApp Web via wa-rs native client (QR pairing, feature: whatsapp-web) -- `WhatsAppCloudChannel` - WhatsApp Cloud API (webhook inbound + REST outbound, no bridge) +- `WhatsAppCloudChannel` - WhatsApp Cloud API (signed webhook inbound + REST outbound, no bridge) +- `EmailChannel` - IMAP IDLE + SMTP email channel; sender allowlist is parsed From-header trust only and warns accordingly - `MqttChannel` - MQTT messaging for IoT devices over WiFi/network (rumqttc, feature: mqtt) - `SerialChannel` - UART serial messaging (line-delimited JSON, feature: hardware) - CLI mode via direct agent invocation @@ -565,8 +566,8 @@ Panel web dashboard backend: ### Security (`src/security/`) - `shell.rs` - Regex-based command blocklist + optional allowlist (`ShellAllowlistMode`: Off/Warn/Strict); includes `.zeptoclaw/config.json` blocklist to prevent LLM-driven config exfiltration -- `path.rs` - Workspace path validation, symlink escape detection -- `mount.rs` - Mount allowlist validation, docker binary verification, host-path `..` traversal rejection, lightweight blocked-path checks on unresolved paths plus canonical host paths when the source exists, and Unix hardlink alias rejection for regular-file mounts +- `path.rs` - Workspace path validation, symlink escape detection, and secure directory-chain creation for write paths +- `mount.rs` - Mount allowlist validation, docker binary verification, host-path `..` traversal rejection, lightweight blocked-path checks on unresolved paths plus canonical host paths when the source exists, and Unix hardlink alias rejection for regular-file mounts in both blocked-path and allowlist validation flows - `encryption.rs` - `SecretEncryption`: XChaCha20-Poly1305 AEAD + Argon2id KDF, `ENC[...]` ciphertext format, `resolve_master_key()` for env/file/prompt sources, transparent config decrypt on load ### Tunnel (`src/tunnel/`)
src/channels/email_channel.rs+19 −0 modified@@ -21,6 +21,11 @@ //! } //! } //! ``` +//! +//! `allowed_senders` is matched against the parsed inbound `From` header. +//! This is a trust-model limitation of IMAP/header-based ingestion, not a +//! cryptographic sender-authentication guarantee. If sender authenticity +//! matters, enforce SPF/DKIM/DMARC upstream before messages reach this channel. #[cfg(feature = "channel-email")] use futures::FutureExt; @@ -65,6 +70,14 @@ pub struct EmailChannel { impl EmailChannel { /// Create a new `EmailChannel` from configuration. pub fn new(config: EmailConfig, bus: Arc<MessageBus>) -> Self { + #[cfg(feature = "channel-email")] + if config.enabled && !config.allowed_senders.is_empty() { + warn!( + "Email allowed_senders relies on the parsed From header only. \ + Enforce SPF/DKIM/DMARC upstream if sender authenticity matters." + ); + } + let base_config = BaseChannelConfig { name: "email".to_string(), allowlist: config.allowed_senders.clone(), @@ -447,6 +460,12 @@ impl Channel for EmailChannel { "Email channel starting (IMAP IDLE on {})", self.config.imap_host ); + if !self.config.allowed_senders.is_empty() { + warn!( + "Email allowed_senders checks parsed From headers only; \ + configure authenticated-mail enforcement upstream if sender authenticity matters." + ); + } Ok(()) } }
src/channels/factory.rs+5 −0 modified@@ -89,6 +89,11 @@ pub async fn register_configured_channels( port: webhook_config.port, path: webhook_config.path.clone(), auth_token: webhook_config.auth_token.clone(), + signature_secret: webhook_config.signature_secret.clone(), + signature_header: webhook_config.signature_header.clone(), + sender_id: webhook_config.sender_id.clone(), + chat_id: webhook_config.chat_id.clone(), + trust_payload_identity: webhook_config.trust_payload_identity, }; let base_config = BaseChannelConfig { name: "webhook".to_string(),
src/channels/telegram.rs+83 −10 modified@@ -69,6 +69,8 @@ use super::{BaseChannelConfig, Channel}; /// silently overwrites earlier ones. #[derive(Clone)] struct Allowlist(Vec<String>); +#[derive(Clone, Copy)] +struct AllowUsernames(bool); #[derive(Clone)] struct DefaultModel(String); #[derive(Clone)] @@ -117,6 +119,35 @@ fn render_telegram_html(content: &str) -> String { out } +fn is_numeric_allowlist_entry(entry: &str) -> bool { + let trimmed = entry.trim(); + !trimmed.is_empty() && trimmed.bytes().all(|b| b.is_ascii_digit()) +} + +fn allowlist_has_username_entries(allowlist: &[String]) -> bool { + allowlist + .iter() + .any(|entry| !is_numeric_allowlist_entry(entry)) +} + +fn telegram_allowlist_allows( + allowlist: &[String], + user_id: &str, + username: &str, + allow_usernames: bool, +) -> bool { + allowlist.contains(&user_id.to_string()) + || (allow_usernames + && !username.is_empty() + && allowlist.iter().any(|entry| { + let entry_lower = entry.trim().to_lowercase(); + let user_lower = username.to_lowercase(); + entry_lower == user_lower + || entry_lower == format!("@{user_lower}") + || format!("@{entry_lower}") == user_lower + })) +} + /// Telegram channel implementation using teloxide. /// /// This channel connects to Telegram's Bot API to receive and send messages. @@ -193,6 +224,18 @@ impl TelegramChannel { configured_models: Vec<(String, String)>, memory_enabled: bool, ) -> Self { + if allowlist_has_username_entries(&config.allow_from) { + if config.allow_usernames { + warn!( + "Telegram allow_from contains username entries. Username matching is a legacy compatibility mode and can drift if usernames are reassigned; migrate to numeric user IDs and set channels.telegram.allow_usernames=false when ready." + ); + } else { + warn!( + "Telegram allow_from contains non-numeric entries, but channels.telegram.allow_usernames=false so only numeric user IDs will match." + ); + } + } + let base_config = BaseChannelConfig { name: "telegram".to_string(), allowlist: config.allow_from.clone(), @@ -312,6 +355,7 @@ impl Channel for TelegramChannel { let token = self.config.token.clone(); let bus = self.bus.clone(); let allowlist = Allowlist(self.config.allow_from.clone()); + let allow_usernames = AllowUsernames(self.config.allow_usernames); let deny_by_default = self.config.deny_by_default; let overrides_dep = OverridesDep { model: self.model_overrides.clone(), @@ -408,6 +452,7 @@ impl Channel for TelegramChannel { msg: Message, bus: Arc<MessageBus>, Allowlist(allowlist): Allowlist, + AllowUsernames(allow_usernames): AllowUsernames, deny_by_default: bool, overrides_dep: OverridesDep, DefaultModel(default_model): DefaultModel, @@ -427,19 +472,15 @@ impl Channel for TelegramChannel { .unwrap_or_default(); // Check allowlist with deny_by_default support. - // Accepts both numeric IDs ("123456") and usernames ("alice" or "@alice"). let allowed = if allowlist.is_empty() { !deny_by_default } else { - allowlist.contains(&user_id) - || (!username.is_empty() - && allowlist.iter().any(|entry| { - let entry_lower = entry.to_lowercase(); - let user_lower = username.to_lowercase(); - entry_lower == user_lower - || entry_lower == format!("@{user_lower}") - || format!("@{entry_lower}") == user_lower - })) + telegram_allowlist_allows( + &allowlist, + &user_id, + &username, + allow_usernames, + ) }; if !allowed { if allowlist.is_empty() { @@ -763,6 +804,7 @@ impl Channel for TelegramChannel { .dependencies(dptree::deps![ bus, allowlist, + allow_usernames, deny_by_default, overrides_dep, default_model, @@ -1016,6 +1058,37 @@ mod tests { assert!(!channel.is_allowed("hacker")); } + #[test] + fn test_telegram_allowlist_allows_numeric_user_id_without_usernames() { + let allowlist = vec!["123456".to_string()]; + assert!(telegram_allowlist_allows( + &allowlist, "123456", "alice", false + )); + assert!(!telegram_allowlist_allows( + &allowlist, "999999", "alice", false + )); + } + + #[test] + fn test_telegram_allowlist_rejects_username_when_disabled() { + let allowlist = vec!["alice".to_string(), "@bob".to_string()]; + assert!(!telegram_allowlist_allows( + &allowlist, "123456", "alice", false + )); + assert!(!telegram_allowlist_allows( + &allowlist, "123456", "bob", false + )); + } + + #[test] + fn test_telegram_allowlist_allows_legacy_username_when_enabled() { + let allowlist = vec!["alice".to_string(), "@bob".to_string()]; + assert!(telegram_allowlist_allows( + &allowlist, "123456", "alice", true + )); + assert!(telegram_allowlist_allows(&allowlist, "123456", "bob", true)); + } + #[test] fn test_render_telegram_html_escapes_html() { let rendered = render_telegram_html("5 < 7 & 9 > 2");
src/channels/webhook.rs+481 −42 modified@@ -27,9 +27,7 @@ //! Authorization: Bearer <optional-token> //! //! { -//! "message": "Hello, ZeptoClaw!", -//! "sender": "external-service", -//! "chat_id": "webhook-chat-123" +//! "message": "Hello, ZeptoClaw!" //! } //! ``` //! @@ -50,6 +48,7 @@ use async_trait::async_trait; use futures::FutureExt; use serde::Deserialize; +use sha2::Digest; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -79,11 +78,40 @@ fn constant_time_eq(a: &str, b: &str) -> bool { result == 0 } +fn hmac_sha256_hex(key: &[u8], message: &[u8]) -> String { + let mut k = [0u8; SHA256_BLOCK_SIZE]; + if key.len() > SHA256_BLOCK_SIZE { + let hashed = sha2::Sha256::digest(key); + k[..SHA256_OUTPUT_SIZE].copy_from_slice(&hashed); + } else { + k[..key.len()].copy_from_slice(key); + } + + let mut k_ipad = [0u8; SHA256_BLOCK_SIZE]; + let mut k_opad = [0u8; SHA256_BLOCK_SIZE]; + for i in 0..SHA256_BLOCK_SIZE { + k_ipad[i] = k[i] ^ 0x36; + k_opad[i] = k[i] ^ 0x5c; + } + + let mut inner = sha2::Sha256::new(); + inner.update(k_ipad); + inner.update(message); + let inner_result = inner.finalize(); + + let mut outer = sha2::Sha256::new(); + outer.update(k_opad); + outer.update(inner_result); + hex::encode(outer.finalize()) +} + /// Maximum allowed request body size (1 MB). const MAX_BODY_SIZE: usize = 1_048_576; /// Maximum allowed header section size (8 KB). const MAX_HEADER_SIZE: usize = 8_192; +const SHA256_BLOCK_SIZE: usize = 64; +const SHA256_OUTPUT_SIZE: usize = 32; // --- HTTP response constants --- @@ -110,6 +138,17 @@ pub struct WebhookChannelConfig { /// Optional Bearer token for request authentication. /// When set, all requests must include a matching `Authorization: Bearer <token>` header. pub auth_token: Option<String>, + /// Optional HMAC secret for request signature verification. + pub signature_secret: Option<String>, + /// Header carrying the HMAC signature when `signature_secret` is configured. + pub signature_header: String, + /// Server-controlled sender ID used when `trust_payload_identity` is disabled. + pub sender_id: Option<String>, + /// Optional server-controlled chat ID used when `trust_payload_identity` is disabled. + /// Falls back to `sender_id` when omitted. + pub chat_id: Option<String>, + /// When true, accept caller-supplied `sender` and `chat_id` from the JSON payload. + pub trust_payload_identity: bool, } impl Default for WebhookChannelConfig { @@ -119,6 +158,11 @@ impl Default for WebhookChannelConfig { port: 9876, path: "/webhook".to_string(), auth_token: None, + signature_secret: None, + signature_header: "X-ZeptoClaw-Signature-256".to_string(), + sender_id: None, + chat_id: None, + trust_payload_identity: false, } } } @@ -129,9 +173,11 @@ struct WebhookPayload { /// The message content. message: String, /// Identifier of the sender. - sender: String, + #[serde(default)] + sender: Option<String>, /// Chat/conversation identifier for session routing. - chat_id: String, + #[serde(default)] + chat_id: Option<String>, } /// Parsed representation of an incoming HTTP request (first line + headers + body). @@ -231,6 +277,99 @@ impl WebhookChannel { }) } + /// Validate an optional request signature header against the configured secret. + /// + /// The expected format is `sha256=<hex-digest>`, matching the WhatsApp Cloud + /// webhook convention so operators can reuse existing tooling. + fn validate_signature( + headers: &[(String, String)], + body: &str, + header_name: &str, + secret: &Option<String>, + ) -> bool { + let secret = match secret { + Some(secret) => secret, + None => return true, + }; + + let provided = headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(header_name)) + .map(|(_, value)| value.trim()); + + let provided = match provided { + Some(value) => value, + None => return false, + }; + + let expected = format!( + "sha256={}", + hmac_sha256_hex(secret.as_bytes(), body.as_bytes()) + ); + constant_time_eq(provided, &expected) + } + + fn validate_runtime_config(config: &WebhookChannelConfig) -> Result<()> { + if config.trust_payload_identity { + return Ok(()); + } + + let sender_id = config + .sender_id + .as_deref() + .map(str::trim) + .unwrap_or_default(); + if sender_id.is_empty() { + return Err(ZeptoError::Config( + "Webhook channel requires sender_id unless trust_payload_identity=true".to_string(), + )); + } + + Ok(()) + } + + fn resolve_identity( + payload: &WebhookPayload, + config: &WebhookChannelConfig, + ) -> Result<(String, String)> { + if config.trust_payload_identity { + let sender = payload.sender.as_deref().map(str::trim).unwrap_or_default(); + let chat_id = payload + .chat_id + .as_deref() + .map(str::trim) + .unwrap_or_default(); + + if sender.is_empty() || chat_id.is_empty() { + return Err(ZeptoError::Channel( + "message, sender, and chat_id must be non-empty".to_string(), + )); + } + + return Ok((sender.to_string(), chat_id.to_string())); + } + + let sender = config + .sender_id + .as_deref() + .map(str::trim) + .unwrap_or_default(); + if sender.is_empty() { + return Err(ZeptoError::Config( + "Webhook channel requires sender_id unless trust_payload_identity=true".to_string(), + )); + } + + let chat_id = config + .chat_id + .as_deref() + .map(str::trim) + .filter(|chat_id| !chat_id.is_empty()) + .unwrap_or(sender); + + Ok((sender.to_string(), chat_id.to_string())) + } + /// Parse a raw HTTP request from bytes into structured parts. /// /// This is intentionally minimal — it only handles what the webhook needs: @@ -384,6 +523,16 @@ impl WebhookChannel { return; } + if !Self::validate_signature( + &request.headers, + &request.body, + &config.signature_header, + &config.signature_secret, + ) { + let _ = stream.write_all(HTTP_401_UNAUTHORIZED.as_bytes()).await; + return; + } + // Parse JSON body let payload: WebhookPayload = match serde_json::from_str(&request.body) { Ok(p) => p, @@ -401,12 +550,8 @@ impl WebhookChannel { } }; - // Validate required fields are non-empty - if payload.message.trim().is_empty() - || payload.sender.trim().is_empty() - || payload.chat_id.trim().is_empty() - { - let body = r#"{"error":"message, sender, and chat_id must be non-empty"}"#; + if payload.message.trim().is_empty() { + let body = r#"{"error":"message must be non-empty"}"#; let response = format!( "{}Content-Length: {}\r\n\r\n{}", HTTP_400_BAD_REQUEST, @@ -417,23 +562,31 @@ impl WebhookChannel { return; } + let (sender_id, chat_id) = match Self::resolve_identity(&payload, config) { + Ok(identity) => identity, + Err(e) => { + debug!("Webhook: invalid identity configuration or payload: {}", e); + let body = format!("{{\"error\":\"{}\"}}", e); + let response = format!( + "{}Content-Length: {}\r\n\r\n{}", + HTTP_400_BAD_REQUEST, + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + return; + } + }; + // Check allowlist - if !base_config.is_allowed(&payload.sender) { - info!( - "Webhook: sender {} not in allowlist, rejecting", - payload.sender - ); + if !base_config.is_allowed(&sender_id) { + info!("Webhook: sender {} not in allowlist, rejecting", sender_id); let _ = stream.write_all(HTTP_401_UNAUTHORIZED.as_bytes()).await; return; } // Build and publish inbound message - let inbound = InboundMessage::new( - "webhook", - payload.sender.trim(), - payload.chat_id.trim(), - payload.message.trim(), - ); + let inbound = InboundMessage::new("webhook", &sender_id, &chat_id, payload.message.trim()); if let Err(e) = bus.publish_inbound(inbound).await { error!("Webhook: failed to publish inbound message: {}", e); @@ -449,7 +602,7 @@ impl WebhookChannel { info!( "Webhook: received message from {} in chat {}", - payload.sender, payload.chat_id + sender_id, chat_id ); let _ = stream.write_all(HTTP_200_OK.as_bytes()).await; } @@ -483,6 +636,11 @@ impl Channel for WebhookChannel { return Ok(()); } + if let Err(e) = Self::validate_runtime_config(&self.config) { + self.running.store(false, Ordering::SeqCst); + return Err(e); + } + let bind_addr = format!("{}:{}", self.config.bind_address, self.config.port); let listener = TcpListener::bind(&bind_addr).await.map_err(|e| { @@ -626,6 +784,11 @@ mod tests { assert_eq!(config.port, 9876); assert_eq!(config.path, "/webhook"); assert!(config.auth_token.is_none()); + assert!(config.signature_secret.is_none()); + assert_eq!(config.signature_header, "X-ZeptoClaw-Signature-256"); + assert!(config.sender_id.is_none()); + assert!(config.chat_id.is_none()); + assert!(!config.trust_payload_identity); } #[test] @@ -635,11 +798,21 @@ mod tests { port: 8080, path: "/api/hook".to_string(), auth_token: Some("secret-token".to_string()), + signature_secret: Some("sig-secret".to_string()), + signature_header: "X-Test-Signature".to_string(), + sender_id: Some("service-a".to_string()), + chat_id: Some("chat-a".to_string()), + trust_payload_identity: false, }; assert_eq!(config.bind_address, "0.0.0.0"); assert_eq!(config.port, 8080); assert_eq!(config.path, "/api/hook"); assert_eq!(config.auth_token, Some("secret-token".to_string())); + assert_eq!(config.signature_secret, Some("sig-secret".to_string())); + assert_eq!(config.signature_header, "X-Test-Signature"); + assert_eq!(config.sender_id, Some("service-a".to_string())); + assert_eq!(config.chat_id, Some("chat-a".to_string())); + assert!(!config.trust_payload_identity); } // ----------------------------------------------------------------------- @@ -751,6 +924,117 @@ mod tests { assert!(!WebhookChannel::validate_auth(&headers, &token)); } + #[test] + fn test_signature_validation_not_required_without_secret() { + let headers = vec![("Content-Type".to_string(), "application/json".to_string())]; + assert!(WebhookChannel::validate_signature( + &headers, + r#"{"message":"ok"}"#, + "X-Test-Signature", + &None, + )); + } + + #[test] + fn test_signature_validation_valid_signature() { + let secret = Some("topsecret".to_string()); + let body = r#"{"message":"hello","sender":"svc","chat_id":"c1"}"#; + let sig = format!("sha256={}", hmac_sha256_hex(b"topsecret", body.as_bytes())); + let headers = vec![("X-Test-Signature".to_string(), sig)]; + assert!(WebhookChannel::validate_signature( + &headers, + body, + "X-Test-Signature", + &secret, + )); + } + + #[test] + fn test_signature_validation_missing_header_when_required() { + let secret = Some("topsecret".to_string()); + let headers = vec![("Content-Type".to_string(), "application/json".to_string())]; + assert!(!WebhookChannel::validate_signature( + &headers, + r#"{"message":"ok"}"#, + "X-Test-Signature", + &secret, + )); + } + + #[test] + fn test_signature_validation_invalid_signature() { + let secret = Some("topsecret".to_string()); + let headers = vec![( + "X-Test-Signature".to_string(), + "sha256=deadbeef".to_string(), + )]; + assert!(!WebhookChannel::validate_signature( + &headers, + r#"{"message":"ok"}"#, + "X-Test-Signature", + &secret, + )); + } + + #[test] + fn test_validate_runtime_config_requires_sender_id_by_default() { + let config = WebhookChannelConfig::default(); + assert!(WebhookChannel::validate_runtime_config(&config).is_err()); + } + + #[test] + fn test_validate_runtime_config_allows_trusted_payload_identity() { + let config = WebhookChannelConfig { + trust_payload_identity: true, + ..WebhookChannelConfig::default() + }; + assert!(WebhookChannel::validate_runtime_config(&config).is_ok()); + } + + #[test] + fn test_resolve_identity_uses_configured_identity() { + let payload: WebhookPayload = + serde_json::from_str(r#"{"message":"hello","sender":"ignored","chat_id":"ignored"}"#) + .unwrap(); + let config = WebhookChannelConfig { + sender_id: Some("fixed-sender".to_string()), + chat_id: Some("fixed-chat".to_string()), + ..WebhookChannelConfig::default() + }; + + let (sender_id, chat_id) = WebhookChannel::resolve_identity(&payload, &config).unwrap(); + assert_eq!(sender_id, "fixed-sender"); + assert_eq!(chat_id, "fixed-chat"); + } + + #[test] + fn test_resolve_identity_falls_back_chat_id_to_sender_id() { + let payload: WebhookPayload = serde_json::from_str(r#"{"message":"hello"}"#).unwrap(); + let config = WebhookChannelConfig { + sender_id: Some("fixed-sender".to_string()), + ..WebhookChannelConfig::default() + }; + + let (sender_id, chat_id) = WebhookChannel::resolve_identity(&payload, &config).unwrap(); + assert_eq!(sender_id, "fixed-sender"); + assert_eq!(chat_id, "fixed-sender"); + } + + #[test] + fn test_resolve_identity_uses_payload_when_legacy_mode_enabled() { + let payload: WebhookPayload = + serde_json::from_str(r#"{"message":"hello","sender":"svc","chat_id":"chat-1"}"#) + .unwrap(); + let config = WebhookChannelConfig { + trust_payload_identity: true, + ..WebhookChannelConfig::default() + }; + + let (sender_id, chat_id) = WebhookChannel::resolve_identity(&payload, &config).unwrap(); + assert_eq!(sender_id, "svc"); + assert_eq!(chat_id, "chat-1"); + } + // ----------------------------------------------------------------------- // 5. HTTP request parsing // ----------------------------------------------------------------------- @@ -793,13 +1077,13 @@ mod tests { let json = r#"{"message":"hello","sender":"svc-a","chat_id":"chat-1"}"#; let payload: WebhookPayload = serde_json::from_str(json).expect("should parse"); assert_eq!(payload.message, "hello"); - assert_eq!(payload.sender, "svc-a"); - assert_eq!(payload.chat_id, "chat-1"); + assert_eq!(payload.sender.as_deref(), Some("svc-a")); + assert_eq!(payload.chat_id.as_deref(), Some("chat-1")); } #[test] fn test_webhook_payload_missing_fields() { - let json = r#"{"message":"hello"}"#; + let json = r#"{}"#; let result: std::result::Result<WebhookPayload, _> = serde_json::from_str(json); assert!(result.is_err()); } @@ -926,13 +1210,23 @@ mod tests { port: 3000, path: "/hooks/inbound".to_string(), auth_token: Some("abc".to_string()), + signature_secret: Some("sig".to_string()), + signature_header: "X-Signature".to_string(), + sender_id: Some("fixed-sender".to_string()), + chat_id: Some("fixed-chat".to_string()), + trust_payload_identity: false, }; let channel = WebhookChannel::new(config, BaseChannelConfig::new("webhook"), test_bus()); let cfg = channel.webhook_config(); assert_eq!(cfg.bind_address, "10.0.0.1"); assert_eq!(cfg.port, 3000); assert_eq!(cfg.path, "/hooks/inbound"); assert_eq!(cfg.auth_token, Some("abc".to_string())); + assert_eq!(cfg.signature_secret, Some("sig".to_string())); + assert_eq!(cfg.signature_header, "X-Signature"); + assert_eq!(cfg.sender_id, Some("fixed-sender".to_string())); + assert_eq!(cfg.chat_id, Some("fixed-chat".to_string())); + assert!(!cfg.trust_payload_identity); } // ----------------------------------------------------------------------- @@ -949,6 +1243,11 @@ mod tests { port: 0, path: "/webhook".to_string(), auth_token: None, + signature_secret: None, + signature_header: "X-ZeptoClaw-Signature-256".to_string(), + sender_id: Some("svc".to_string()), + chat_id: Some("ch1".to_string()), + trust_payload_identity: false, }; // We need to bind ourselves first to discover the actual port, then @@ -979,28 +1278,31 @@ mod tests { // Parse body let payload: WebhookPayload = serde_json::from_str(&req.body).expect("should parse body"); assert_eq!(payload.message, "integration"); - assert_eq!(payload.sender, "svc"); - assert_eq!(payload.chat_id, "ch1"); + assert_eq!(payload.sender.as_deref(), Some("svc")); + assert_eq!(payload.chat_id.as_deref(), Some("ch1")); + + let identity_cfg = WebhookChannelConfig { + sender_id: Some("fixed-sender".to_string()), + chat_id: Some("fixed-chat".to_string()), + ..WebhookChannelConfig::default() + }; + let (sender_id, chat_id) = WebhookChannel::resolve_identity(&payload, &identity_cfg) + .expect("should resolve identity"); // Verify allowlist check passes - assert!(base.is_allowed(&payload.sender)); + assert!(base.is_allowed(&sender_id)); // Publish - let inbound = InboundMessage::new( - "webhook", - &payload.sender, - &payload.chat_id, - &payload.message, - ); + let inbound = InboundMessage::new("webhook", &sender_id, &chat_id, &payload.message); bus_clone.publish_inbound(inbound).await.unwrap(); // Consume and verify let received = bus.consume_inbound().await.expect("should receive message"); assert_eq!(received.channel, "webhook"); - assert_eq!(received.sender_id, "svc"); - assert_eq!(received.chat_id, "ch1"); + assert_eq!(received.sender_id, "fixed-sender"); + assert_eq!(received.chat_id, "fixed-chat"); assert_eq!(received.content, "integration"); - assert_eq!(received.session_key, "webhook:ch1"); + assert_eq!(received.session_key, "webhook:fixed-chat"); // Clean up unused duplex streams drop(client_stream); @@ -1025,6 +1327,11 @@ mod tests { port, path: "/webhook".to_string(), auth_token: None, + signature_secret: None, + signature_header: "X-ZeptoClaw-Signature-256".to_string(), + sender_id: Some("svc".to_string()), + chat_id: Some("ch1".to_string()), + trust_payload_identity: false, }; let mut channel = @@ -1063,6 +1370,11 @@ mod tests { port, path: "/webhook".to_string(), auth_token: None, + signature_secret: None, + signature_header: "X-ZeptoClaw-Signature-256".to_string(), + sender_id: Some("svc".to_string()), + chat_id: Some("ch1".to_string()), + trust_payload_identity: false, }; let mut channel = @@ -1096,6 +1408,11 @@ mod tests { port, path: "/webhook".to_string(), auth_token: Some("test-token".to_string()), + signature_secret: None, + signature_header: "X-ZeptoClaw-Signature-256".to_string(), + sender_id: Some("fixed-sender".to_string()), + chat_id: Some("fixed-chat".to_string()), + trust_payload_identity: false, }; let mut channel = @@ -1109,7 +1426,7 @@ mod tests { .await .expect("should connect"); - let body = r#"{"message":"e2e test","sender":"test-client","chat_id":"e2e-chat"}"#; + let body = r#"{"message":"e2e test"}"#; let request = format!( "POST /webhook HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAuthorization: Bearer test-token\r\nContent-Length: {}\r\n\r\n{}", body.len(), @@ -1139,8 +1456,8 @@ mod tests { .expect("should receive message"); assert_eq!(received.channel, "webhook"); - assert_eq!(received.sender_id, "test-client"); - assert_eq!(received.chat_id, "e2e-chat"); + assert_eq!(received.sender_id, "fixed-sender"); + assert_eq!(received.chat_id, "fixed-chat"); assert_eq!(received.content, "e2e test"); channel.stop().await.unwrap(); @@ -1157,6 +1474,11 @@ mod tests { port, path: "/webhook".to_string(), auth_token: Some("correct-token".to_string()), + signature_secret: None, + signature_header: "X-ZeptoClaw-Signature-256".to_string(), + sender_id: Some("fixed-sender".to_string()), + chat_id: Some("fixed-chat".to_string()), + trust_payload_identity: false, }; let mut channel = @@ -1169,7 +1491,7 @@ mod tests { .await .expect("should connect"); - let body = r#"{"message":"test","sender":"s","chat_id":"c"}"#; + let body = r#"{"message":"test"}"#; let request = format!( "POST /webhook HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer wrong-token\r\nContent-Length: {}\r\n\r\n{}", body.len(), @@ -1192,4 +1514,121 @@ mod tests { channel.stop().await.unwrap(); } + + #[tokio::test] + async fn test_webhook_end_to_end_signature_required() { + let bus = test_bus(); + + let temp_listener = TcpListener::bind("127.0.0.1:0").await.expect("should bind"); + let port = temp_listener.local_addr().unwrap().port(); + drop(temp_listener); + + let config = WebhookChannelConfig { + bind_address: "127.0.0.1".to_string(), + port, + path: "/webhook".to_string(), + auth_token: Some("test-token".to_string()), + signature_secret: Some("shared-secret".to_string()), + signature_header: "X-Test-Signature".to_string(), + sender_id: Some("fixed-sender".to_string()), + chat_id: Some("fixed-chat".to_string()), + trust_payload_identity: false, + }; + + let mut channel = + WebhookChannel::new(config, BaseChannelConfig::new("webhook"), Arc::clone(&bus)); + + channel.start().await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let body = r#"{"message":"signed"}"#; + let signature = format!( + "sha256={}", + hmac_sha256_hex(b"shared-secret", body.as_bytes()) + ); + + let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .expect("should connect"); + let request = format!( + "POST /webhook HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAuthorization: Bearer test-token\r\nX-Test-Signature: {}\r\nContent-Length: {}\r\n\r\n{}", + signature, + body.len(), + body + ); + stream.write_all(request.as_bytes()).await.unwrap(); + + let mut response_buf = vec![0u8; 4096]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(5), + stream.read(&mut response_buf), + ) + .await + .expect("should not timeout") + .expect("should read"); + + let response = std::str::from_utf8(&response_buf[..n]).expect("valid utf8"); + assert!(response.starts_with("HTTP/1.1 200 OK")); + + let received = + tokio::time::timeout(std::time::Duration::from_secs(2), bus.consume_inbound()) + .await + .expect("should not timeout") + .expect("should receive message"); + assert_eq!(received.content, "signed"); + assert_eq!(received.sender_id, "fixed-sender"); + assert_eq!(received.chat_id, "fixed-chat"); + + channel.stop().await.unwrap(); + } + + #[tokio::test] + async fn test_webhook_end_to_end_missing_required_signature() { + let temp_listener = TcpListener::bind("127.0.0.1:0").await.expect("should bind"); + let port = temp_listener.local_addr().unwrap().port(); + drop(temp_listener); + + let config = WebhookChannelConfig { + bind_address: "127.0.0.1".to_string(), + port, + path: "/webhook".to_string(), + auth_token: Some("test-token".to_string()), + signature_secret: Some("shared-secret".to_string()), + signature_header: "X-Test-Signature".to_string(), + sender_id: Some("fixed-sender".to_string()), + chat_id: Some("fixed-chat".to_string()), + trust_payload_identity: false, + }; + + let mut channel = + WebhookChannel::new(config, BaseChannelConfig::new("webhook"), test_bus()); + + channel.start().await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let body = r#"{"message":"unsigned"}"#; + let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .expect("should connect"); + let request = format!( + "POST /webhook HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAuthorization: Bearer test-token\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(request.as_bytes()).await.unwrap(); + + let mut response_buf = vec![0u8; 4096]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(5), + stream.read(&mut response_buf), + ) + .await + .expect("should not timeout") + .expect("should read"); + + let response = std::str::from_utf8(&response_buf[..n]).expect("valid utf8"); + assert!(response.starts_with("HTTP/1.1 401")); + + channel.stop().await.unwrap(); + } }
src/channels/whatsapp_cloud.rs+208 −1 modified@@ -17,6 +17,7 @@ use futures::FutureExt; use reqwest::Client; use serde::Deserialize; use serde_json::{json, Value}; +use sha2::Digest; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -39,6 +40,8 @@ const MAX_HEADER_SIZE: usize = 8_192; /// WhatsApp text message character limit. const MAX_MESSAGE_LENGTH: usize = 4096; +const SHA256_BLOCK_SIZE: usize = 64; +const SHA256_OUTPUT_SIZE: usize = 32; // --- HTTP response constants --- @@ -159,6 +162,47 @@ struct ParsedHttpRequest { // --- Helper functions --- +fn constant_time_eq(a: &str, b: &str) -> bool { + let a = a.as_bytes(); + let b = b.as_bytes(); + if a.len() != b.len() { + return false; + } + + let mut result = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + result |= x ^ y; + } + result == 0 +} + +fn hmac_sha256_hex(key: &[u8], message: &[u8]) -> String { + let mut k = [0u8; SHA256_BLOCK_SIZE]; + if key.len() > SHA256_BLOCK_SIZE { + let hashed = sha2::Sha256::digest(key); + k[..SHA256_OUTPUT_SIZE].copy_from_slice(&hashed); + } else { + k[..key.len()].copy_from_slice(key); + } + + let mut k_ipad = [0u8; SHA256_BLOCK_SIZE]; + let mut k_opad = [0u8; SHA256_BLOCK_SIZE]; + for i in 0..SHA256_BLOCK_SIZE { + k_ipad[i] = k[i] ^ 0x36; + k_opad[i] = k[i] ^ 0x5c; + } + + let mut inner = sha2::Sha256::new(); + inner.update(k_ipad); + inner.update(message); + let inner_result = inner.finalize(); + + let mut outer = sha2::Sha256::new(); + outer.update(k_opad); + outer.update(inner_result); + hex::encode(outer.finalize()) +} + /// Parse a raw HTTP request into structured parts. fn parse_http_request(raw: &[u8]) -> Result<ParsedHttpRequest> { let raw_str = std::str::from_utf8(raw) @@ -493,6 +537,33 @@ impl WhatsAppCloudChannel { Some(challenge.to_string()) } + fn validate_signature( + headers: &[(String, String)], + body: &str, + app_secret: &Option<String>, + ) -> bool { + let app_secret = match app_secret { + Some(secret) => secret, + None => return true, + }; + + let provided = headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("x-hub-signature-256")) + .map(|(_, value)| value.trim()); + + let provided = match provided { + Some(value) => value, + None => return false, + }; + + let expected = format!( + "sha256={}", + hmac_sha256_hex(app_secret.as_bytes(), body.as_bytes()) + ); + constant_time_eq(provided, &expected) + } + /// Handle a single TCP connection. async fn handle_connection( mut stream: tokio::net::TcpStream, @@ -582,7 +653,13 @@ impl WhatsAppCloudChannel { } } "POST" => { - // Always respond 200 immediately (Meta requirement) + if !Self::validate_signature(&request.headers, &request.body, &config.app_secret) { + warn!("WhatsApp Cloud: invalid or missing X-Hub-Signature-256"); + let _ = stream.write_all(HTTP_403_FORBIDDEN.as_bytes()).await; + return; + } + + // Respond 200 only after the webhook authenticity check passes. let _ = stream.write_all(HTTP_200_OK.as_bytes()).await; // Parse notification @@ -845,6 +922,7 @@ mod tests { phone_number_id: "123456".to_string(), access_token: "test-token".to_string(), webhook_verify_token: "verify-secret".to_string(), + app_secret: None, bind_address: "127.0.0.1".to_string(), port: 0, path: "/whatsapp".to_string(), @@ -909,6 +987,41 @@ mod tests { assert!(result.is_none()); } + #[test] + fn test_signature_validation_not_required_without_app_secret() { + let headers = vec![("Content-Type".to_string(), "application/json".to_string())]; + assert!(WhatsAppCloudChannel::validate_signature( + &headers, + sample_webhook_json(), + &None, + )); + } + + #[test] + fn test_signature_validation_valid_signature() { + let secret = Some("meta-app-secret".to_string()); + let body = sample_webhook_json(); + let signature = format!( + "sha256={}", + hmac_sha256_hex(b"meta-app-secret", body.as_bytes()) + ); + let headers = vec![("X-Hub-Signature-256".to_string(), signature)]; + assert!(WhatsAppCloudChannel::validate_signature( + &headers, body, &secret + )); + } + + #[test] + fn test_signature_validation_missing_header_when_required() { + let secret = Some("meta-app-secret".to_string()); + let headers = vec![("Content-Type".to_string(), "application/json".to_string())]; + assert!(!WhatsAppCloudChannel::validate_signature( + &headers, + sample_webhook_json(), + &secret, + )); + } + // ----------------------------------------------------------------------- // 3. Inbound message parsing // ----------------------------------------------------------------------- @@ -1238,6 +1351,100 @@ mod tests { channel.stop().await.unwrap(); } + #[tokio::test] + async fn test_end_to_end_inbound_message_with_valid_signature() { + let bus = test_bus(); + let temp_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = temp_listener.local_addr().unwrap().port(); + drop(temp_listener); + + let mut config = test_config(); + config.port = port; + config.allow_from = vec![]; + config.app_secret = Some("meta-app-secret".to_string()); + let mut channel = WhatsAppCloudChannel::new(config, Arc::clone(&bus), None); + channel.start().await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let body = sample_webhook_json(); + let signature = format!( + "sha256={}", + hmac_sha256_hex(b"meta-app-secret", body.as_bytes()) + ); + let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let request = format!( + "POST /whatsapp HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nX-Hub-Signature-256: {}\r\nContent-Length: {}\r\n\r\n{}", + signature, + body.len(), + body + ); + stream.write_all(request.as_bytes()).await.unwrap(); + + let mut buf = vec![0u8; 4096]; + let n = tokio::time::timeout(std::time::Duration::from_secs(5), stream.read(&mut buf)) + .await + .unwrap() + .unwrap(); + + let response = std::str::from_utf8(&buf[..n]).unwrap(); + assert!(response.starts_with("HTTP/1.1 200 OK")); + + let received = + tokio::time::timeout(std::time::Duration::from_secs(2), bus.consume_inbound()) + .await + .unwrap() + .unwrap(); + assert_eq!(received.channel, "whatsapp_cloud"); + assert_eq!(received.sender_id, "60123456789"); + + channel.stop().await.unwrap(); + } + + #[tokio::test] + async fn test_end_to_end_inbound_message_missing_required_signature() { + let bus = test_bus(); + let temp_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = temp_listener.local_addr().unwrap().port(); + drop(temp_listener); + + let mut config = test_config(); + config.port = port; + config.allow_from = vec![]; + config.app_secret = Some("meta-app-secret".to_string()); + let mut channel = WhatsAppCloudChannel::new(config, Arc::clone(&bus), None); + channel.start().await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let body = sample_webhook_json(); + let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)) + .await + .unwrap(); + let request = format!( + "POST /whatsapp HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(request.as_bytes()).await.unwrap(); + + let mut buf = vec![0u8; 4096]; + let n = tokio::time::timeout(std::time::Duration::from_secs(5), stream.read(&mut buf)) + .await + .unwrap() + .unwrap(); + + let response = std::str::from_utf8(&buf[..n]).unwrap(); + assert!(response.starts_with("HTTP/1.1 403 Forbidden")); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(250), bus.consume_inbound()) + .await + .is_err() + ); + + channel.stop().await.unwrap(); + } + // ----------------------------------------------------------------------- // 11. Audio content parsing // -----------------------------------------------------------------------
src/cli/channel.rs+41 −1 modified@@ -225,16 +225,18 @@ fn setup_telegram(config: &mut Config) -> Result<()> { tg.token = token; tg.enabled = true; - print!("Allowlist user IDs/usernames (comma-separated, or Enter for all): "); + print!("Allowlist numeric Telegram user IDs (comma-separated, or Enter for all): "); io::stdout().flush()?; let allowlist = read_line()?; tg.allow_from = allowlist .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); + tg.allow_usernames = false; println!(" Telegram bot configured."); + println!(" Numeric user IDs are recommended; username allowlists stay disabled."); println!(" Run 'zeptoclaw gateway' to start the bot."); Ok(()) } @@ -346,11 +348,42 @@ fn setup_webhook(config: &mut Config) -> Result<()> { wh.auth_token = Some(auth); } + print!("HMAC signature secret (or Enter to disable body signing): "); + io::stdout().flush()?; + let signature_secret = read_secret()?; + if !signature_secret.is_empty() { + wh.signature_secret = Some(signature_secret); + + print!("Signature header [{}]: ", wh.signature_header); + io::stdout().flush()?; + let signature_header = read_line()?; + if !signature_header.is_empty() { + wh.signature_header = signature_header; + } + } + + print!("Fixed sender ID (recommended, Enter to configure later): "); + io::stdout().flush()?; + let sender_id = read_line()?; + if !sender_id.is_empty() { + wh.sender_id = Some(sender_id); + + print!("Fixed chat ID (Enter to reuse sender ID): "); + io::stdout().flush()?; + let chat_id = read_line()?; + if !chat_id.is_empty() { + wh.chat_id = Some(chat_id); + } + } + wh.enabled = true; println!( " Webhook configured at {}:{}{}", wh.bind_address, wh.port, wh.path ); + if wh.sender_id.is_none() && !wh.trust_payload_identity { + println!(" Note: set channels.webhook.sender_id before starting, or enable trust_payload_identity manually for legacy payload-driven identity."); + } println!(" Run 'zeptoclaw gateway' to start listening."); Ok(()) } @@ -383,13 +416,20 @@ fn setup_whatsapp_cloud(config: &mut Config) -> Result<()> { io::stdout().flush()?; let verify_token = read_secret()?; + print!("Enter Meta app secret for X-Hub-Signature-256 verification (or Enter to skip): "); + io::stdout().flush()?; + let app_secret = read_secret()?; + let wc = config .channels .whatsapp_cloud .get_or_insert_with(Default::default); wc.phone_number_id = phone_id; wc.access_token = token; wc.webhook_verify_token = verify_token; + if !app_secret.is_empty() { + wc.app_secret = Some(app_secret); + } wc.enabled = true; println!(" WhatsApp Cloud API configured.");
src/config/mod.rs+18 −0 modified@@ -1666,12 +1666,30 @@ mod tests { assert!(telegram.enabled); assert_eq!(telegram.token, "bot123:ABC"); assert_eq!(telegram.allow_from, vec!["user1", "user2"]); + assert!(telegram.allow_usernames); let discord = config.channels.discord.unwrap(); assert!(!discord.enabled); assert_eq!(discord.token, "discord-token"); } + #[test] + fn test_telegram_channel_config_can_disable_username_matching() { + let json = r#"{ + "channels": { + "telegram": { + "enabled": true, + "token": "bot123:ABC", + "allow_from": ["123456789"], + "allow_usernames": false + } + } + }"#; + let config: Config = serde_json::from_str(json).unwrap(); + let telegram = config.channels.telegram.unwrap(); + assert!(!telegram.allow_usernames); + } + #[test] fn test_provider_configs() { let json = r#"{
src/config/types.rs+62 −2 modified@@ -885,6 +885,22 @@ pub struct WebhookConfig { /// Optional Bearer token for request authentication #[serde(default)] pub auth_token: Option<String>, + /// Optional HMAC secret for request signature verification. + #[serde(default)] + pub signature_secret: Option<String>, + /// Header carrying the request HMAC signature when `signature_secret` is set. + #[serde(default = "default_webhook_signature_header")] + pub signature_header: String, + /// Server-controlled sender ID used when `trust_payload_identity` is disabled. + #[serde(default)] + pub sender_id: Option<String>, + /// Optional server-controlled chat ID used when `trust_payload_identity` is disabled. + /// Falls back to `sender_id` when omitted. + #[serde(default)] + pub chat_id: Option<String>, + /// When true, accept caller-supplied `sender` and `chat_id` from webhook JSON. + #[serde(default)] + pub trust_payload_identity: bool, /// Allowlist of sender IDs (empty = allow all unless `deny_by_default` is set) #[serde(default)] pub allow_from: Vec<String>, @@ -905,6 +921,10 @@ fn default_webhook_path() -> String { "/webhook".to_string() } +fn default_webhook_signature_header() -> String { + "X-ZeptoClaw-Signature-256".to_string() +} + impl Default for WebhookConfig { fn default() -> Self { Self { @@ -913,26 +933,55 @@ impl Default for WebhookConfig { port: default_webhook_port(), path: default_webhook_path(), auth_token: None, + signature_secret: None, + signature_header: default_webhook_signature_header(), + sender_id: None, + chat_id: None, + trust_payload_identity: false, allow_from: Vec::new(), deny_by_default: false, } } } +fn default_telegram_allow_usernames() -> bool { + true +} + /// Telegram channel configuration -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] pub struct TelegramConfig { /// Whether the channel is enabled #[serde(default)] pub enabled: bool, /// Bot token from BotFather pub token: String, - /// Allowlist of user IDs/usernames (empty = allow all unless `deny_by_default` is set) + /// Allowlist of numeric user IDs (empty = allow all unless `deny_by_default` is set). + /// + /// Legacy username entries are only honored when `allow_usernames` is true. #[serde(default)] pub allow_from: Vec<String>, /// When true, empty `allow_from` rejects all senders (strict mode). #[serde(default)] pub deny_by_default: bool, + /// Legacy compatibility toggle for username-based allowlist entries. + /// + /// New configs should keep this disabled and use numeric Telegram user IDs only. + #[serde(default = "default_telegram_allow_usernames")] + pub allow_usernames: bool, +} + +impl Default for TelegramConfig { + fn default() -> Self { + Self { + enabled: false, + token: String::new(), + allow_from: Vec::new(), + deny_by_default: false, + allow_usernames: default_telegram_allow_usernames(), + } + } } /// Discord channel configuration @@ -987,6 +1036,9 @@ pub struct WhatsAppCloudConfig { /// Webhook verify token (you choose this secret, must match Meta dashboard). #[serde(default)] pub webhook_verify_token: String, + /// Optional Meta app secret used to verify `X-Hub-Signature-256` callback signatures. + #[serde(default)] + pub app_secret: Option<String>, /// Address to bind the webhook HTTP server to. #[serde(default = "default_whatsapp_cloud_bind")] pub bind_address: String, @@ -1023,6 +1075,7 @@ impl Default for WhatsAppCloudConfig { phone_number_id: String::new(), access_token: String::new(), webhook_verify_token: String::new(), + app_secret: None, bind_address: default_whatsapp_cloud_bind(), port: default_whatsapp_cloud_port(), path: default_whatsapp_cloud_path(), @@ -2483,6 +2536,7 @@ mod tests { assert!(config.phone_number_id.is_empty()); assert!(config.access_token.is_empty()); assert!(config.webhook_verify_token.is_empty()); + assert!(config.app_secret.is_none()); assert_eq!(config.bind_address, "127.0.0.1"); assert_eq!(config.port, 9877); assert_eq!(config.path, "/whatsapp"); @@ -2497,6 +2551,7 @@ mod tests { "phone_number_id": "123456", "access_token": "EAAx...", "webhook_verify_token": "my-verify-secret", + "app_secret": "meta-secret", "port": 8443, "allow_from": ["60123456789"] }"#; @@ -2505,6 +2560,7 @@ mod tests { assert_eq!(config.phone_number_id, "123456"); assert_eq!(config.access_token, "EAAx..."); assert_eq!(config.webhook_verify_token, "my-verify-secret"); + assert_eq!(config.app_secret.as_deref(), Some("meta-secret")); assert_eq!(config.port, 8443); assert_eq!(config.allow_from, vec!["60123456789"]); } @@ -2863,6 +2919,10 @@ pub struct EmailConfig { #[serde(default)] pub display_name: Option<String>, /// Allowlist of sender email addresses or domains. + /// + /// These entries are matched against the parsed inbound `From` header. + /// Use upstream authenticated-mail enforcement (SPF/DKIM/DMARC) if sender + /// authenticity matters. #[serde(default)] pub allowed_senders: Vec<String>, /// When `true` and `allowed_senders` is empty, all senders are denied.
src/config/validate.rs+108 −0 modified@@ -259,6 +259,53 @@ pub fn validate_config(raw: &Value) -> Vec<Diagnostic> { message: "Empty \u{2014} anyone can message the bot".to_string(), }); } + + if name == "email" + && enabled + && channel_obj + .get("allowed_senders") + .and_then(|v| v.as_array()) + .is_some_and(|senders| !senders.is_empty()) + { + diagnostics.push(Diagnostic { + level: DiagnosticLevel::Warn, + path: "channels.email.allowed_senders".to_string(), + message: "Email sender allowlists trust the parsed From header only; enforce SPF/DKIM/DMARC upstream if sender authenticity matters".to_string(), + }); + } + + if name == "telegram" { + let allow_usernames = channel_obj + .get("allow_usernames") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let has_username_entries = channel_obj + .get("allow_from") + .and_then(|v| v.as_array()) + .map(|entries| { + entries + .iter() + .filter_map(|entry| entry.as_str()) + .any(|entry| { + let trimmed = entry.trim(); + trimmed.is_empty() + || !trimmed.bytes().all(|b| b.is_ascii_digit()) + }) + }) + .unwrap_or(false); + + if has_username_entries { + diagnostics.push(Diagnostic { + level: DiagnosticLevel::Warn, + path: "channels.telegram.allow_from".to_string(), + message: if allow_usernames { + "Telegram username allowlist entries are legacy and can be reassigned; prefer numeric user IDs and set allow_usernames=false after migration".to_string() + } else { + "Telegram allow_from contains non-numeric entries, but allow_usernames=false so they will never match".to_string() + }, + }); + } + } } } } @@ -527,6 +574,67 @@ mod tests { })); } + #[test] + fn test_validate_email_allowed_senders_warns_header_only_trust() { + let raw = json!({ + "channels": { + "email": { + "enabled": true, + "imap_host": "imap.example.com", + "smtp_host": "smtp.example.com", + "username": "bot@example.com", + "password": "secret", + "allowed_senders": ["@example.com"] + } + } + }); + let diags = validate_config(&raw); + assert!(diags.iter().any(|d| { + d.level == DiagnosticLevel::Warn + && d.path == "channels.email.allowed_senders" + && d.message.contains("From header") + })); + } + + #[test] + fn test_validate_telegram_username_allowlist_warns() { + let raw = json!({ + "channels": { + "telegram": { + "enabled": true, + "token": "abc", + "allow_from": ["alice"] + } + } + }); + let diags = validate_config(&raw); + assert!(diags.iter().any(|d| { + d.level == DiagnosticLevel::Warn + && d.path == "channels.telegram.allow_from" + && d.message.contains("legacy") + })); + } + + #[test] + fn test_validate_telegram_username_allowlist_disabled_warns_non_matching() { + let raw = json!({ + "channels": { + "telegram": { + "enabled": true, + "token": "abc", + "allow_from": ["alice"], + "allow_usernames": false + } + } + }); + let diags = validate_config(&raw); + assert!(diags.iter().any(|d| { + d.level == DiagnosticLevel::Warn + && d.path == "channels.telegram.allow_from" + && d.message.contains("never match") + })); + } + #[test] fn test_validate_not_an_object() { let raw = json!("not an object");
src/security/mod.rs+4 −1 modified@@ -14,5 +14,8 @@ pub use agent_mode::{AgentMode, AgentModeConfig, CategoryPermission, ModePolicy} pub use encryption::{is_secret_field, resolve_master_key, SecretEncryption}; pub use mount::{validate_extra_mounts, validate_mount_not_blocked, DEFAULT_BLOCKED_PATTERNS}; pub use pairing::{DeviceInfo, PairedDevice, PairingManager}; -pub use path::{check_hardlink_write, revalidate_path, validate_path_in_workspace, SafePath}; +pub use path::{ + check_hardlink_write, ensure_directory_chain_secure, revalidate_path, + validate_path_in_workspace, SafePath, +}; pub use shell::{ShellAllowlistMode, ShellSecurityConfig};
src/security/mount.rs+19 −0 modified@@ -214,6 +214,8 @@ pub fn validate_extra_mounts(mounts: &[String], allowlist_path: &str) -> Result< ))); } + validate_no_hardlink_alias(&host_path, mount)?; + let allowed_root = allowlist .allowed_roots .iter() @@ -390,6 +392,23 @@ mod tests { assert!(validated[0].ends_with(":ro")); } + #[cfg(unix)] + #[test] + fn test_validate_mount_rejects_regular_file_with_multiple_hardlinks() { + let temp = tempdir().unwrap(); + let source = temp.path().join("source.txt"); + let alias = temp.path().join("alias.txt"); + std::fs::write(&source, "secret").unwrap(); + std::fs::hard_link(&source, &alias).unwrap(); + + let allowlist = temp.path().join("allowlist.json"); + write_allowlist(&allowlist, temp.path(), true); + + let mounts = vec![format!("{}:/workspace/data", alias.display())]; + let err = validate_extra_mounts(&mounts, allowlist.to_str().unwrap()).unwrap_err(); + assert!(err.to_string().contains("hard links")); + } + #[test] fn test_validate_mount_outside_allowed_root_fails() { let temp = tempdir().unwrap();
src/security/path.rs+126 −0 modified@@ -285,6 +285,106 @@ pub fn revalidate_path(path: &Path, workspace: &str) -> Result<()> { Ok(()) } +/// Securely ensure a directory chain exists within the workspace. +/// +/// This creates missing directories one component at a time, re-checking the +/// workspace boundary and rejecting symlinked or non-directory ancestors. +pub fn ensure_directory_chain_secure(path: &Path, workspace: &str) -> Result<()> { + let workspace_path = Path::new(workspace); + let canonical_workspace = workspace_path + .canonicalize() + .unwrap_or_else(|_| normalize_path(workspace_path)); + let normalized_path = normalize_path(path); + + if !normalized_path.starts_with(&canonical_workspace) { + return Err(ZeptoError::SecurityViolation(format!( + "Directory path escapes workspace: '{}' is not within '{}'", + normalized_path.display(), + canonical_workspace.display() + ))); + } + + let relative = normalized_path + .strip_prefix(&canonical_workspace) + .map_err(|_| { + ZeptoError::SecurityViolation(format!( + "Directory path escapes workspace: '{}' is not within '{}'", + normalized_path.display(), + canonical_workspace.display() + )) + })?; + + let mut current = canonical_workspace.clone(); + for component in relative.components() { + current.push(component); + + check_symlink_escape(¤t, &canonical_workspace)?; + + match std::fs::symlink_metadata(¤t) { + Ok(meta) => { + if meta.file_type().is_symlink() { + return Err(ZeptoError::SecurityViolation(format!( + "Symlink escape detected while creating directory '{}'", + current.display() + ))); + } + if !meta.is_dir() { + return Err(ZeptoError::SecurityViolation(format!( + "Cannot create directory '{}': existing path is not a directory", + current.display() + ))); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + match std::fs::create_dir(¤t) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => { + return Err(ZeptoError::Tool(format!( + "Failed to create directory '{}': {}", + current.display(), + e + ))); + } + } + + let meta = std::fs::symlink_metadata(¤t).map_err(|e| { + ZeptoError::Tool(format!( + "Failed to inspect directory '{}' after creation: {}", + current.display(), + e + )) + })?; + if meta.file_type().is_symlink() || !meta.is_dir() { + return Err(ZeptoError::SecurityViolation(format!( + "Directory '{}' became unsafe during creation", + current.display() + ))); + } + } + Err(e) => { + return Err(ZeptoError::Tool(format!( + "Failed to inspect directory '{}': {}", + current.display(), + e + ))); + } + } + + if let Ok(canonical) = current.canonicalize() { + if !canonical.starts_with(&canonical_workspace) { + return Err(ZeptoError::SecurityViolation(format!( + "Directory '{}' resolves outside workspace to '{}'", + current.display(), + canonical.display() + ))); + } + } + } + + Ok(()) +} + /// Checks if a file has multiple hard links, which could indicate it aliases /// an inode outside the workspace trust boundary. /// @@ -782,6 +882,32 @@ mod tests { ); } + #[test] + fn test_ensure_directory_chain_secure_creates_nested_dirs() { + let temp = tempdir().unwrap(); + let canonical = temp.path().canonicalize().unwrap(); + let workspace = canonical.to_str().unwrap(); + let nested = canonical.join("a/b/c"); + + let result = ensure_directory_chain_secure(&nested, workspace); + assert!(result.is_ok()); + assert!(nested.is_dir()); + } + + #[test] + fn test_ensure_directory_chain_secure_rejects_symlink_parent() { + let temp = tempdir().unwrap(); + let outside = tempdir().unwrap(); + let canonical = temp.path().canonicalize().unwrap(); + let workspace = canonical.to_str().unwrap(); + + let linked = canonical.join("linked"); + symlink(outside.path(), &linked).unwrap(); + + let result = ensure_directory_chain_secure(&linked.join("child"), workspace); + assert!(result.is_err()); + } + // ==================== CHECK_HARDLINK_WRITE TESTS ==================== #[test]
src/tools/filesystem.rs+89 −35 modified@@ -6,10 +6,16 @@ use async_trait::async_trait; use serde_json::{json, Value}; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; use std::path::Path; +#[cfg(unix)] +use std::{fs::OpenOptions, io::Write as _, os::unix::fs::MetadataExt}; use crate::error::{Result, ZeptoError}; -use crate::security::{check_hardlink_write, revalidate_path, validate_path_in_workspace}; +#[cfg(not(unix))] +use crate::security::check_hardlink_write; +use crate::security::{ensure_directory_chain_secure, revalidate_path, validate_path_in_workspace}; use crate::tools::diff::apply_unified_diff; use super::{Tool, ToolCategory, ToolContext, ToolOutput}; @@ -34,6 +40,85 @@ fn resolve_path(path: &str, ctx: &ToolContext) -> Result<(String, String)> { )) } +#[cfg(unix)] +fn write_file_secure_blocking(path: &Path, workspace: &str, content: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + ensure_directory_chain_secure(parent, workspace)?; + revalidate_path(parent, workspace)?; + } + } + + revalidate_path(path, workspace)?; + + let mut options = OpenOptions::new(); + options + .write(true) + .create(true) + .custom_flags(libc::O_NOFOLLOW); + let mut file = options.open(path).map_err(|e| { + ZeptoError::Tool(format!( + "Failed to securely open file '{}': {}", + path.display(), + e + )) + })?; + + let metadata = file.metadata().map_err(|e| { + ZeptoError::Tool(format!( + "Failed to inspect opened file '{}': {}", + path.display(), + e + )) + })?; + if metadata.is_file() && metadata.nlink() > 1 { + return Err(ZeptoError::SecurityViolation(format!( + "Write blocked: '{}' has {} hard links and may alias content outside workspace", + path.display(), + metadata.nlink() + ))); + } + + file.set_len(0).map_err(|e| { + ZeptoError::Tool(format!( + "Failed to truncate file '{}': {}", + path.display(), + e + )) + })?; + file.write_all(content).map_err(|e| { + ZeptoError::Tool(format!("Failed to write file '{}': {}", path.display(), e)) + })?; + + Ok(()) +} + +#[cfg(not(unix))] +fn write_file_secure_blocking(path: &Path, workspace: &str, content: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + ensure_directory_chain_secure(parent, workspace)?; + revalidate_path(parent, workspace)?; + } + } + + revalidate_path(path, workspace)?; + check_hardlink_write(path)?; + std::fs::write(path, content).map_err(|e| { + ZeptoError::Tool(format!("Failed to write file '{}': {}", path.display(), e)) + })?; + Ok(()) +} + +async fn write_file_secure(path: &Path, workspace: &str, content: &[u8]) -> Result<()> { + let path = path.to_path_buf(); + let workspace = workspace.to_string(); + let content = content.to_vec(); + tokio::task::spawn_blocking(move || write_file_secure_blocking(&path, &workspace, &content)) + .await + .map_err(|e| ZeptoError::Tool(format!("Secure write task failed: {}", e)))? +} + /// Tool for reading file contents. /// /// Reads the entire contents of a file and returns it as a string. @@ -177,24 +262,7 @@ impl Tool for WriteFileTool { let (full_path, workspace) = resolve_path(path, ctx)?; let full_path_ref = Path::new(&full_path); - // TOCTOU: re-validate BEFORE any filesystem mutation (including mkdir) - revalidate_path(full_path_ref, &workspace)?; - - // Create parent directories if they don't exist - if let Some(parent) = full_path_ref.parent() { - if !parent.as_os_str().is_empty() { - tokio::fs::create_dir_all(parent).await.map_err(|e| { - ZeptoError::Tool(format!("Failed to create parent directories: {}", e)) - })?; - } - } - - // Hardlink check: block writes to files with multiple hard links - check_hardlink_write(full_path_ref)?; - - tokio::fs::write(&full_path, content).await.map_err(|e| { - ZeptoError::Tool(format!("Failed to write file '{}': {}", full_path, e)) - })?; + write_file_secure(full_path_ref, &workspace, content.as_bytes()).await?; Ok(ToolOutput::llm_only(format!( "Successfully wrote {} bytes to {}", @@ -402,14 +470,7 @@ impl Tool for EditFileTool { let (new_content, summary) = apply_unified_diff(&content, diff_str) .map_err(|e| ZeptoError::Tool(format!("Diff apply failed: {}", e)))?; - revalidate_path(full_path_ref, &workspace)?; - check_hardlink_write(full_path_ref)?; - - tokio::fs::write(&full_path, &new_content) - .await - .map_err(|e| { - ZeptoError::Tool(format!("Failed to write file '{}': {}", full_path, e)) - })?; + write_file_secure(full_path_ref, &workspace, new_content.as_bytes()).await?; Ok(ToolOutput::llm_only(format!( "Applied {} hunk(s): +{} -{} in {}", @@ -433,14 +494,7 @@ impl Tool for EditFileTool { let new_content = content.replace(old_text, new_text); - revalidate_path(full_path_ref, &workspace)?; - check_hardlink_write(full_path_ref)?; - - tokio::fs::write(&full_path, &new_content) - .await - .map_err(|e| { - ZeptoError::Tool(format!("Failed to write file '{}': {}", full_path, e)) - })?; + write_file_secure(full_path_ref, &workspace, new_content.as_bytes()).await?; let replacements = content.matches(old_text).count(); Ok(ToolOutput::llm_only(format!(
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-46q5-g3j9-wx5cghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-32231ghsaADVISORY
- github.com/qhkm/zeptoclaw/commit/bf004a20d3687a0c1a9e052ec79536e30d6de134ghsax_refsource_MISCWEB
- github.com/qhkm/zeptoclaw/pull/324ghsax_refsource_MISCWEB
- github.com/qhkm/zeptoclaw/releases/tag/v0.7.6ghsax_refsource_MISCWEB
- github.com/qhkm/zeptoclaw/security/advisories/GHSA-46q5-g3j9-wx5cghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.