Skip to content

Security: DreamLab-AI/Claude-Code-Rust-Telegram

Security

docs/SECURITY.md

Security Documentation

Threat Model

graph TB
    subgraph "Trust Boundary: Developer Machine"
        CC[Claude Code CLI]
        TMUX[tmux]
        CTM[CTM Bridge]
        DB[(SQLite DB)]
        SOCK[Unix Socket]
    end

    subgraph "Trust Boundary: Network"
        TGAPI[Telegram Bot API<br/>HTTPS/TLS]
    end

    subgraph "Trust Boundary: User Device"
        PHONE[Telegram App]
    end

    CC -->|hooks| CTM
    CTM -->|NDJSON| SOCK
    CTM -->|queries| DB
    CTM -->|send-keys| TMUX
    CTM <-->|polling| TGAPI
    TGAPI <--> PHONE

    ATTACKER1[Attacker: Local user] -.->|read config| DB
    ATTACKER1 -.->|connect| SOCK
    ATTACKER2[Attacker: Telegram user] -.->|send messages| TGAPI
    ATTACKER3[Attacker: Network] -.->|MITM| TGAPI

    style ATTACKER1 fill:#f66,stroke:#333
    style ATTACKER2 fill:#f66,stroke:#333
    style ATTACKER3 fill:#f66,stroke:#333
Loading

Attack Surfaces

Surface Risk Mitigation
Unix socket Local user connects 0o600 permissions, flock PID
Config file Token theft 0o600 permissions
Telegram API Unauthorized commands Chat ID validation on ALL updates
tmux injection Command injection Command::arg(), key whitelist
JSON parsing Denial of service serde_json Result handling, no panics
PID file Race condition flock(2) atomic locking
Rate limiting API abuse governor token-bucket per bot
File downloads Path traversal, symlinks UUID filenames, 0o600 perms, absolute path validation

Vulnerability Fixes

CRITICAL-1: Command Injection in tmux Slash Commands

Before (TypeScript):

// VULNERABLE: User input interpolated into shell command
execSync(`tmux send-keys -t ${target} "${userInput}" Enter`);
// Input: `"; rm -rf / #` -> executes arbitrary commands

After (Rust):

// SAFE: Each argument is passed separately, never shell-interpreted
fn inject(&self, text: &str) -> Result<bool> {
    self.run_tmux(&["send-keys", "-t", &target, "-l", text])?;
    self.run_tmux(&["send-keys", "-t", &target, "Enter"])?;
    Ok(true)
}

fn run_tmux(&self, args: &[&str]) -> Result<bool> {
    let mut cmd = std::process::Command::new("tmux");
    for arg in args {
        cmd.arg(arg);  // Each arg is a separate OS argument
    }
    // ...
}

CRITICAL-2: FIFO Path Injection

Before: User-controlled path embedded in shell command for named pipe communication.

After: FIFO mechanism eliminated entirely. All communication uses Unix socket with NDJSON protocol.

CRITICAL-3: World-Readable Config Files

Before: Config files created with default permissions (typically 0o644), exposing bot tokens to all local users.

After:

// Config files
let file = OpenOptions::new()
    .write(true)
    .create(true)
    .mode(0o600)  // Owner read/write only
    .open(&path)?;

// Config directory
fs::create_dir_all(&dir)?;
fs::set_permissions(&dir, Permissions::from_mode(0o700))?;

HIGH-4: Logs in World-Readable /tmp

Before: Debug logs written to /tmp/ with default permissions.

After: All data stored in ~/.config/claude-telegram-mirror/ with 0o700 directory and 0o600 file permissions.

HIGH-5: Chat ID Bypass on Callback Queries

Before: Chat ID was only checked on message updates, not callback queries. An attacker with a different Telegram account could send callback data to approve/reject tool executions.

After:

pub fn is_authorized_chat(update: &Update, authorized_chat_id: i64) -> bool {
    // Check message-based updates
    if let Some(chat) = update.chat() {
        return chat.id.0 == authorized_chat_id;
    }
    // Also check callback queries
    if let UpdateKind::CallbackQuery(query) = &update.kind {
        if let Some(msg) = &query.message {
            return msg.chat().id.0 == authorized_chat_id;
        }
    }
    false
}

HIGH-6: Config Directory Without Restrictive Permissions

Before: mkdir without explicit mode, inheriting umask (often 0o755).

After:

pub fn ensure_config_dir(dir: &Path) -> Result<()> {
    if !dir.exists() {
        fs::create_dir_all(dir)?;
    }
    fs::set_permissions(dir, Permissions::from_mode(0o700))?;
    Ok(())
}

HIGH-7: tmux Target Shell Interpolation

Before: tmux target string (e.g., workspace:0.0) was interpolated into shell commands.

After: Target is always passed as a separate .arg() parameter:

cmd.arg("-t").arg(&self.target);  // Never interpolated

MEDIUM-8: TOCTOU Race in PID Locking

Before:

// Check-then-write: race condition between check and write
if (fs.existsSync(pidFile)) {
    const pid = fs.readFileSync(pidFile);
    if (isRunning(pid)) throw "already running";
}
fs.writeFileSync(pidFile, process.pid);  // TOCTOU gap!

After:

// Atomic: flock(2) is kernel-level, no race possible
let file = fs::OpenOptions::new().write(true).create(true).open(&pid_path)?;
match Flock::lock(file, FlockArg::LockExclusiveNonblock) {
    Ok(locked_file) => { /* We hold the lock */ }
    Err((_, errno)) if errno == Errno::EWOULDBLOCK => {
        /* Another process holds it */
    }
}

MEDIUM-9: No Input Rate Limiting

Before: No rate limiting on Telegram Bot API calls, risking 429 errors.

After:

// governor token-bucket: 25 requests/second
let quota = Quota::per_second(NonZeroU32::new(25).unwrap());
let rate_limiter = Arc::new(RateLimiter::direct(quota));

// Every API call waits for a token
self.rate_limiter.until_ready().await;

MEDIUM-10: Panic on Malformed JSON

Before:

const event = JSON.parse(input);  // Throws on invalid JSON
event.tool_name.toLowerCase();     // Throws on missing field

After:

// Returns Result, never panics
match serde_json::from_str::<BridgeMessage>(&line) {
    Ok(msg) => { /* process */ }
    Err(e) => {
        tracing::warn!(error = %e, "Failed to parse NDJSON message");
    }
}

File Transfer Security

Inbound (Telegram -> local disk)

Photos and documents received from Telegram are downloaded securely:

  • Download directory: /tmp/ctm-images/ created with 0o700 permissions
  • UUID filenames: Files are saved as {uuid}.{ext} (photos) or {uuid}_{sanitized_name} (documents), preventing predictable paths
  • Atomic writes: Files are downloaded to {uuid}.downloading then renamed to final path, preventing partial reads
  • File permissions: All downloaded files set to 0o600 (owner-only)
  • Filename sanitization: Original document filenames have / and \ replaced with _ to prevent path traversal

Outbound (local disk -> Telegram)

The send_image socket message type validates file paths before sending:

  • Absolute path required: Relative paths are rejected
  • No path traversal: Paths containing .. are rejected
  • Existence check: File must exist before sending
  • Extension-based routing: Image extensions (jpg, png, gif, webp, bmp) sent as photos; all others sent as documents

tmux Key Whitelist

Only these keys can be sent to tmux via the send_key() method:

pub const ALLOWED_TMUX_KEYS: &[&str] = &[
    "Enter", "Escape", "Tab", "BSpace", "DC",
    "Up", "Down", "Left", "Right",
    "Home", "End", "PageUp", "PageDown",
    "C-c", "C-d", "C-z", "C-a", "C-e", "C-l",
    "F1", "F2", "F3", "F4", "F5",
    "F6", "F7", "F8", "F9", "F10", "F11", "F12",
];

Any key not in this list is rejected:

pub fn send_key(&self, key: &str) -> Result<bool> {
    if !ALLOWED_TMUX_KEYS.contains(&key) {
        return Err(AppError::Injection(
            format!("Key '{}' not in whitelist", key)
        ));
    }
    self.run_tmux(&["send-keys", "-t", &target, key])
}

File Permission Summary

graph TB
    subgraph "Directory: 0o700"
        DIR[~/.config/claude-telegram-mirror/]
    end

    subgraph "Files: 0o600"
        CONFIG[config.json]
        DB[sessions.db]
        SOCK[bridge.sock]
        PID[bridge.pid]
        LOG[supervisor.log]
    end

    subgraph "Download Dir: 0o700"
        DLDIR[/tmp/ctm-images/]
        DLFILES["{uuid}.{ext} — 0o600"]
    end

    DIR --> CONFIG
    DIR --> DB
    DIR --> SOCK
    DIR --> PID
    DIR --> LOG
    DLDIR --> DLFILES

    style DIR fill:#ffa,stroke:#333
    style CONFIG fill:#afa,stroke:#333
    style DB fill:#afa,stroke:#333
    style SOCK fill:#afa,stroke:#333
    style PID fill:#afa,stroke:#333
Loading

Security Checklist

  • No unsafe blocks in application code
  • No unwrap() on user-supplied data
  • No shell interpolation (Command::arg() everywhere)
  • All files created with restrictive permissions
  • Chat ID validated on ALL update types
  • PID locking uses atomic flock(2)
  • Rate limiting on all Telegram API calls
  • tmux keys restricted to whitelist
  • NDJSON parsing returns Result
  • No secrets in logs or error messages
  • File downloads use UUID filenames and 0o600 perms
  • Outbound file paths validated (absolute, no traversal, exists)

There aren’t any published security advisories