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
+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]]"}
})
.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", "12345")
.header("x-slack-request-timestamp", &now)
.header("x-slack-signature", "v0=deadbeef")
.body(body.clone())
.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 = client
.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", &challenge_body),
sign(signing_secret, &now, &challenge_body),
)
.body(challenge_body.clone())
.send()
@@ -176,12 +186,31 @@ async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
"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", "12345")
.header("x-slack-signature", sign(signing_secret, "12345", &body))
.header("x-slack-request-timestamp", &now)
.header("x-slack-signature", sign(signing_secret, &now, &body))
.body(body)
.send()
.await