Skip to content

Commit 2d1e344

Browse files
Robert E. Leeruvnet
andcommitted
fix: Address CRITICAL and HIGH security audit findings
- CRIT-01: Scrub bot token from teloxide error messages to prevent credential leakage - CRIT-02: Validate session_id length (128 max) and characters (alphanumeric/hyphens) - HIGH-03: Verify approval session ownership matches authorized chat_id (prevents IDOR) - HIGH-04: Add 1MiB per-line limit on NDJSON reader to prevent memory exhaustion - HIGH-05: Limit concurrent socket connections to 64 via semaphore (DoS prevention) - HIGH-06: Use i64 for polling offset to prevent overflow at i32::MAX - MED-05: Set umask(0o177) before socket bind to prevent brief world-accessible window Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 61814aa commit 2d1e344

3 files changed

Lines changed: 80 additions & 11 deletions

File tree

src/bot.rs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ use teloxide::types::{
88
ChatId, InlineKeyboardButton, InlineKeyboardMarkup, MessageId, ParseMode, ThreadId,
99
};
1010

11+
/// Scrub bot token from error messages to prevent credential leakage.
12+
/// Teloxide wraps reqwest errors that include the full API URL with token.
13+
fn scrub_telegram_error(err: &teloxide::RequestError) -> String {
14+
let raw = err.to_string();
15+
// Telegram API URLs contain the token: /bot<TOKEN>/method
16+
// Scrub anything that looks like a bot token (numeric:alphanumeric)
17+
let re = regex::Regex::new(r"bot\d+:[A-Za-z0-9_-]+/").unwrap_or_else(|_| {
18+
regex::Regex::new(r"$^").unwrap() // fallback: match nothing
19+
});
20+
re.replace_all(&raw, "bot<REDACTED>/").to_string()
21+
}
22+
1123
/// Telegram Bot wrapper with rate limiting and forum topic support
1224
pub struct TelegramBot {
1325
bot: Bot,
@@ -71,7 +83,8 @@ impl TelegramBot {
7183
req = req.message_thread_id(ThreadId(MessageId(tid)));
7284
}
7385

74-
req.await.map_err(|e| AppError::Telegram(e.to_string()))
86+
req.await
87+
.map_err(|e| AppError::Telegram(scrub_telegram_error(&e)))
7588
}
7689

7790
/// Send a message with inline keyboard buttons
@@ -115,7 +128,8 @@ impl TelegramBot {
115128
req = req.message_thread_id(ThreadId(MessageId(tid)));
116129
}
117130

118-
req.await.map_err(|e| AppError::Telegram(e.to_string()))
131+
req.await
132+
.map_err(|e| AppError::Telegram(scrub_telegram_error(&e)))
119133
}
120134

121135
/// Create a forum topic
@@ -210,7 +224,8 @@ impl TelegramBot {
210224
req = req.parse_mode(pm);
211225
}
212226

213-
req.await.map_err(|e| AppError::Telegram(e.to_string()))?;
227+
req.await
228+
.map_err(|e| AppError::Telegram(scrub_telegram_error(&e)))?;
214229
Ok(())
215230
}
216231

@@ -223,19 +238,20 @@ impl TelegramBot {
223238
req = req.text(t);
224239
}
225240

226-
req.await.map_err(|e| AppError::Telegram(e.to_string()))?;
241+
req.await
242+
.map_err(|e| AppError::Telegram(scrub_telegram_error(&e)))?;
227243
Ok(())
228244
}
229245

230246
/// Poll for updates (long polling)
231-
pub async fn get_updates(&self, offset: i32) -> Result<Vec<Update>> {
247+
pub async fn get_updates(&self, offset: i64) -> Result<Vec<Update>> {
232248
let updates = self
233249
.bot
234250
.get_updates()
235-
.offset(offset)
251+
.offset(offset as i32)
236252
.timeout(30)
237253
.await
238-
.map_err(|e| AppError::Telegram(e.to_string()))?;
254+
.map_err(|e| AppError::Telegram(scrub_telegram_error(&e)))?;
239255

240256
Ok(updates)
241257
}

src/bridge.rs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,21 @@ impl BridgeShared {
174174
msg: BridgeMessage,
175175
_broadcast_tx: &broadcast::Sender<BridgeMessage>,
176176
) -> Result<()> {
177+
// CRIT-02: Validate session_id to prevent unbounded memory growth
178+
const MAX_SESSION_ID_LEN: usize = 128;
179+
if msg.session_id.len() > MAX_SESSION_ID_LEN
180+
|| !msg
181+
.session_id
182+
.chars()
183+
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
184+
{
185+
tracing::warn!(
186+
len = msg.session_id.len(),
187+
"Rejecting message with invalid session_id"
188+
);
189+
return Ok(());
190+
}
191+
177192
tracing::debug!(msg_type = ?msg.msg_type, session_id = %msg.session_id, "Socket message");
178193

179194
// Update session activity
@@ -577,13 +592,14 @@ impl BridgeShared {
577592
// ============ Telegram Update Handling (Telegram -> CLI) ============
578593

579594
async fn poll_telegram_updates(&self) {
580-
let mut offset = 0i32;
595+
let mut offset = 0i64;
581596

582597
loop {
583598
match self.bot.get_updates(offset).await {
584599
Ok(updates) => {
585600
for update in &updates {
586-
offset = update.id.0 as i32 + 1;
601+
// HIGH-06: Use i64 to prevent u32->i32 overflow at i32::MAX
602+
offset = (update.id.0 as i64) + 1;
587603

588604
// Security fix #5: Chat ID filter on ALL updates
589605
if !bot::is_authorized_chat(update, self.config.chat_id) {
@@ -826,6 +842,21 @@ impl BridgeShared {
826842
let approval = sessions.get_approval(id);
827843

828844
if let Some(approval) = approval {
845+
// HIGH-03: Verify approval belongs to this chat (prevent IDOR)
846+
if let Some(session) = sessions.get_session(&approval.session_id) {
847+
if session.chat_id != self.config.chat_id {
848+
tracing::warn!(
849+
approval_id = %id,
850+
"Approval belongs to different chat, rejecting"
851+
);
852+
let _ = self
853+
.bot
854+
.answer_callback_query(&query.id, Some("Unauthorized"))
855+
.await;
856+
return;
857+
}
858+
}
859+
829860
let status = match action {
830861
"approve" => "approved",
831862
"reject" | "abort" => "rejected",

src/socket.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
use crate::error::{AppError, Result};
22
use crate::types::BridgeMessage;
33
use nix::fcntl::{Flock, FlockArg};
4+
use nix::sys::stat::{umask, Mode};
45
use std::fs;
56
use std::os::unix::fs::PermissionsExt;
67
use std::path::PathBuf;
8+
use std::sync::Arc;
79
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
810
use tokio::net::{UnixListener, UnixStream};
911
use tokio::sync::{broadcast, mpsc};
@@ -131,13 +133,16 @@ impl SocketServer {
131133
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
132134
}
133135

134-
// Step 4: Bind the listener
136+
// Step 4: Bind the listener with restrictive umask (MED-05: prevent brief 0755 window)
137+
let old_mask = umask(Mode::from_bits_truncate(0o177));
135138
let listener = UnixListener::bind(&self.socket_path).map_err(|e| {
139+
umask(old_mask);
136140
self.release_pid_lock();
137141
AppError::Io(e)
138142
})?;
143+
umask(old_mask);
139144

140-
// Security fix #3: Set socket file permissions to 0o600
145+
// Ensure socket file permissions are 0o600 (belt-and-suspenders with umask)
141146
fs::set_permissions(&self.socket_path, fs::Permissions::from_mode(0o600))?;
142147

143148
tracing::info!(
@@ -151,14 +156,26 @@ impl SocketServer {
151156
let (broadcast_tx, _) = broadcast::channel::<BridgeMessage>(256);
152157
let broadcast_tx_clone = broadcast_tx.clone();
153158

159+
// HIGH-05: Limit concurrent connections to prevent DoS
160+
let conn_semaphore = Arc::new(tokio::sync::Semaphore::new(64));
161+
154162
// Accept connections in background
155163
tokio::spawn(async move {
156164
loop {
157165
match listener.accept().await {
158166
Ok((stream, _addr)) => {
159167
let tx = msg_tx.clone();
160168
let btx = broadcast_tx_clone.clone();
169+
let permit = match conn_semaphore.clone().try_acquire_owned() {
170+
Ok(p) => p,
171+
Err(_) => {
172+
tracing::warn!("Max connections reached, rejecting");
173+
drop(stream);
174+
continue;
175+
}
176+
};
161177
tokio::spawn(async move {
178+
let _permit = permit; // released when task ends
162179
if let Err(e) = handle_client_connection(stream, tx, btx).await {
163180
tracing::debug!("Client connection ended: {}", e);
164181
}
@@ -213,11 +230,16 @@ async fn handle_client_connection(
213230
});
214231

215232
// Read NDJSON lines from client
233+
const MAX_LINE_BYTES: usize = 1_048_576; // HIGH-04: 1 MiB per-line limit
216234
while let Ok(Some(line)) = lines.next_line().await {
217235
let line = line.trim().to_string();
218236
if line.is_empty() {
219237
continue;
220238
}
239+
if line.len() > MAX_LINE_BYTES {
240+
tracing::warn!(len = line.len(), "Oversized NDJSON line, dropping");
241+
continue;
242+
}
221243

222244
// Security fix #10: serde_json::from_str returns Result, no unwrap/panic
223245
match serde_json::from_str::<BridgeMessage>(&line) {

0 commit comments

Comments
 (0)