fix(security): harden cross-channel approval authorization

- Fix migration number collisions (V14 already taken by users migration;
  rename to V15 for PostgreSQL, bump to 16 for libSQL)
- Extract TRUSTED_APPROVAL_CHANNELS constant to replace hardcoded
  "web"/"gateway" in is_approval_authorized(); WASM setup imports it
- Add __bootstrap__ sentinel to WASM reserved channel names to prevent
  impersonation granting universal approval rights
- Fix TenantScope::ensure_conversation passing None for source_channel,
  which silently blocked approvals for tenant-created threads
- Add 11 regression tests: authorization logic, WASM reserved name
  validation, libSQL source_channel DB round-trip and upsert invariant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ilblackdragon@gmail.com
2026-03-30 00:40:06 -07:00
parent 8e1ff86500
commit aa151d9fa3
6 changed files with 304 additions and 11 deletions

View File

@@ -239,6 +239,11 @@ pub const MAX_PENDING_MESSAGES: usize = 10;
/// Sentinel value for bootstrap threads that accept approvals from any channel.
pub const BOOTSTRAP_SOURCE_CHANNEL: &str = "__bootstrap__";
/// Channels that are always authorized to approve tool calls on any thread,
/// regardless of which channel originally created the thread. These are
/// trusted UI surfaces (the web dashboard and its gateway).
pub const TRUSTED_APPROVAL_CHANNELS: &[&str] = &["web", "gateway"];
/// Check whether an approval from `requesting_channel` is authorized for a
/// thread whose `source_channel` is `source`.
///
@@ -246,13 +251,13 @@ pub const BOOTSTRAP_SOURCE_CHANNEL: &str = "__bootstrap__";
/// - `None` (unknown origin) -> denied (fail-closed)
/// - `Some("__bootstrap__")` -> authorized from any channel
/// - `Some(src) == requesting` -> same channel, authorized
/// - requesting is "web" or "gateway" -> always authorized (trusted UI)
/// - requesting is in `TRUSTED_APPROVAL_CHANNELS` -> always authorized
/// - Otherwise -> denied
pub fn is_approval_authorized(source: Option<&str>, requesting: &str) -> bool {
match source {
None => false,
Some(src) if src == BOOTSTRAP_SOURCE_CHANNEL => true,
Some(src) => src == requesting || requesting == "web" || requesting == "gateway",
Some(src) => src == requesting || TRUSTED_APPROVAL_CHANNELS.contains(&requesting),
}
}
@@ -1926,4 +1931,110 @@ mod tests {
"__bootstrap__ should be authorized from any channel"
);
}
#[test]
fn test_approval_authorized_uses_trusted_channels_constant() {
// Every channel in TRUSTED_APPROVAL_CHANNELS should be authorized
// against any source, ensuring the constant drives the logic.
for &trusted in TRUSTED_APPROVAL_CHANNELS {
assert!(
is_approval_authorized(Some("any-source"), trusted),
"TRUSTED_APPROVAL_CHANNELS entry '{}' should always be authorized",
trusted
);
}
}
#[test]
fn test_approval_blocks_thread_without_pending_approval() {
// A thread with no pending_approval should not be eligible for
// approval routing. This test verifies the data-level invariant
// that `agent_loop.rs` checks before calling is_approval_authorized.
let thread = Thread::new(Uuid::new_v4(), Some("telegram"));
assert!(
thread.pending_approval.is_none(),
"new thread should have no pending approval"
);
// Set up a thread WITH a pending approval to contrast
let mut thread_with_approval = Thread::new(Uuid::new_v4(), Some("telegram"));
thread_with_approval.pending_approval = Some(PendingApproval {
request_id: Uuid::new_v4(),
tool_name: "shell".to_string(),
parameters: serde_json::json!({"cmd": "rm -rf /"}),
display_parameters: serde_json::json!({"cmd": "rm -rf /"}),
description: "run shell command".to_string(),
tool_call_id: "call_1".to_string(),
context_messages: vec![],
deferred_tool_calls: vec![],
user_timezone: None,
allow_always: true,
});
assert!(
thread_with_approval.pending_approval.is_some(),
"thread with pending approval should be eligible"
);
// Authorization check should pass for the thread with pending approval
// (same channel), confirming the two checks compose correctly.
assert!(is_approval_authorized(
thread_with_approval.source_channel.as_deref(),
"telegram"
));
}
#[test]
fn test_approval_wasm_channel_cannot_impersonate_trusted() {
// A WASM channel named "web" or "gateway" would bypass authorization.
// This test documents the invariant that WASM setup must reject these
// names (tested separately in wasm/setup.rs).
// Here we verify the authorization logic itself treats them as trusted.
assert!(is_approval_authorized(Some("telegram"), "web"));
assert!(is_approval_authorized(Some("telegram"), "gateway"));
// But a random WASM channel name should NOT be trusted
assert!(!is_approval_authorized(Some("telegram"), "my-wasm-channel"));
}
#[test]
fn test_approval_bootstrap_sentinel_not_a_normal_channel() {
// If a channel happens to be named __bootstrap__, it should be treated
// as the source (always authorized), NOT as a requesting channel with
// special trust. Only TRUSTED_APPROVAL_CHANNELS get that privilege.
assert!(
!is_approval_authorized(Some("telegram"), BOOTSTRAP_SOURCE_CHANNEL),
"__bootstrap__ as requesting channel should not have special trust"
);
}
#[test]
fn test_create_thread_propagates_channel() {
let mut session = Session::new("user-chan");
let tid = session.create_thread(Some("signal")).id;
let thread = session.threads.get(&tid).unwrap();
assert_eq!(thread.source_channel.as_deref(), Some("signal"));
}
#[test]
fn test_get_or_create_thread_propagates_channel() {
let mut session = Session::new("user-chan2");
// First call creates
let tid = session.get_or_create_thread(Some("http")).id;
assert_eq!(
session.threads.get(&tid).unwrap().source_channel.as_deref(),
Some("http")
);
// Second call returns existing (channel param ignored)
let tid2 = session.get_or_create_thread(Some("different")).id;
assert_eq!(tid, tid2);
assert_eq!(
session
.threads
.get(&tid2)
.unwrap()
.source_channel
.as_deref(),
Some("http"),
"existing thread should keep its original source_channel"
);
}
}

View File

@@ -75,12 +75,14 @@ pub async fn setup_wasm_channels(
// Reserved channel names that WASM modules must not claim.
// A malicious module could otherwise register as a trusted built-in
// channel and bypass cross-channel authorization checks.
// This list must cover every built-in channel name to prevent a WASM
// module from impersonating a built-in and satisfying same-channel
// approval checks.
const RESERVED_CHANNEL_NAMES: &[&str] = &[
"web",
"gateway",
//
// This list includes:
// - All built-in channel names (prevent impersonation)
// - Trusted approval channels from session::TRUSTED_APPROVAL_CHANNELS
// - The bootstrap sentinel (universal approval wildcard)
use crate::agent::session::{BOOTSTRAP_SOURCE_CHANNEL, TRUSTED_APPROVAL_CHANNELS};
let mut reserved: Vec<&str> = vec![
"cli",
"repl",
"http",
@@ -88,10 +90,12 @@ pub async fn setup_wasm_channels(
"slack-relay",
"secret_save",
];
reserved.extend(TRUSTED_APPROVAL_CHANNELS);
reserved.push(BOOTSTRAP_SOURCE_CHANNEL);
for loaded in results.loaded {
let name_lower = loaded.name().to_ascii_lowercase();
if RESERVED_CHANNEL_NAMES.contains(&name_lower.as_str()) {
if reserved.contains(&name_lower.as_str()) {
tracing::warn!(
channel = %loaded.name(),
"Rejected WASM channel with reserved name"
@@ -489,3 +493,74 @@ async fn inject_channel_secrets_into_config(
}
}
}
#[cfg(test)]
mod tests {
use crate::agent::session::{BOOTSTRAP_SOURCE_CHANNEL, TRUSTED_APPROVAL_CHANNELS};
/// Build the same reserved-name list that `setup_wasm_channels` uses.
fn reserved_names() -> Vec<&'static str> {
let mut reserved: Vec<&str> = vec![
"cli",
"repl",
"http",
"signal",
"slack-relay",
"secret_save",
];
reserved.extend(TRUSTED_APPROVAL_CHANNELS);
reserved.push(BOOTSTRAP_SOURCE_CHANNEL);
reserved
}
#[test]
fn reserved_names_include_trusted_approval_channels() {
let reserved = reserved_names();
for &trusted in TRUSTED_APPROVAL_CHANNELS {
assert!(
reserved.contains(&trusted),
"trusted approval channel '{}' must be in WASM reserved names",
trusted
);
}
}
#[test]
fn reserved_names_include_bootstrap_sentinel() {
let reserved = reserved_names();
assert!(
reserved.contains(&BOOTSTRAP_SOURCE_CHANNEL),
"__bootstrap__ sentinel must be in WASM reserved names"
);
}
#[test]
fn reserved_names_reject_case_insensitive() {
// The setup logic lowercases the WASM channel name before checking.
// Verify that "Web" or "GATEWAY" would be caught.
let reserved = reserved_names();
let test_cases = ["Web", "GATEWAY", "CLI", "Repl", "__BOOTSTRAP__"];
for name in test_cases {
let lowered = name.to_ascii_lowercase();
assert!(
reserved.contains(&lowered.as_str()),
"'{}' (lowercased to '{}') should match a reserved name",
name,
lowered
);
}
}
#[test]
fn non_reserved_names_allowed() {
let reserved = reserved_names();
let allowed = ["telegram", "discord", "my-custom-channel", "slack-bot"];
for name in allowed {
assert!(
!reserved.contains(&name),
"'{}' should NOT be reserved",
name
);
}
}
}

View File

@@ -748,4 +748,111 @@ mod tests {
"Expected same heartbeat conversation on repeated calls"
);
}
#[tokio::test]
async fn test_source_channel_round_trip() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test_source_channel.db");
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
backend.run_migrations().await.unwrap();
let conv_id = Uuid::new_v4();
let user_id = "user-src-chan";
// Create conversation with a source_channel
let created = backend
.ensure_conversation(conv_id, "telegram", user_id, None, Some("telegram"))
.await
.unwrap();
assert!(created, "first ensure should create");
// Read it back
let source = backend
.get_conversation_source_channel(conv_id)
.await
.unwrap();
assert_eq!(
source.as_deref(),
Some("telegram"),
"source_channel should round-trip through DB"
);
}
#[tokio::test]
async fn test_source_channel_none_round_trip() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test_source_channel_none.db");
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
backend.run_migrations().await.unwrap();
let conv_id = Uuid::new_v4();
let user_id = "user-no-src";
// Create conversation without source_channel
backend
.ensure_conversation(conv_id, "http", user_id, None, None)
.await
.unwrap();
let source = backend
.get_conversation_source_channel(conv_id)
.await
.unwrap();
assert!(
source.is_none(),
"None source_channel should persist as NULL"
);
}
#[tokio::test]
async fn test_source_channel_not_found() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test_source_channel_404.db");
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
backend.run_migrations().await.unwrap();
let source = backend
.get_conversation_source_channel(Uuid::new_v4())
.await
.unwrap();
assert!(
source.is_none(),
"non-existent conversation should return None"
);
}
#[tokio::test]
async fn test_source_channel_not_overwritten_on_upsert() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test_source_channel_upsert.db");
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
backend.run_migrations().await.unwrap();
let conv_id = Uuid::new_v4();
let user_id = "user-upsert";
// First insert with source_channel = "telegram"
backend
.ensure_conversation(conv_id, "telegram", user_id, None, Some("telegram"))
.await
.unwrap();
// Upsert same conversation (same user/channel) — source_channel should
// NOT be overwritten because the ON CONFLICT clause only updates
// last_activity.
backend
.ensure_conversation(conv_id, "telegram", user_id, None, Some("different"))
.await
.unwrap();
let source = backend
.get_conversation_source_channel(conv_id)
.await
.unwrap();
assert_eq!(
source.as_deref(),
Some("telegram"),
"upsert should not overwrite original source_channel"
);
}
}

View File

@@ -788,7 +788,7 @@ CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
"#,
),
(
15,
16,
"conversation_source_channel",
// Add source_channel to conversations for cross-channel approval authorization.
r#"

View File

@@ -278,7 +278,7 @@ impl TenantScope {
thread_id: Option<&str>,
) -> Result<bool, DatabaseError> {
self.inner
.ensure_conversation(id, channel, &self.user_id, thread_id, None)
.ensure_conversation(id, channel, &self.user_id, thread_id, Some(channel))
.await
}