Skip to content

06.3 Channel Security

Nikolay Vyahhi edited this page Feb 19, 2026 · 3 revisions

Channel Security

Relevant source files

The following files were used as context for generating this wiki page:

Purpose and Scope

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.


Deny-by-Default Access Control

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.

Allowlist Configuration by Channel

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 ["*"]
Email allowed_senders Email address or @domain.com ["*"]
WhatsApp 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).

Access Control Flow

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
Loading

Sources:

Implementation: Telegram Allowlist Check

// 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:


Pairing Authentication (Telegram)

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.

Pairing Flow

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
Loading

Sources:

Brute-Force Protection

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:

Code Generation: Cryptographic Randomness

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:


Mention-Only Modes

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.

Mention Detection

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
Loading

Sources:

Telegram: Mention Normalization

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: Mention Formats

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:


Webhook Verification

Push-based channels (WhatsApp, Gateway endpoints) verify webhook authenticity using signature verification or verification tokens.

WhatsApp: Verification Token

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:

Gateway: Webhook Signature Verification

The gateway (documented in Gateway Security) verifies webhook signatures for:

  • WhatsApp: X-Hub-Signature-256 header (HMAC-SHA256)
  • Telegram: Optional secret token in webhook URL
  • Lark: Uses app verification token in event payloads

Sources: (inferred from high-level security architecture)


Input Validation and Injection Prevention

Channels that execute system commands or interpolate user input implement input validation and escaping.

iMessage: AppleScript Injection Prevention

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 tell

If user input = "; do shell script "rm -rf /"; "``, this executes shell commands.

Defense: Escaping + Validation

// 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: Path Encoding

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:


Security Best Practices

1. Never use wildcard allowlists in production

# ❌ BAD: Allows anyone
[channels.telegram]
allowed_users = ["*"]

# ✅ GOOD: Explicit allowlist
[channels.telegram]
allowed_users = ["alice_dev", "1234567890"]

2. Use pairing mode for initial setup

# 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.

3. Enable mention-only for group chats

[channels.telegram]
mention_only = true

[channels.discord]
mention_only = true

4. Restrict webhook endpoints to localhost

[gateway]
bind = "127.0.0.1:3000"  # Not 0.0.0.0
require_pairing = true

Use a reverse proxy (Caddy, nginx) with TLS for external access.

5. Use E2EE channels when possible

  • Matrix: Supports end-to-end encryption (requires matrix-sdk with 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.

6. Rotate pairing codes after binding

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-only

7. Monitor unauthorized access attempts

Channels 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:


Security Implementation Summary

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
Loading

Sources:


Clone this wiki locally