Root cause
Authentication & access control introduced
The Claude-coauthored commit created the webhook with optional authentication and made both authorization and routing depend on identity fields supplied in JSON.
How AI contributed: Direct introductionPublished Mar 12, 2026 in
qhkm/zeptoclawAll 5 findings in this repository
Summary 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. Details Relevant code paths: · src/channels/webhook.rs:121 sets runtime default auth_token: None. · src/config/types.rs:910 also defaults webhook config auth_token to None. · src/channels/webhook.rs:224 (validate_auth) explicitly allows requests when no token is configured. · src/channels/webhook.rs:128 defines WebhookPayload with identity fields fully controlled by caller input: · sender: String · chat_id: String · src/channels/webhook.rs:421 performs allowlist authorization using payload.sender. · src/channels/webhook.rs:433 and src/channels/webhook.rs:434 create InboundMessage using untrusted payload.sender and payload.chat_id. Why this is vulnerable: · The system treats user-provided JSON identity as authoritative identity. · Allowlist enforcement does not verify sender authenticity beyond that payload value. · chat_id is also attacker-controlled, so routing/session association can be steered to arbitrary chats/conversations. · If the webhook is exposed without strong upstream authn/authz controls, spoofing is straightforward. PoC 1. Configure the webhook channel in a vulnerable posture (common default behavior): · enabled = true · bind_address = "0.0.0.0" (or any reachable interface) · port = 9876 · path = "/webhook" · auth_token = null (or omitted) · allow_from = ["trusted-user-1"] · deny_by_default = true 2. Start ZeptoClaw. 3. Send a forged request with attacker-chosen sender and chat_id, without any Authorization header: 4. Observe: · Response is HTTP/1.1 200 OK. · Message is accepted as if it originated from trusted-user-1. · Message is routed under attacker-chosen chat_id (victim-chat-42). Impact · Vulnerability type: · Authentication/authorization bypass (identity spoofing) · IDOR-style routing/control issue via attacker-chosen chat_id · Affected deployments: · Any deployment exposing the generic webhook endpoint without strict upstream authentication and identity binding. · Security consequences: · Forged inbound messages from spoofed trusted users. · Bypass of allowlist intent by injecting allowlisted sender IDs in payload. · Cross-chat/session contamination or hijacking by choosing arbitrary chat_id. · Potential unauthorized downstream agent/tool actions triggered by malicious input.
Claude 2c9deef first adds src/channels/webhook.rs with WebhookPayload {sender, chat_id}, default auth_token None, and allowlist checks against payload.sender. Parent has no webhook.rs. Fix bf004a20 defaults trust_payload_identity=false and uses server-controlled identity. v0.7.5 contains origin without fix; v0.7.6 contains the fix.
Root cause
The Claude-coauthored commit created the webhook with optional authentication and made both authorization and routing depend on identity fields supplied in JSON.
Fix
The direct fix defaults to server-configured sender and chat identifiers, fails startup without a fixed sender, and makes payload identity an explicit legacy opt-in.
Fix by qhkm · no AI marker found
Case facts
@@ -0,0 +84,33 @@+pub struct WebhookChannelConfig {+ /// Address to bind the HTTP server to.+ pub bind_address: String,+ /// Port to listen on.+ pub port: u16,+ /// URL path to accept webhook requests on.+ pub path: String,+ /// Optional Bearer token for request authentication.+ /// When set, all requests must include a matching `Authorization: Bearer <token>` header.+ pub auth_token: Option<String>,+}++impl Default for WebhookChannelConfig {+ fn default() -> Self {+ Self {+ bind_address: "127.0.0.1".to_string(),+ port: 9876,+ path: "/webhook".to_string(),+ auth_token: None,+ }+ }+}++/// JSON body expected from webhook POST requests.+#[derive(Debug, Deserialize)]+struct WebhookPayload {+ /// The message content.+ message: String,+ /// Identifier of the sender.+ sender: String,+ /// Chat/conversation identifier for session routing.+ chat_id: String,+}@@ -0,0 +382,23 @@+// 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"}"#;+}++// Check allowlist+if !base_config.is_allowed(&payload.sender) {+ info!(+ "Webhook: sender {} not in allowlist, rejecting",+ payload.sender+ );+}++// Build and publish inbound message+let inbound = InboundMessage::new(+ "webhook",+ payload.sender.trim(),+ payload.chat_id.trim(),+ payload.message.trim(),+);@@ -887,0 +888,16 @@ pub struct WebhookConfig {+ /// 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,@@ -915,0 +936,5 @@ impl Default for WebhookConfig {+ signature_secret: None,+ signature_header: default_webhook_signature_header(),+ sender_id: None,+ chat_id: None,+ trust_payload_identity: false,@@ -91,0 +92,5 @@ pub async fn register_configured_channels(+ 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,@@ -112,0 +141,11 @@ pub struct WebhookChannelConfig {+ /// 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,@@ -121,0 +161,5 @@ impl Default for WebhookChannelConfig {+ signature_secret: None,+ signature_header: "X-ZeptoClaw-Signature-256".to_string(),+ sender_id: None,+ chat_id: None,+ trust_payload_identity: false,@@ -279,0 +313,60 @@+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()))+}@@ -419,0 +565,15 @@ impl WebhookChannel {+ 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;+ }+ };@@ -421,5 +582,2 @@ impl WebhookChannel {- 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);@@ -431,6 +589,1 @@ impl WebhookChannel {- 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());@@ -485,0 +639,5 @@ impl Channel for WebhookChannel {+ if let Err(e) = Self::validate_runtime_config(&self.config) {+ self.running.store(false, Ordering::SeqCst);+ return Err(e);+ }+AI-assisted change 7cc16ed6a0430108a27c0d24eea9707a5cdcf6129a9219f22f04e378c9704b12 · Fix 25ec9a5760de1e1629c8a346021e5fa9f66afcafa6442eb1b01bce5a1d4fb28d