fix(security): block cross-channel approval thread hijacking (#1485)

Add source_channel to Thread and verify channel authorization before
allowing approval messages to target threads by UUID. The web gateway
channel is allowed as a trusted approval UI. Threads without
source_channel (deserialized from older DB records) are permitted
for backward compatibility.

Closes #1485

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Zaki
2026-03-28 15:10:20 +00:00
committed by Claude
co-authored by Claude Opus 4.6
parent 9bb19a98f7
commit 5d1d504e11
8 changed files with 146 additions and 81 deletions
+15 -2
View File
@@ -843,7 +843,7 @@ impl Agent {
{
use crate::agent::session::Thread;
let mut sess = session.lock().await;
let thread = Thread::with_id(id, sess.id);
let thread = Thread::with_id(id, sess.id, None);
sess.active_thread = Some(id);
sess.threads.entry(id).or_insert(thread);
}
@@ -1148,7 +1148,20 @@ impl Agent {
.get_or_create_session(&message.user_id)
.await;
let mut sess = session.lock().await;
if sess.threads.contains_key(&target_thread_id) {
if let Some(thread) = sess.threads.get(&target_thread_id) {
let authorized = thread.source_channel.as_ref().is_none_or(|src| {
src == &message.channel || message.channel == "web"
});
if !authorized {
tracing::warn!(
%target_thread_id,
source_channel = ?thread.source_channel,
approval_channel = %message.channel,
"Blocked cross-channel approval attempt"
);
drop(sess);
return Ok(Some("Error: approval not authorized for this channel".into()));
}
sess.active_thread = Some(target_thread_id);
sess.last_active_at = chrono::Utc::now();
drop(sess);