fix: address code review findings for cross-channel approval security

1. Add "telegram" to WASM channel name blocklist -- bundled channels
   like telegram were claimable by malicious WASM modules that load
   before the bundled one, bypassing cross-channel approval auth.

2. Make V16 libSQL migration (ADD COLUMN source_channel) idempotent --
   the runner now checks pragma_table_info before executing ALTER TABLE,
   preventing startup failures if the base schema already includes the
   column.

3. Replace silent .unwrap_or(None) in thread hydration with explicit
   match on DB result -- legacy threads without stored source_channel
   now log a warning, and DB errors log an error. Both cases remain
   fail-closed (approvals denied) but are no longer silent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zaki
2026-03-30 19:51:02 -07:00
parent aa151d9fa3
commit 92138b8ccf
3 changed files with 85 additions and 8 deletions

View File

@@ -138,11 +138,34 @@ impl Agent {
// Create thread with the historical ID and restore messages.
// Read source_channel from DB so the authorization check uses the
// original creator's channel, not the requesting message's channel.
//
// Fail-closed policy: if the DB lookup fails or the conversation has
// no stored source_channel (legacy row), the thread is hydrated with
// source_channel = None. `is_approval_authorized(None, _)` returns
// false, so approvals are denied until the conversation is backfilled
// with a source_channel via an explicit migration or re-creation.
let db_source_channel = if let Some(store) = self.store() {
store
.get_conversation_source_channel(thread_uuid)
.await
.unwrap_or(None)
match store.get_conversation_source_channel(thread_uuid).await {
Ok(sc) => {
if sc.is_none() {
tracing::warn!(
thread_id = %thread_uuid,
"Legacy thread has no stored source_channel; \
cross-channel approvals will be denied (fail-closed)"
);
}
sc
}
Err(e) => {
tracing::error!(
thread_id = %thread_uuid,
error = %e,
"Failed to read source_channel from DB; \
cross-channel approvals will be denied (fail-closed)"
);
None
}
}
} else {
None
};

View File

@@ -87,6 +87,7 @@ pub async fn setup_wasm_channels(
"repl",
"http",
"signal",
"telegram",
"slack-relay",
"secret_save",
];
@@ -505,6 +506,7 @@ mod tests {
"repl",
"http",
"signal",
"telegram",
"slack-relay",
"secret_save",
];
@@ -554,7 +556,7 @@ mod tests {
#[test]
fn non_reserved_names_allowed() {
let reserved = reserved_names();
let allowed = ["telegram", "discord", "my-custom-channel", "slack-bot"];
let allowed = ["discord", "my-custom-channel", "slack-bot"];
for name in allowed {
assert!(
!reserved.contains(&name),

View File

@@ -791,12 +791,42 @@ CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
16,
"conversation_source_channel",
// Add source_channel to conversations for cross-channel approval authorization.
// Marked as idempotent (see IDEMPOTENT_ADD_COLUMN_MIGRATIONS below)
// because SQLite does not support IF NOT EXISTS for ADD COLUMN.
// The runner checks pragma_table_info before executing the ALTER.
r#"
ALTER TABLE conversations ADD COLUMN source_channel TEXT;
"#,
),
];
/// Migrations whose ADD COLUMN should be skipped when the column already
/// exists (e.g. because the base SCHEMA was updated to include it).
/// Each entry is `(version, table_name, column_name)`.
const IDEMPOTENT_ADD_COLUMN_MIGRATIONS: &[(i64, &str, &str)] =
&[(16, "conversations", "source_channel")];
/// Check whether `table` already contains `column` via `pragma_table_info`.
async fn column_exists(
conn: &libsql::Connection,
table: &str,
column: &str,
) -> Result<bool, crate::error::DatabaseError> {
use crate::error::DatabaseError;
let sql = format!(
"SELECT 1 FROM pragma_table_info('{}') WHERE name = ?1",
table
);
let mut rows = conn
.query(&sql, libsql::params![column])
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to check column {table}.{column}: {e}"))
})?;
Ok(rows.next().await.ok().flatten().is_some())
}
/// Run incremental migrations that haven't been applied yet.
///
/// Each migration is wrapped in a transaction. On success the version is
@@ -821,6 +851,18 @@ pub async fn run_incremental(conn: &libsql::Connection) -> Result<(), crate::err
continue; // Already applied
}
// For ADD COLUMN migrations, skip the ALTER if the column already
// exists (e.g. because the base SCHEMA was updated to include it)
// and just record the migration as applied.
let skip_sql = if let Some(&(_, table, column)) = IDEMPOTENT_ADD_COLUMN_MIGRATIONS
.iter()
.find(|(v, _, _)| *v == version)
{
column_exists(conn, table, column).await?
} else {
false
};
// Wrap migration + recording in a transaction for atomicity.
// If the process crashes mid-migration, the transaction rolls back
// and the migration will be retried on next startup.
@@ -830,9 +872,19 @@ pub async fn run_incremental(conn: &libsql::Connection) -> Result<(), crate::err
))
})?;
tx.execute_batch(sql).await.map_err(|e| {
DatabaseError::Migration(format!("libSQL migration V{version} ({name}) failed: {e}"))
})?;
if skip_sql {
tracing::debug!(
version,
name,
"libSQL: column already exists, recording migration as applied"
);
} else {
tx.execute_batch(sql).await.map_err(|e| {
DatabaseError::Migration(format!(
"libSQL migration V{version} ({name}) failed: {e}"
))
})?;
}
// Record as applied (inside the same transaction)
tx.execute(