Files
clawmates/crates/cm-api/tests/slack_inbound.rs
T
Omar SobhandClaude Opus 5 113de610ec fix(security): a signed Slack request could be replayed forever
Phase 5. The headline is not the coverage work — it is what looking for
coverage found.

A CAPTURED SLACK REQUEST AUTHENTICATED INDEFINITELY

`slack_signature_valid` verified the HMAC correctly, and nothing anywhere
checked how old the timestamp was. The timestamp is an input to the
basestring, so an old request's signature verifies exactly as well as a
fresh one — meaning anyone holding a single captured signed request (a
proxy log, a mirrored packet, a leaked webhook body) could replay it
forever, and every replay would authenticate.

Slack's documented 5-minute window is now enforced IN THE BROKER, not the
caller: the broker does not trust its caller (§15), and a check the caller
can forget to make is one that will eventually be forgotten. Symmetric, so
a far-future timestamp cannot mint a request valid for as long as the
attacker chooses.

Seven unit tests over the pure function with the clock injected, and the
HTTP-level test now asserts an hour-old but validly signed request is
refused. Negative control: removing the window fails the stale and
future cases specifically.

The existing slack_inbound test used the literal timestamp "12345" — a 1970
date — which passed only because nothing checked freshness. That is the
shape of the whole finding: the fixture could not have failed, so it never
told us anything.

COVERAGE, RE-EXAMINED

The review ranked crates by raw test count. That metric was misleading and
found the wrong crates: cm-safety's seven tests already cover the decide
CAS, grant double-consume, expiry and the approved/rejected split, and the
audit_log immutability trigger is tested over in cm-db.

Reading the API surface against the tests found the real gaps —
verify_slack_signature above, and `credits_for_tokens`, pure pricing
arithmetic that every existing billing test went through the database to
reach without ever checking directly. Now pinned: the round-up contract,
the deliberate one-credit floor, and that an absurd token count cannot wrap
into a negative charge (a refund granted by an overflow).

Still genuinely thin: cm-brain, where 6 of 9 tests need live
clawbrainhub.com. Stubbing it means reproducing an external registry
protocol we have no spec for — its own piece of work, not a coverage chore.
Recorded rather than faked.

GATEWAY PREFLIGHT

ZEROCLAW_GATEWAY_URL and ZEROCLAW_TOKEN have no defaults and are read at
FIRST USE, so a deployment missing them boots clean, serves every page, and
fails the first time someone presses run. Third sibling of runtime_preflight
and validator_preflight, same stance: a report, not a gate. The message
names the consequence — "container-tier missions cannot run" — rather than
only the unset variable.

One process note: `cargo test -p cm-secrets` passed while the LIBRARY build
was broken, because `time` is a dev-dependency there and my reference to it
only resolved under cfg(test). Switched to std. Checking `cargo build
--workspace` as well as the test profile is the guard.

Full workspace suite green: 106 binaries, zero build errors.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 12:24:09 -07:00

239 lines
8.0 KiB
Rust

//! Inbound Slack @mentions: signature verified BY the broker, a verified
//! mention drives a real run, and the agent's reply is gated as usual.
use std::sync::Arc;
use std::time::Duration;
use cm_api::AppState;
use cm_auth::AuthService;
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
use cm_llm::ScriptedProvider;
use cm_runtime::{Runtime, RuntimeConfig};
use cm_secrets::{BrokerServer, FileKey};
use hmac::{Hmac, Mac};
use serde_json::{json, Value};
const SCENARIOS: &str = r##"
[[scenario]]
marker = "[[scenario:mention]]"
[[scenario.turns]]
events = [
{ type = "tool_use", name = "slack.post", input = { channel = "#general", text = "On it!" } },
]
[[scenario.turns]]
events = [
{ type = "text", text = " Replied in Slack." },
]
"##;
fn sign(secret: &str, timestamp: &str, body: &str) -> String {
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(format!("v0:{timestamp}:{body}").as_bytes());
format!("v0={}", hex::encode(mac.finalize().into_bytes()))
}
async fn spawn_broker(pool: sqlx::PgPool) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("tc-in-{}", uuid::Uuid::now_v7().simple()));
std::fs::create_dir_all(&dir).unwrap();
let key_path = dir.join("broker.key");
FileKey::generate(&key_path).unwrap();
let key = FileKey::load(&key_path).unwrap();
let short = uuid::Uuid::now_v7().simple().to_string();
let socket = std::path::PathBuf::from(format!("/tmp/tci-{}.sock", &short[short.len() - 12..]));
let server = BrokerServer::new(pool, key, socket.clone());
tokio::spawn(async move {
server.serve().await.unwrap();
});
for _ in 0..50 {
if socket.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
socket
}
#[tokio::test]
async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
let pool = cm_testkit::test_pool().await;
let socket = spawn_broker(pool.clone()).await;
let runtime = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
RuntimeConfig {
model: "scripted".into(),
max_tokens: 1024,
sandboxes: None,
browser: None,
terminals: None,
broker_socket: Some(socket.clone()),
slack_base_url: "http://127.0.0.1:1".into(), // never reached here
providers: Default::default(),
},
);
let app = cm_api::router(AppState::new(pool.clone(), runtime).with_broker(socket.clone()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base = format!("http://{addr}");
let client = reqwest::Client::new();
// Seed + connect Slack with a JSON secret (bot token + signing secret).
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
cm_db::repo::users::insert(&pool, &owner).await.unwrap();
AuthService::new(pool.clone())
.set_password(owner.id, "pw")
.await
.unwrap();
let token = client
.post(format!("{base}/api/auth/login"))
.json(&json!({"email": owner.email, "password": "pw"}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_owned();
let claw: Value = client
.post(format!("{base}/api/claws"))
.bearer_auth(&token)
.json(&json!({"name": "Scout", "job_title": "Analyst"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let claw_id = claw["id"].as_str().unwrap();
let signing_secret = "shh-signing";
let connect = client
.post(format!("{base}/api/apps/connect"))
.bearer_auth(&token)
.json(&json!({
"clawId": claw_id,
"provider": "slack",
"authType": "keys",
"secret": json!({"bot_token": "xoxb-in", "signing_secret": signing_secret}).to_string(),
}))
.send()
.await
.unwrap();
assert_eq!(connect.status(), 201);
// A forged signature is rejected outright.
let body = json!({
"type": "event_callback",
"event": {"type": "app_mention", "text": "summarize [[scenario:mention]]"}
})
.to_string();
// A CURRENT timestamp. It used to be the literal "12345" — a 1970 date —
// which passed only because nothing checked freshness. The broker now
// enforces Slack's 5-minute replay window, so a fixture that never moves
// starts failing the moment the guard is real.
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.to_string();
let forged = client
.post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", &now)
.header("x-slack-signature", "v0=deadbeef")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(forged.status(), 401);
// url_verification handshake echoes the challenge when signed.
let challenge_body = json!({"type": "url_verification", "challenge": "abc123"}).to_string();
let challenge = client
.post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", &now)
.header(
"x-slack-signature",
sign(signing_secret, &now, &challenge_body),
)
.body(challenge_body.clone())
.send()
.await
.unwrap();
assert_eq!(challenge.status(), 200);
assert_eq!(
challenge.json::<Value>().await.unwrap()["challenge"],
"abc123"
);
// A correctly signed request from outside the window is refused. The
// signature is genuine — that is the point of a replay: an attacker holding
// one captured request must not be able to use it forever.
let stale_ts = (now.parse::<u64>().unwrap() - 3600).to_string();
let replayed = client
.post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", &stale_ts)
.header("x-slack-signature", sign(signing_secret, &stale_ts, &body))
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(
replayed.status(),
401,
"a validly signed but hour-old request must be refused — otherwise one \
captured request authenticates forever"
);
// A properly signed mention starts a run in the '💬 Slack' session and
// the agent's reply is intercepted by the approval gate.
let mention = client
.post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", &now)
.header("x-slack-signature", sign(signing_secret, &now, &body))
.body(body)
.send()
.await
.unwrap();
assert_eq!(mention.status(), 200);
let agent_id = cm_domain::AgentId::from(claw_id.parse::<uuid::Uuid>().unwrap());
let mut gated = false;
for _ in 0..100 {
let sessions = cm_db::repo::sessions::list_by_agent(&pool, agent_id)
.await
.unwrap();
if sessions.iter().any(|s| s.title == "💬 Slack") {
let pending = cm_safety::approvals::list_pending(&pool, ws.id)
.await
.unwrap();
if pending.len() == 1 && pending[0].action_type == "slack.post" {
gated = true;
break;
}
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(gated, "mention must drive a run whose reply is gated");
}