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
| 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 |
Before (TypeScript):
// VULNERABLE: User input interpolated into shell command
execSync(`tmux send-keys -t ${target} "${userInput}" Enter`);
// Input: `"; rm -rf / #` -> executes arbitrary commandsAfter (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
}
// ...
}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.
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))?;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.
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
}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(())
}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 interpolatedBefore:
// 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 */
}
}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;Before:
const event = JSON.parse(input); // Throws on invalid JSON
event.tool_name.toLowerCase(); // Throws on missing fieldAfter:
// 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");
}
}Photos and documents received from Telegram are downloaded securely:
- Download directory:
/tmp/ctm-images/created with0o700permissions - UUID filenames: Files are saved as
{uuid}.{ext}(photos) or{uuid}_{sanitized_name}(documents), preventing predictable paths - Atomic writes: Files are downloaded to
{uuid}.downloadingthen 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
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
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])
}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
- No
unsafeblocks 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)