-
Notifications
You must be signed in to change notification settings - Fork 4.4k
06.3 Channel Security
Relevant source files
The following files were used as context for generating this wiki page:
- src/channels/cli.rs
- src/channels/dingtalk.rs
- src/channels/discord.rs
- src/channels/email_channel.rs
- src/channels/imessage.rs
- src/channels/lark.rs
- src/channels/matrix.rs
- src/channels/slack.rs
- src/channels/telegram.rs
- src/channels/traits.rs
- src/channels/whatsapp.rs
- src/memory/snapshot.rs
- src/security/pairing.rs
- src/security/secrets.rs
- src/tools/browser.rs
This page documents the security mechanisms that protect channel communications in ZeroClaw. Channels are the primary attack surface for the agent, as they accept external input from messaging platforms. This page covers:
- Allowlist-based access control (deny-by-default user filtering)
- Pairing authentication (one-time code binding for Telegram)
- Mention-only modes (group chat filtering)
- Webhook verification (signature validation for push-based channels)
- Input validation and injection prevention
For the overall security architecture across tools and gateway, see Security Model. For channel implementation details, see Channel Implementations.
All channels implement allowlist-based access control. By default, if the allowed_users (or equivalent field) is empty or missing, all messages are rejected. This prevents unauthorized access until explicit user identities are configured.
| Channel | Config Field | Identity Format | Wildcard |
|---|---|---|---|
| Telegram | allowed_users |
@username or numeric user ID |
["*"] |
| Discord | allowed_users |
Numeric user ID | ["*"] |
| Slack | allowed_users |
Slack user ID | ["*"] |
allowed_senders |
Email address or @domain.com
|
["*"] |
|
allowed_numbers |
E.164 phone number (+1234567890) |
["*"] |
|
| Matrix | allowed_users |
Matrix user ID (@user:server) |
["*"] |
| Lark | allowed_users |
Open ID | ["*"] |
| DingTalk | allowed_users |
Staff ID | ["*"] |
| iMessage | allowed_contacts |
Phone number or email | ["*"] |
Empty list = deny all. The special value ["*"] allows all users (use only in trusted environments).
flowchart TD
Msg["Incoming Message"] --> Extract["Extract Sender Identity<br/>(username, user_id, email, phone)"]
Extract --> Normalize["Normalize Identity<br/>(strip @, lowercase, E.164)"]
Normalize --> CheckEmpty{"allowed_users<br/>empty?"}
CheckEmpty -->|Yes| Reject1["Reject: Deny-by-default"]
CheckEmpty -->|No| CheckWildcard{"Contains '*'?"}
CheckWildcard -->|Yes| Accept["Accept Message"]
CheckWildcard -->|No| CheckMatch{"Identity in<br/>allowlist?"}
CheckMatch -->|Yes| Accept
CheckMatch -->|No| CheckPairing{"Pairing mode<br/>active?"}
CheckPairing -->|Yes| HandlePairing["Handle /bind command"]
CheckPairing -->|No| Reject2["Reject with instructions"]
Reject1 --> Log1["Log: unauthorized user"]
Reject2 --> Log2["Log: unauthorized user<br/>Suggest: zeroclaw channel bind-*"]
HandlePairing --> ValidateBind["Validate pairing code"]
ValidateBind -->|Valid| AddUser["Add to allowlist + persist"]
ValidateBind -->|Invalid| RejectBind["Reject: invalid code"]
style CheckEmpty fill:#f9f9f9
style CheckWildcard fill:#f9f9f9
style CheckMatch fill:#f9f9f9
style Reject1 fill:#ffe0e0
style Reject2 fill:#ffe0e0
style RejectBind fill:#ffe0e0
style Accept fill:#e0ffe0
Sources:
- src/channels/telegram.rs:572-628
- src/channels/discord.rs:42-46
- src/channels/email_channel.rs:122-142
- src/channels/slack.rs:27-29
- src/channels/whatsapp.rs:38-40
// src/channels/telegram.rs
fn is_user_allowed(&self, username: &str) -> bool {
let identity = Self::normalize_identity(username);
self.allowed_users
.read()
.map(|users| users.iter().any(|u| u == "*" || u == &identity))
.unwrap_or(false)
}Identities are normalized (strip @, trim whitespace) before comparison. The check supports both usernames and numeric user IDs.
Sources:
Telegram channels support pairing mode when the allowed_users list is initially empty. On startup, the daemon generates a one-time 6-digit code printed to the terminal. Users must send /bind <code> from their Telegram account to authenticate.
sequenceDiagram
participant Op as Operator Terminal
participant Daemon as ZeroClaw Daemon
participant TG as Telegram Bot API
participant User as User's Telegram
Op->>Daemon: zeroclaw daemon
Daemon->>Daemon: PairingGuard::new(true, [])
Daemon->>Daemon: generate_code() → "482391"
Daemon->>Op: 🔐 Pairing code: 482391
User->>TG: /bind 482391
TG->>Daemon: Update (message)
Daemon->>Daemon: extract_bind_code() → "482391"
Daemon->>Daemon: PairingGuard::try_pair("482391")
Daemon->>Daemon: constant_time_eq(code, expected)
alt Valid Code
Daemon->>Daemon: generate_token() → "zc_a1b2..."
Daemon->>Daemon: add_allowed_identity_runtime(user_id)
Daemon->>Daemon: persist_allowed_identity(user_id)
Daemon->>Daemon: Save config.toml
Daemon->>TG: ✅ Bound successfully
TG->>User: ✅ Bound successfully
else Invalid Code
Daemon->>Daemon: Increment failed_attempts
Daemon->>TG: ❌ Invalid code
TG->>User: ❌ Invalid code
end
Sources:
PairingGuard implements rate limiting:
- Max attempts: 5 incorrect codes
- Lockout duration: 300 seconds (5 minutes)
- Constant-time comparison: prevents timing attacks
// src/security/pairing.rs
pub fn try_pair(&self, code: &str) -> Result<Option<String>, u64> {
// Check brute force lockout
{
let attempts = self.failed_attempts.lock();
if let (count, Some(locked_at)) = &*attempts {
if *count >= MAX_PAIR_ATTEMPTS {
let elapsed = locked_at.elapsed().as_secs();
if elapsed < PAIR_LOCKOUT_SECS {
return Err(PAIR_LOCKOUT_SECS - elapsed); // Return lockout time
}
}
}
}
// ... validation logic
}Sources:
Pairing codes use rejection sampling over UUID v4 (backed by OS CSPRNG) to eliminate modulo bias:
// src/security/pairing.rs
fn generate_code() -> String {
const UPPER_BOUND: u32 = 1_000_000;
const REJECT_THRESHOLD: u32 = (u32::MAX / UPPER_BOUND) * UPPER_BOUND;
loop {
let uuid = uuid::Uuid::new_v4();
let bytes = uuid.as_bytes();
let raw = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
if raw < REJECT_THRESHOLD {
return format!("{:06}", raw % UPPER_BOUND);
}
}
}Rejection probability is ~0.02%, so the loop exits on first iteration with overwhelming probability.
Sources:
Channels that support group chats (Telegram, Discord) can be configured with mention_only: true. When enabled, the bot only processes messages that explicitly mention it. This prevents the bot from responding to every message in busy group chats.
flowchart LR
Msg["Group Message"] --> CheckMode{"mention_only<br/>enabled?"}
CheckMode -->|No| Accept["Process Message"]
CheckMode -->|Yes| GetBotID["Get bot_user_id"]
GetBotID --> FindMention["find_bot_mention_spans(text, bot_id)"]
FindMention --> HasMention{"Contains<br/>@bot mention?"}
HasMention -->|Yes| Strip["Strip mention tags"]
Strip --> Accept
HasMention -->|No| Ignore["Ignore Message"]
style CheckMode fill:#f9f9f9
style HasMention fill:#f9f9f9
style Accept fill:#e0ffe0
style Ignore fill:#ffe0e0
Sources:
Telegram bots can be mentioned as @bot_username. The channel extracts the bot's username via getMe API and uses it to detect mentions:
// src/channels/telegram.rs
fn normalize_incoming_content(text: &str, bot_username: &str) -> Option<String> {
let spans = Self::find_bot_mention_spans(text, bot_username);
if spans.is_empty() {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
return (!normalized.is_empty()).then_some(normalized);
}
let mut normalized = String::with_capacity(text.len());
let mut cursor = 0;
for (start, end) in spans {
normalized.push_str(&text[cursor..start]);
cursor = end;
}
normalized.push_str(&text[cursor..]);
let normalized = normalized.split_whitespace().collect::<Vec<_>>().join(" ");
(!normalized.is_empty()).then_some(normalized)
}Mention tags are stripped from the content before passing it to the agent.
Sources:
Discord supports two mention formats:
-
<@USER_ID>(plain mention) -
<@!USER_ID>(nickname mention)
Both are detected and stripped:
// src/channels/discord.rs
fn mention_tags(bot_user_id: &str) -> [String; 2] {
[format!("<@{bot_user_id}>"), format!("<@!{bot_user_id}>")]
}
fn contains_bot_mention(content: &str, bot_user_id: &str) -> bool {
let tags = mention_tags(bot_user_id);
content.contains(&tags[0]) || content.contains(&tags[1])
}Sources:
Push-based channels (WhatsApp, Gateway endpoints) verify webhook authenticity using signature verification or verification tokens.
WhatsApp Cloud API requires a verification token for webhook registration. The gateway compares the token in webhook GET requests:
// Webhook verification (conceptual, implemented in gateway)
fn verify_whatsapp_webhook(query: &str, expected_token: &str) -> bool {
query.contains(&format!("hub.verify_token={}", expected_token))
}Sources:
The gateway (documented in Gateway Security) verifies webhook signatures for:
-
WhatsApp:
X-Hub-Signature-256header (HMAC-SHA256) - Telegram: Optional secret token in webhook URL
- Lark: Uses app verification token in event payloads
Sources: (inferred from high-level security architecture)
Channels that execute system commands or interpolate user input implement input validation and escaping.
The iMessage channel uses osascript to send messages. Without escaping, malicious input could execute arbitrary AppleScript:
-- VULNERABLE (without escaping):
tell application "Messages"
send "user input" to targetBuddy
end tellIf user input = "; do shell script "rm -rf /"; "``, this executes shell commands.
// src/channels/imessage.rs
fn escape_applescript(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
fn is_valid_imessage_target(target: &str) -> bool {
let target = target.trim();
if target.is_empty() {
return false;
}
// Phone: +1234567890
if target.starts_with('+') {
let digits_only: String = target.chars().filter(char::is_ascii_digit).collect();
return digits_only.len() >= 7 && digits_only.len() <= 15;
}
// Email: user@example.com
if let Some(at_pos) = target.find('@') {
let local = &target[..at_pos];
let domain = &target[at_pos + 1..];
// ... validation logic
}
false
}Both the message content and recipient are escaped before interpolation.
Sources:
Matrix channel encodes room IDs and user IDs for safe URL interpolation:
// src/channels/matrix.rs
fn encode_path_segment(value: &str) -> String {
fn should_encode(byte: u8) -> bool {
!matches!(
byte,
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~'
)
}
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if should_encode(byte) {
use std::fmt::Write;
let _ = write!(&mut encoded, "%{byte:02X}");
} else {
encoded.push(byte as char);
}
}
encoded
}Sources:
# ❌ BAD: Allows anyone
[channels.telegram]
allowed_users = ["*"]
# ✅ GOOD: Explicit allowlist
[channels.telegram]
allowed_users = ["alice_dev", "1234567890"]# Start daemon with empty allowlist
zeroclaw daemon
# Output:
# 🔐 Telegram pairing required. One-time bind code: 482391
# Send `/bind <code>` from your Telegram account.Users send /bind 482391 to authenticate. Their identity is persisted to config.toml.
[channels.telegram]
mention_only = true
[channels.discord]
mention_only = true[gateway]
bind = "127.0.0.1:3000" # Not 0.0.0.0
require_pairing = trueUse a reverse proxy (Caddy, nginx) with TLS for external access.
-
Matrix: Supports end-to-end encryption (requires
matrix-sdkwith E2EE features) - iMessage: E2EE by default (Apple's iMessage protocol)
- Signal: E2EE by default (requires Signal CLI integration)
Encrypted channels prevent message interception in transit.
Pairing codes are single-use and consumed after successful binding. If the operator needs to revoke access:
# Manual revocation: edit config.toml and remove user from allowed_users
zeroclaw onboard --channels-onlyChannels log warnings when unauthorized users attempt to send messages:
WARN: Telegram: ignoring message from unauthorized user: username=attacker, user_id=9999999999.
Allowlist Telegram username (without '@') or numeric user ID.
Set up log monitoring to detect brute-force attempts.
Sources:
flowchart TB
subgraph "Layer 1: Network"
Bind["Bind to 127.0.0.1"]
TLS["TLS for external access"]
end
subgraph "Layer 2: Authentication"
Pairing["PairingGuard<br/>(Telegram)"]
WebhookToken["Verification Tokens<br/>(WhatsApp)"]
Signature["Webhook Signatures<br/>(Gateway)"]
end
subgraph "Layer 3: Authorization"
Allowlist["Allowlist Check<br/>(deny-by-default)"]
MentionOnly["Mention-Only Filter<br/>(group chats)"]
end
subgraph "Layer 4: Input Validation"
Escape["AppleScript Escaping<br/>(iMessage)"]
URLEncode["URL Encoding<br/>(Matrix)"]
Normalize["Identity Normalization<br/>(strip @, lowercase)"]
end
Bind --> TLS
TLS --> Pairing
TLS --> WebhookToken
TLS --> Signature
Pairing --> Allowlist
WebhookToken --> Allowlist
Signature --> Allowlist
Allowlist --> MentionOnly
MentionOnly --> Escape
MentionOnly --> URLEncode
MentionOnly --> Normalize
style Pairing fill:#f9f9f9
style Allowlist fill:#f9f9f9
style Escape fill:#f9f9f9
Sources:
- src/security/pairing.rs:1-231
- src/channels/telegram.rs:299-738
- src/channels/imessage.rs:40-131
- src/channels/email_channel.rs:122-142
- src/channels/discord.rs:42-145