Root cause
Authentication & access control
feat: smarter retry, HTTP/PDF tools, Gemini native, Lark, Email (#82)
AI-assisted change: Claude
How AI contributed: Flawed AI-written codePublished Mar 12, 2026 in
qhkm/zeptoclawAll 5 findings in this repository
Email channel authorizes inbound messages using only the parsed From header address; is_sender_allowed compares allowed_senders against that spoofable header value with no SPF/DKIM/DMARC authentication gate in-channel, letting a forged allowlisted From identity enter trusted agent/inbound flows when upstream mail authentication is weak.
Authorization relies entirely on a spoofable header value and performs no message-authentication check, so the allowlist gate can be satisfied by simply writing the victim address.
Root cause
feat: smarter retry, HTTP/PDF tools, Gemini native, Lark, Email (#82)
AI-assisted change: Claude
Fix status
No minimum fix commit has been established for this finding.
Fix status unresolved
Case facts
@@ -0,0 +1,1330 @@+//! Lark / Feishu channel implementation.+//!+//! Receives events via the Lark WS long-connection (pbbp2 protobuf frames).+//! No public HTTPS endpoint is required — only an App ID and App Secret.+//!+//! # Endpoints+//!+//! | Region | REST API base | WS endpoint base |+//! |---------|-----------------------------------------|---------------------------|+//! | Lark | `https://open.larksuite.com/open-apis` | `https://open.larksuite.com` |+//! | Feishu | `https://open.feishu.cn/open-apis` | `https://open.feishu.cn` |+//!+//! # WS frame format (pbbp2.proto)+//!+//! All binary frames are protobuf-encoded `PbFrame` messages:+//! - `method=0` → CONTROL (ping / pong)+//! - `method=1` → DATA (events)+//!+//! DATA frames carry a JSON event payload in `frame.payload`.+//!+//! # Reconnect strategy+//!+//! The `start()` loop reconnects with exponential back-off (2 s … 60 s) on+//! any connection failure or heartbeat timeout.++use async_trait::async_trait;+use futures::{SinkExt, StreamExt};+use prost::Message as ProstMessage;+use std::collections::HashMap;+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::Arc;+use std::time::{Duration, Instant};+use tokio::sync::RwLock;+use tokio_tungstenite::connect_async;+use tokio_tungstenite::tungstenite::Message as WsMsg;+use tracing::{debug, error, info, warn};++use crate::bus::{InboundMessage, MessageBus, OutboundMessage};+use crate::config::LarkConfig;+use crate::error::{Result, ZeptoError};++use super::{BaseChannelConfig, Channel};++// ---------------------------------------------------------------------------+// Constants+// ---------------------------------------------------------------------------++const FEISHU_API_BASE: &str = "https://open.feishu.cn/open-apis";+const FEISHU_WS_BASE: &str = "https://open.feishu.cn";+const LARK_API_BASE: &str = "https://open.larksuite.com/open-apis";+const LARK_WS_BASE: &str = "https://open.larksuite.com";++/// If no binary frame arrives within this window the connection is considered dead.+const WS_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(300);++/// Refresh tenant token this many seconds before it expires.+const TOKEN_REFRESH_SKEW: Duration = Duration::from_secs(120);++/// Fallback TTL when the token response does not include an expiry.+const DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(7200);++/// Lark business error code for an expired/invalid tenant access token.+const LARK_INVALID_ACCESS_TOKEN_CODE: i64 = 99_991_663;++/// Base reconnect delay in seconds.+const BASE_RECONNECT_DELAY_SECS: u64 = 2;++/// Maximum reconnect delay in seconds.+const MAX_RECONNECT_DELAY_SECS: u64 = 60;++// ---------------------------------------------------------------------------+// pbbp2.proto frame types+// ---------------------------------------------------------------------------++/// A key-value header carried inside a `PbFrame`.+#[derive(Clone, PartialEq, prost::Message)]+struct PbHeader {+ #[prost(string, tag = "1")]+ pub key: String,+ #[prost(string, tag = "2")]+ pub value: String,+}++/// Top-level Lark / Feishu WS frame (pbbp2.proto).+///+/// `method=0` → CONTROL (ping / pong) +/// `method=1` → DATA (events)+#[derive(Clone, PartialEq, prost::Message)]+struct PbFrame {@@ -403,6 +403,8 @@ pub struct ChannelsConfig { pub whatsapp_cloud: Option<WhatsAppCloudConfig>, /// Feishu (Lark) configuration pub feishu: Option<FeishuConfig>,+ /// Lark/Feishu WS long-connection configuration+ pub lark: Option<LarkConfig>, /// MaixCam configuration pub maixcam: Option<MaixCamConfig>, /// QQ configuration@@ -411,6 +413,8 @@ pub struct ChannelsConfig { pub dingtalk: Option<DingTalkConfig>, /// Webhook inbound channel configuration pub webhook: Option<WebhookConfig>,+ /// Email channel configuration (IMAP IDLE + SMTP). Feature-gated behind channel-email.+ pub email: Option<EmailConfig>, /// Directory for channel plugins (default: ~/.zeptoclaw/channels/) #[serde(default)] pub channel_plugins_dir: Option<String>,@@ -651,6 +655,33 @@ pub struct FeishuConfig { pub deny_by_default: bool, } +/// Lark (international) / Feishu (China) channel configuration.+///+/// Uses the Lark WS long-connection (pbbp2) for receiving events —+/// no public HTTPS endpoint required.+#[derive(Debug, Clone, Serialize, Deserialize, Default)]+pub struct LarkConfig {+ /// Whether the channel is enabled+ #[serde(default)]+ pub enabled: bool,+ /// Lark / Feishu application ID+ pub app_id: String,+ /// Lark / Feishu application secret+ pub app_secret: String,+ /// When true, use Feishu (open.feishu.cn); when false, use Lark (open.larksuite.com)+ #[serde(default)]+ pub feishu: bool,+ /// Allowlist of sender open_ids (empty = allow all unless deny_by_default)+ #[serde(default)]+ pub allowed_senders: Vec<String>,+ /// Bot's own open_id — messages from the bot itself are silently dropped+ #[serde(default)]+ pub bot_open_id: Option<String>,+ /// When true, empty allowed_senders rejects all senders (strict mode)+ #[serde(default)]+ pub deny_by_default: bool,+}+ /// MaixCam channel configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MaixCamConfig {@@ -895,6 +926,30 @@ pub struct ToolsConfig { pub whatsapp: WhatsAppToolConfig, /// Google Sheets tool configuration pub google_sheets: GoogleSheetsToolConfig,+ /// HTTP request tool configuration+ pub http_request: Option<HttpRequestConfig>,+}++/// Configuration for the HTTP request tool.+#[derive(Debug, Clone, Serialize, Deserialize, Default)]+pub struct HttpRequestConfig {+ /// Allowlist of domains the agent may call. Required — tool fails fast if empty.+ #[serde(default)]+ pub allowed_domains: Vec<String>,+ /// Request timeout in seconds. Default: 30.+ #[serde(default = "default_http_request_timeout")]+ pub timeout_secs: u64,+ /// Maximum response body size in bytes. Default: 512KB.+ #[serde(default = "default_http_request_max_bytes")]+ pub max_response_bytes: usize,+}++fn default_http_request_timeout() -> u64 {+ 30+}++fn default_http_request_max_bytes() -> usize {+ 512 * 1024 } /// Web tools configuration@@ -1723,3 +1778,98 @@ mod tests { assert_eq!(config.stop_timeout_secs, 300); } }++// ---------------------------------------------------------------------------+// EmailConfig (used by channels::EmailChannel, feature-gated: channel-email)@@ -0,0 +1,746 @@+//! Email channel implementation — IMAP IDLE (inbound) + SMTP (outbound).+//!+//! Feature-gated behind `channel-email`:+//! - Without the feature: channel compiles but `start()` / `send()` return a+//! clear error telling the user to rebuild.+//! - With the feature: full IMAP IDLE loop with automatic reconnect and SMTP+//! send via STARTTLS.+//!+//! # Example configuration+//!+//! ```json+//! {+//! "channels": {+//! "email": {+//! "imap_host": "imap.gmail.com",+//! "smtp_host": "smtp.gmail.com",+//! "username": "[email protected]",+//! "password": "app-password",+//! "allowed_senders": ["@mycompany.com"]+//! }+//! }+//! }+//! ```++#[cfg(feature = "channel-email")]+use tracing::{error, info, warn};++use async_trait::async_trait;+use std::collections::HashSet;+use std::sync::{+ atomic::{AtomicBool, Ordering},+ Arc,+};+use tokio::sync::Mutex;++#[cfg(feature = "channel-email")]+use crate::bus::InboundMessage;+use crate::bus::{MessageBus, OutboundMessage};+use crate::config::EmailConfig;+use crate::error::{Result, ZeptoError};++use super::{BaseChannelConfig, Channel};++// ---------------------------------------------------------------------------+// Channel struct (always compiled)+// ---------------------------------------------------------------------------++/// Email channel: IMAP IDLE for inbound push, SMTP for outbound.+///+/// This struct always compiles regardless of the `channel-email` feature.+/// Without the feature, `start()` and `send()` return a clear rebuild error.+#[cfg_attr(not(feature = "channel-email"), allow(dead_code))]+pub struct EmailChannel {+ config: EmailConfig,+ base_config: BaseChannelConfig,+ bus: Arc<MessageBus>,+ running: Arc<AtomicBool>,+ /// Tracks Message-IDs seen in the current session to avoid reprocessing on+ /// reconnect.+ seen_ids: Arc<Mutex<HashSet<String>>>,+}++impl EmailChannel {+ /// Create a new `EmailChannel` from configuration.+ pub fn new(config: EmailConfig, bus: Arc<MessageBus>) -> Self {+ let base_config = BaseChannelConfig {+ name: "email".to_string(),+ allowlist: config.allowed_senders.clone(),+ deny_by_default: config.deny_by_default,+ };+ Self {+ config,+ base_config,+ bus,+ running: Arc::new(AtomicBool::new(false)),+ seen_ids: Arc::new(Mutex::new(HashSet::new())),+ }+ }++ /// Check whether the given sender email address is permitted.+ ///+ /// Rules (applied in order):+ /// 1. `"*"` in allowlist → allow all.+ /// 2. Exact address match (case-insensitive).+ /// 3. Domain with `@` prefix (`"@example.com"`) matches any address at that domain.+ /// 4. Domain without `@` prefix (`"example.com"`) also matches any address at that domain.+ /// 5. Empty allowlist + `deny_by_default = false` → allow all.+ /// 6. Empty allowlist + `deny_by_default = true` → deny all.+ pub fn is_sender_allowed(&self, from: &str) -> bool {