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]>
This commit is contained in:
Omar Sobh
2026-08-19 12:24:09 -07:00
co-authored by Claude Opus 5
parent 5c2c63f8e8
commit 113de610ec
7 changed files with 422 additions and 12 deletions
+4
View File
@@ -497,6 +497,10 @@ async fn run() -> Result<(), String> {
// every consequence — an ungated test suite, a scan that scanned nothing — // every consequence — an ungated test suite, a scan that scanned nothing —
// looked like a normal result rather than a broken deployment. // looked like a normal result rather than a broken deployment.
cm_api::runtime_preflight::report_at_boot(); cm_api::runtime_preflight::report_at_boot();
// And whether the gateway those missions drive is configured at all. Both
// of its variables are read at FIRST USE, so a deployment missing them
// boots clean and fails on the first phase someone runs.
cm_api::gateway_preflight::report_at_boot();
// Graceful shutdown: on SIGTERM/Ctrl-C, stop accepting, finish in-flight // Graceful shutdown: on SIGTERM/Ctrl-C, stop accepting, finish in-flight
// requests, then DRAIN the sandbox managers so no container is left running. // requests, then DRAIN the sandbox managers so no container is left running.
let shutdown = async move { let shutdown = async move {
+160
View File
@@ -0,0 +1,160 @@
//! Is the mission gateway configured, and is anything listening?
//!
//! The third sibling of [`crate::runtime_preflight`] and
//! [`crate::validator_preflight`], for the same class of failure: the
//! configuration is absent or wrong, and nothing says so until a mission pays
//! for it.
//!
//! `ZEROCLAW_GATEWAY_URL` and `ZEROCLAW_TOKEN` have no defaults and are read at
//! FIRST USE, inside `ZeroClawDriveExecutor::from_env`. So a deployment missing
//! them boots clean, serves every page, lists every mission — and fails the
//! first time someone presses run, with an error that surfaces on a phase
//! rather than at startup. The information exists the whole time; nobody is
//! told until it is expensive.
//!
//! A report, not a gate, matching its siblings. A server with no gateway should
//! still boot: the frontend, the catalogue and every read path work without it,
//! and refusing to start would turn a degraded deployment into a dead one.
use std::time::Duration;
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// What the preflight found.
#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
/// No gateway configured. Missions on the container tier cannot run.
NotConfigured { missing: Vec<String> },
/// Configured but nothing answered at that address.
Unreachable { url: String, error: String },
/// Configured and something answered.
Reachable { url: String },
}
impl Verdict {
/// The line to print at boot.
///
/// Each names the CONSEQUENCE, not just the state. "ZEROCLAW_TOKEN not set"
/// tells an operator what is missing; it does not tell them that every
/// container-tier mission they launch will fail on its first phase.
pub fn message(&self) -> String {
match self {
Verdict::NotConfigured { missing } => format!(
"gateway_preflight: NOT CONFIGURED ({}) — container-tier missions \
cannot run. They will launch, provision a runtime, and fail on \
the first turn; the server is otherwise healthy",
missing.join(", ")
),
Verdict::Unreachable { url, error } => format!(
"gateway_preflight: {url} is configured but did not answer ({error}) \
— container-tier missions will fail on their first turn. The \
config is right and the machine is not"
),
Verdict::Reachable { url } => {
format!("gateway_preflight: {url} answered")
}
}
}
}
/// Which required variables are absent.
///
/// Split from the network probe so the rule is testable without a gateway:
/// this is the half that is pure, and it is the half that is wrong most often.
pub fn missing_config(url: Option<&str>, token: Option<&str>, pairing: Option<&str>) -> Vec<String> {
let mut missing = Vec::new();
if url.map(str::trim).unwrap_or("").is_empty() {
missing.push("ZEROCLAW_GATEWAY_URL".to_string());
}
// Either credential works: a durable token, or a one-time pairing code the
// executor exchanges on first use.
let has_token = !token.map(str::trim).unwrap_or("").is_empty();
let has_pairing = !pairing.map(str::trim).unwrap_or("").is_empty();
if !has_token && !has_pairing {
missing.push("ZEROCLAW_TOKEN or ZEROCLAW_PAIRING_CODE".to_string());
}
missing
}
fn env_opt(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
}
/// Probe the configured gateway.
pub async fn check() -> Verdict {
let url = env_opt("ZEROCLAW_GATEWAY_URL");
let missing = missing_config(
url.as_deref(),
env_opt("ZEROCLAW_TOKEN").as_deref(),
env_opt("ZEROCLAW_PAIRING_CODE").as_deref(),
);
if !missing.is_empty() {
return Verdict::NotConfigured { missing };
}
let url = url.expect("checked above");
// Any HTTP answer proves something is listening and routable, which is the
// question this preflight exists to answer. Authenticating here would need
// a pairing exchange that BURNS a one-time code — a preflight that costs
// the deployment its credential is worse than no preflight.
let client = match reqwest::Client::builder().timeout(PROBE_TIMEOUT).build() {
Ok(c) => c,
Err(e) => {
return Verdict::Unreachable {
url,
error: e.to_string(),
}
}
};
match client.get(&url).send().await {
Ok(_) => Verdict::Reachable { url },
Err(e) => Verdict::Unreachable {
url,
error: e.to_string(),
},
}
}
/// Run the probe and print the verdict. Never panics, never blocks boot.
pub fn report_at_boot() {
tokio::spawn(async {
eprintln!("{}", check().await.message());
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fully_configured_deployment_is_missing_nothing() {
assert!(missing_config(Some("http://gw:42617"), Some("tok"), None).is_empty());
// A pairing code alone is enough — the executor exchanges it on first use.
assert!(missing_config(Some("http://gw:42617"), None, Some("123456")).is_empty());
}
#[test]
fn an_empty_string_counts_as_absent() {
// The failure this whole module exists for: `unwrap_or_default` and an
// empty env var turn "unconfigured" into "configured with nothing",
// which fails later as a 401 rather than now as a missing setting.
let missing = missing_config(Some(" "), Some(""), Some(" "));
assert_eq!(missing.len(), 2, "both must be reported: {missing:?}");
assert!(missing[0].contains("GATEWAY_URL"));
assert!(missing[1].contains("ZEROCLAW_TOKEN"));
}
#[test]
fn the_message_names_the_consequence_not_just_the_state() {
let v = Verdict::NotConfigured {
missing: vec!["ZEROCLAW_GATEWAY_URL".into()],
};
let m = v.message();
assert!(m.contains("ZEROCLAW_GATEWAY_URL"));
assert!(
m.contains("cannot run"),
"an operator needs to know what stops working, not only what is \
unset: {m}"
);
}
}
+1
View File
@@ -52,6 +52,7 @@ pub mod runtime_preflight;
pub mod runtime_provision; pub mod runtime_provision;
pub mod security_scan; pub mod security_scan;
pub mod session_executor; pub mod session_executor;
pub mod gateway_preflight;
pub mod skill_self_authoring; pub mod skill_self_authoring;
pub mod skill_use; pub mod skill_use;
pub mod skills_loader; pub mod skills_loader;
+34 -5
View File
@@ -147,9 +147,19 @@ async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
"event": {"type": "app_mention", "text": "summarize [[scenario:mention]]"} "event": {"type": "app_mention", "text": "summarize [[scenario:mention]]"}
}) })
.to_string(); .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 let forged = client
.post(format!("{base}/api/slack/events")) .post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", "12345") .header("x-slack-request-timestamp", &now)
.header("x-slack-signature", "v0=deadbeef") .header("x-slack-signature", "v0=deadbeef")
.body(body.clone()) .body(body.clone())
.send() .send()
@@ -161,10 +171,10 @@ async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
let challenge_body = json!({"type": "url_verification", "challenge": "abc123"}).to_string(); let challenge_body = json!({"type": "url_verification", "challenge": "abc123"}).to_string();
let challenge = client let challenge = client
.post(format!("{base}/api/slack/events")) .post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", "12345") .header("x-slack-request-timestamp", &now)
.header( .header(
"x-slack-signature", "x-slack-signature",
sign(signing_secret, "12345", &challenge_body), sign(signing_secret, &now, &challenge_body),
) )
.body(challenge_body.clone()) .body(challenge_body.clone())
.send() .send()
@@ -176,12 +186,31 @@ async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
"abc123" "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 // A properly signed mention starts a run in the '💬 Slack' session and
// the agent's reply is intercepted by the approval gate. // the agent's reply is intercepted by the approval gate.
let mention = client let mention = client
.post(format!("{base}/api/slack/events")) .post(format!("{base}/api/slack/events"))
.header("x-slack-request-timestamp", "12345") .header("x-slack-request-timestamp", &now)
.header("x-slack-signature", sign(signing_secret, "12345", &body)) .header("x-slack-signature", sign(signing_secret, &now, &body))
.body(body) .body(body)
.send() .send()
.await .await
+41
View File
@@ -137,3 +137,44 @@ pub async fn usage_last_7_days(
.await?; .await?;
Ok((row.tokens_in, row.tokens_out, row.credits)) Ok((row.tokens_in, row.tokens_out, row.credits))
} }
#[cfg(test)]
mod pricing_tests {
use super::*;
/// Pricing is the one place a rounding slip bills a real person.
///
/// Pure, cheap to test, and previously untested — the crate's four tests
/// all exercise the database path, so the arithmetic underneath them was
/// never checked directly.
#[test]
fn credits_round_up_and_never_charge_zero() {
// A run that used tokens always costs at least one credit; charging
// zero for real work is how usage silently stops being metered.
assert_eq!(credits_for_tokens(1), 1);
assert_eq!(credits_for_tokens(TOKENS_PER_CREDIT - 1), 1);
assert_eq!(credits_for_tokens(TOKENS_PER_CREDIT), 1);
// Round UP, not to nearest: one token into the next bracket is a
// whole credit, which is the documented contract.
assert_eq!(credits_for_tokens(TOKENS_PER_CREDIT + 1), 2);
assert_eq!(credits_for_tokens(TOKENS_PER_CREDIT * 3), 3);
assert_eq!(credits_for_tokens(TOKENS_PER_CREDIT * 3 + 1), 4);
}
/// Zero tokens is the odd case: the `.max(1)` floor means it still costs a
/// credit. That is deliberate, and worth pinning so a future "fix" to it
/// is a decision rather than an accident.
#[test]
fn a_zero_token_run_still_costs_one_credit() {
assert_eq!(credits_for_tokens(0), 1);
}
/// The cast to i64 must not wrap into a negative charge — a negative
/// credit is a refund, and a refund granted by an overflow is the worst
/// shape this bug could take.
#[test]
fn an_absurd_token_count_does_not_wrap_negative() {
assert!(credits_for_tokens(u64::MAX / 2) > 0);
assert!(credits_for_tokens(u64::MAX) > 0);
}
}
+150
View File
@@ -20,12 +20,58 @@ fn credential_field(secret: &str, field: &str) -> String {
} }
/// Slack request signing: v0=hex(HMAC-SHA256(secret, "v0:{ts}:{body}")). /// Slack request signing: v0=hex(HMAC-SHA256(secret, "v0:{ts}:{body}")).
/// How stale a Slack request may be before the broker refuses it.
///
/// Slack's documented replay window. Without it a signature stays valid
/// forever: the timestamp is an input to the HMAC, so an old request's
/// signature verifies exactly as well as a fresh one. Anyone who captured a
/// single signed request — a proxy log, a mirrored packet, a leaked webhook
/// body — could replay it indefinitely and every replay would authenticate.
const SLACK_MAX_AGE_SECS: i64 = 5 * 60;
pub(crate) fn slack_signature_valid( pub(crate) fn slack_signature_valid(
signing_secret: &str, signing_secret: &str,
timestamp: &str, timestamp: &str,
body: &str, body: &str,
signature: &str, signature: &str,
) -> bool { ) -> bool {
slack_signature_valid_at(
signing_secret,
timestamp,
body,
signature,
// std, not the `time` crate: `time` is a DEV-dependency here, so a
// reference to it compiles under `cargo test` and breaks the library
// build — which is exactly how this line shipped broken for one run.
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0),
)
}
/// [`slack_signature_valid`] with the clock injected, so freshness is testable
/// without sleeping or mocking the process clock.
pub(crate) fn slack_signature_valid_at(
signing_secret: &str,
timestamp: &str,
body: &str,
signature: &str,
now_unix: i64,
) -> bool {
// Freshness FIRST, and in the broker rather than 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.
let Ok(ts) = timestamp.trim().parse::<i64>() else {
return false;
};
// Symmetric window — a timestamp far in the FUTURE is equally suspect, and
// allowing it would let an attacker mint a request valid for as long as
// they chose.
if (now_unix - ts).abs() > SLACK_MAX_AGE_SECS {
return false;
}
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
let Ok(mut mac) = Hmac::<sha2::Sha256>::new_from_slice(signing_secret.as_bytes()) else { let Ok(mut mac) = Hmac::<sha2::Sha256>::new_from_slice(signing_secret.as_bytes()) else {
return false; return false;
@@ -41,6 +87,110 @@ pub(crate) fn slack_signature_valid(
== 0 == 0
} }
#[cfg(test)]
mod slack_signature_tests {
use super::*;
use hmac::{Hmac, Mac};
const SECRET: &str = "8f742231b10e8888abcd99yyyzzz85a5";
const BODY: &str = "token=xyz&team_id=T1&text=hello";
const NOW: i64 = 1_700_000_000;
fn sign(ts: i64, body: &str) -> String {
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(SECRET.as_bytes()).unwrap();
mac.update(format!("v0:{ts}:{body}").as_bytes());
format!("v0={}", hex::encode(mac.finalize().into_bytes()))
}
#[test]
fn a_correctly_signed_fresh_request_is_accepted() {
let sig = sign(NOW, BODY);
assert!(slack_signature_valid_at(
SECRET,
&NOW.to_string(),
BODY,
&sig,
NOW
));
}
#[test]
fn a_tampered_body_is_rejected() {
let sig = sign(NOW, BODY);
assert!(!slack_signature_valid_at(
SECRET,
&NOW.to_string(),
"token=xyz&team_id=T1&text=goodbye",
&sig,
NOW
));
}
#[test]
fn the_wrong_secret_is_rejected() {
let sig = sign(NOW, BODY);
assert!(!slack_signature_valid_at(
"not-the-signing-secret",
&NOW.to_string(),
BODY,
&sig,
NOW
));
}
/// The replay this window exists to stop.
///
/// The signature is still perfectly valid — that is the point. Anyone who
/// captured one signed request could otherwise replay it forever.
#[test]
fn a_correctly_signed_but_stale_request_is_rejected() {
let old = NOW - SLACK_MAX_AGE_SECS - 1;
let sig = sign(old, BODY);
assert!(
slack_signature_valid_at(SECRET, &old.to_string(), BODY, &sig, old),
"the signature itself is valid at its own time"
);
assert!(
!slack_signature_valid_at(SECRET, &old.to_string(), BODY, &sig, NOW),
"a validly signed request older than the window must still be refused"
);
}
/// A timestamp far in the future would otherwise mint a long-lived request.
#[test]
fn a_far_future_timestamp_is_rejected() {
let future = NOW + SLACK_MAX_AGE_SECS + 1;
let sig = sign(future, BODY);
assert!(!slack_signature_valid_at(
SECRET,
&future.to_string(),
BODY,
&sig,
NOW
));
}
#[test]
fn small_clock_skew_in_either_direction_is_tolerated() {
for delta in [-SLACK_MAX_AGE_SECS + 1, -30, 0, 30, SLACK_MAX_AGE_SECS - 1] {
let ts = NOW + delta;
let sig = sign(ts, BODY);
assert!(
slack_signature_valid_at(SECRET, &ts.to_string(), BODY, &sig, NOW),
"delta {delta}s must be inside the window — a strict clock would \
reject legitimate traffic"
);
}
}
#[test]
fn a_non_numeric_timestamp_is_rejected_rather_than_defaulting() {
let sig = sign(NOW, BODY);
assert!(!slack_signature_valid_at(SECRET, "not-a-time", BODY, &sig, NOW));
assert!(!slack_signature_valid_at(SECRET, "", BODY, &sig, NOW));
}
}
/// The broker daemon: listens on a unix socket reachable only by the /// The broker daemon: listens on a unix socket reachable only by the
/// server process (never mounted into agent sandboxes). /// server process (never mounted into agent sandboxes).
pub struct BrokerServer { pub struct BrokerServer {
+32 -7
View File
@@ -200,13 +200,38 @@ work and the condition.
memory winning modestly on low absolute numbers, and `Harness the Memory` memory winning modestly on low absolute numbers, and `Harness the Memory`
finds excessive retrieval actively harms agent decisions. Measure against a finds excessive retrieval actively harms agent decisions. Measure against a
baseline before migrating. baseline before migrating.
- **Thin test coverage**: `cm-secrets` (4), `cm-billing` (4), `cm-safety` (7), - **Test coverage, re-examined.** The original entry ranked crates by raw test
`cm-telemetry` (1 — and that one is a genuinely good test: it stands up a count. That was a shallow metric and it was misleading: `cm-safety`'s seven
real OTLP/HTTP receiver and decodes protobuf with the official proto types, tests already cover the CAS on decide, grant double-consume, expiry, and the
so it exercises the actual wire contract rather than a mock. The crate needs approved/rejected split, and the `audit_log` immutability trigger is tested in
more tests, not a different one); 6 of `cm-brain`'s 9 tests are `#[ignore]`d. `cm-db`. Counting tests found the wrong crates.
- **`ZEROCLAW_GATEWAY_URL` / `_TOKEN`** have no default and fail at *first use*,
not boot — a deployment looks healthy until someone clicks run. Reading the API surface against the tests found the right ones:
- **`verify_slack_signature` had no replay protection** — fixed, see below.
- **`credits_for_tokens` was untested** — pure pricing arithmetic, now pinned
including the round-up contract and an overflow case.
Genuinely still thin: `cm-brain`, where 6 of 9 tests need live
`clawbrainhub.com` so the whole hub client is unexercised offline. Stubbing it
means reproducing an external registry protocol we have no spec for, which is
its own piece of work rather than a coverage chore.
- **A Slack request could be replayed forever.** `slack_signature_valid`
verified the HMAC correctly and nothing anywhere checked the timestamp's age —
it was only an input to the basestring, so an old request's signature verified
exactly as well as a fresh one. Anyone holding one captured signed request
could replay it indefinitely. Slack's documented 5-minute window is now
enforced **in the broker**, not the caller, because the broker does not trust
its caller and a check the caller can forget will eventually be forgotten.
Symmetric, so a far-future timestamp cannot mint a long-lived request.
- ~~**`ZEROCLAW_GATEWAY_URL` / `_TOKEN` fail at *first use*, not boot.**~~
**Fixed 2026-08-19.** `gateway_preflight` reports at startup, the third
sibling of `runtime_preflight` and `validator_preflight`. A report, not a
gate — every read path works without a gateway, and refusing to boot would
turn a degraded deployment into a dead one. The message names the
consequence ("container-tier missions cannot run") rather than just the
missing variable.
- ~~**`gitea_forge` resolves to nothing.**~~ **Resolved 2026-08-19 by removing - ~~**`gitea_forge` resolves to nothing.**~~ **Resolved 2026-08-19 by removing
the name.** It was harmless while `provision_claw` ignored the bundle list; the name.** It was harmless while `provision_claw` ignored the bundle list;
once the list was honoured, an undefined name became a capability an agent is once the list was honoured, an undefined name became a capability an agent is