Files
optimclaw/tests/e2e_thread_id_isolation.rs
T
ZakiandClaude 5f50df7583 fix(security): persist source_channel to DB, harden cross-channel authorization
Address PR #1590 review feedback:

1. Persist source_channel to DB: Add source_channel column to conversations
   table in both PostgreSQL (V14 migration) and libSQL (incremental migration
   + base schema). Add get_conversation_source_channel trait method to
   ConversationStore with both backend implementations.

2. Fix hydrate_thread_from_db: Read source_channel from DB instead of
   stamping the requesting message's channel, preventing channel confusion
   after server restart.

3. Reject reserved WASM channel names: Validate that WASM channels cannot
   register as "web", "gateway", "cli", or "repl" to prevent authorization
   bypass via name spoofing.

4. Require pending_approval exists: Authorization check now verifies
   thread.pending_approval.is_some() before allowing approval-shaped messages
   to target a thread.

5. Fail-closed for None source_channel: Use "__bootstrap__" sentinel for
   bootstrap threads (authorized from any channel). None now means "deny by
   default" instead of "allow by default".

6. Extract and test authorization predicate: is_approval_authorized() helper
   with 6 unit tests covering same-channel, cross-channel blocked, web/gateway
   always allowed, None denied, and bootstrap sentinel.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-28 15:11:03 +00:00

184 lines
6.4 KiB
Rust

//! E2E regression test: forged thread IDs must not cross user boundaries.
//!
//! Demonstrates that a client cannot provide another user's conversation UUID
//! and get that history hydrated into prompt context or written into.
#[cfg(feature = "libsql")]
mod support;
#[cfg(feature = "libsql")]
mod tests {
use std::time::Duration;
use ironclaw::channels::{IncomingMessage, OutgoingResponse};
use uuid::Uuid;
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::{LlmTrace, TraceResponse, TraceStep};
fn assert_safe_thread_rejection(response: &OutgoingResponse) {
let msg = response.content.to_lowercase();
assert!(
msg.contains("thread") && (msg.contains("invalid") || msg.contains("unauthorized")),
"expected safe thread-id rejection response, got: {}",
response.content
);
}
#[tokio::test]
async fn forged_existing_foreign_thread_id_is_rejected_without_hydration_or_persistence() {
let trace = LlmTrace::single_turn(
"thread-id-isolation",
"attacker turn",
vec![TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "safe response".to_string(),
input_tokens: 12,
output_tokens: 4,
},
expected_tool_results: Vec::new(),
}],
);
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let foreign_thread_id = Uuid::new_v4();
let marker = format!("FOREIGN-MARKER-{}", Uuid::new_v4());
let store = rig.database();
assert!(
store
.ensure_conversation(foreign_thread_id, "gateway", "victim-user", None, Some("gateway"))
.await
.expect("failed to create victim conversation"),
"test setup failed: victim conversation was not created"
);
store
.add_conversation_message(
foreign_thread_id,
"user",
&format!("victim-only secret marker: {marker}"),
)
.await
.expect("failed to seed victim conversation message");
let before_messages = store
.list_conversation_messages(foreign_thread_id)
.await
.expect("failed to read victim conversation before forged send");
assert!(
before_messages.iter().any(|m| m.content.contains(&marker)),
"test setup failed: victim marker message missing"
);
let before_len = before_messages.len();
let forged = IncomingMessage::new("test", "test-user", "attacker turn")
.with_thread(foreign_thread_id.to_string());
rig.send_incoming(forged).await;
let responses = rig.wait_for_responses(1, Duration::from_secs(20)).await;
assert_eq!(
responses.len(),
1,
"expected one assistant response for forged-thread request"
);
assert_safe_thread_rejection(&responses[0]);
let captured = rig.captured_llm_requests();
assert!(
captured.is_empty(),
"forged thread-id request should be rejected before any LLM call"
);
let prompt_dump = captured
.iter()
.flat_map(|req| req.iter().map(|m| m.content.as_str()))
.collect::<Vec<_>>()
.join("\n");
assert!(
!prompt_dump.contains(&marker),
"forged thread_id leaked foreign marker into LLM prompt context: {prompt_dump}"
);
let after_messages = store
.list_conversation_messages(foreign_thread_id)
.await
.expect("failed to read victim conversation after forged send");
assert_eq!(
after_messages.len(),
before_len,
"forged thread_id wrote new messages into victim conversation"
);
assert!(
after_messages
.iter()
.all(|m| m.content != "attacker turn" && m.content != "safe response"),
"forged request content was persisted to victim conversation"
);
rig.shutdown();
}
#[tokio::test]
async fn forged_nonexistent_thread_id_is_rejected_and_followup_request_still_works() {
let trace = LlmTrace::single_turn(
"thread-id-isolation-nonexistent",
"real follow-up turn",
vec![TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "safe response".to_string(),
input_tokens: 12,
output_tokens: 4,
},
expected_tool_results: Vec::new(),
}],
);
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let forged_thread_id = Uuid::new_v4();
let store = rig.database();
let forged = IncomingMessage::new("test", "test-user", "attacker turn")
.with_thread(forged_thread_id.to_string());
rig.send_incoming(forged).await;
let responses = rig.wait_for_responses(1, Duration::from_secs(20)).await;
assert_eq!(
responses.len(),
1,
"expected one response for forged nonexistent-thread request"
);
assert_safe_thread_rejection(&responses[0]);
assert!(
rig.captured_llm_requests().is_empty(),
"forged nonexistent thread-id request should be rejected before any LLM call"
);
assert!(
store
.get_conversation_metadata(forged_thread_id)
.await
.expect("get metadata for forged thread id")
.is_none(),
"forged nonexistent thread id must not create a conversation row"
);
rig.send_message("real follow-up turn").await;
let responses = rig.wait_for_responses(2, Duration::from_secs(20)).await;
assert_eq!(
responses.len(),
2,
"expected follow-up response after rejection"
);
assert_eq!(
responses[1].content, "safe response",
"follow-up valid request should still be handled normally"
);
assert_eq!(
rig.captured_llm_requests().len(),
1,
"only follow-up request should reach LLM"
);
rig.shutdown();
}
}