Skip to content

Commit a5b48fd

Browse files
author
matthew
committed
Merge branch 'fix/1.6.0-mcp-security-findings' into dev
2 parents e7f2b7a + 03064b4 commit a5b48fd

16 files changed

Lines changed: 1403 additions & 61 deletions

crates/busbar/src/ingress/tests/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ fn minimal_app() -> Arc<App> {
2323
Arc::new(App {
2424
mcp_catalogue: Arc::new(crate::mcp::catalogue::Catalogue::default()),
2525
mcp_sightings: Default::default(),
26+
mcp_spent_approvals: Default::default(),
2627
mcp_pool: Default::default(),
2728
mcp_servers: Arc::new(Default::default()),
2829
// Not an MCP server: the plane is absent and the dispatch table empty, which is what every

crates/busbar/src/main.rs

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,19 +1154,18 @@ async fn run() {
11541154
// and the A2A job are: a second job against the same registry would double every fetch and race
11551155
// every ledger stamp. It holds the HANDLE, not the app, so a config apply is picked up on the
11561156
// next tick rather than sweeping a generation the operator has already replaced.
1157-
if !app_handle.load().mcp_catalogue.is_empty() {
1158-
tracing::info!(
1159-
tick_secs = crate::trust::sweep::SWEEP_TICK.as_secs(),
1160-
"mcp: tool-list refresh job started; registered servers are re-hashed on their own \
1161-
`refresh_ttl:` and quarantined on drift with no operator present"
1162-
);
1163-
// Handle intentionally dropped, exactly as the A2A job's is: it runs for the process
1164-
// lifetime and exits its own loop on the shutdown broadcast.
1165-
std::mem::drop(crate::trust::sweep::spawn(
1166-
crate::mcp::connect::RefreshSweeper(app_handle.clone()),
1167-
shutdown_tx.subscribe(),
1168-
));
1169-
}
1157+
//
1158+
// The decision itself lives in `mcp::spawn_refresh_job` rather than inline here, because
1159+
// `run()` binds real listeners and joins them and so nothing can test a line of it. While this
1160+
// was inline, the whole battery in `mcp/tests/timer_dispatch_tests.rs` called `refresh_sweep`
1161+
// by hand, and deleting this block would have failed exactly nothing.
1162+
//
1163+
// Handle intentionally dropped, exactly as the A2A job's is: it runs for the process lifetime
1164+
// and exits its own loop on the shutdown broadcast.
1165+
std::mem::drop(crate::mcp::spawn_refresh_job(
1166+
&app_handle,
1167+
shutdown_tx.subscribe(),
1168+
));
11701169

11711170
// THE A2A RE-VERIFICATION JOB. An approval is a statement about a document at a moment and
11721171
// nothing keeps it true; the pin catches a change only when somebody looks, and this is what
@@ -3971,6 +3970,13 @@ pub(crate) fn build_app_from_config(
39713970
|| Arc::new(crate::mcp::client::catalogue::CatalogueCache::new()),
39723971
|p| p.mcp_sightings.clone(),
39733972
),
3973+
// CARRIED ACROSS THE APPLY for the same reason, and it is the same class of mistake: an
3974+
// approval already spent is evidence, not intent, and a config apply that forgot it would
3975+
// hand every outstanding confirmation back to whoever still holds it.
3976+
mcp_spent_approvals: prior.map_or_else(
3977+
|| Arc::new(crate::mcp::askstate::SpentAskStates::new()),
3978+
|p| p.mcp_spent_approvals.clone(),
3979+
),
39743980
credential_cache: prior.map_or_else(
39753981
|| Arc::new(auth_cache::CredentialCache::new()),
39763982
|p| p.credential_cache.clone(),

crates/busbar/src/mcp/askstate.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ pub(crate) enum Rejected {
134134
WrongRequest,
135135
/// Sealed under a catalogue generation that is no longer live — an approval moved underneath it.
136136
WrongGeneration,
137+
/// ALREADY REDEEMED. Perfectly valid, for this caller, for this request, inside its window — and
138+
/// already spent on the call it was minted to approve. See [`SpentAskStates`].
139+
AlreadySpent,
137140
}
138141

139142
impl Rejected {
@@ -146,6 +149,7 @@ impl Rejected {
146149
Rejected::WrongPrincipal => "state_wrong_principal",
147150
Rejected::WrongRequest => "state_wrong_request",
148151
Rejected::WrongGeneration => "state_wrong_generation",
152+
Rejected::AlreadySpent => "state_already_spent",
149153
}
150154
}
151155
}
@@ -291,6 +295,77 @@ pub(crate) fn digest_arguments(arguments: &serde_json::Value) -> String {
291295
hex::encode(h.finalize())
292296
}
293297

298+
/// THE SPENT-APPROVAL LEDGER — what makes an approval SINGLE-USE.
299+
///
300+
/// ## What the seal could not do on its own
301+
///
302+
/// Everything else about this module is a statement the seal itself can carry: who it was minted
303+
/// for, what request, which round, until when. Single use is the one property that cannot ride
304+
/// inside the blob, because a caller presenting the identical blob a second time presents an
305+
/// identical, perfectly valid blob. The only thing that can tell the second presentation from the
306+
/// first is a RECORD THAT THE FIRST HAPPENED — and until this existed there was none, so an operator
307+
/// who gated a money-moving tool behind a confirmation got confirm-once-execute-many.
308+
///
309+
/// ## Keyed on the nonce, and only the terminal redemption is recorded
310+
///
311+
/// The nonce already exists and is already unique per mint (`mrtr`'s multi-round scenario requires
312+
/// it), so it is the natural handle and nothing new has to be sealed. What is recorded is the ONE
313+
/// redemption that dispatches: an intermediate round's state is answered with a fresh ask and a
314+
/// fresh state, so burning it would refuse the ordinary case of a client retrying a request whose
315+
/// answer it never saw. The spend therefore happens exactly where the exchange COMPLETES.
316+
///
317+
/// ## What a restart does to it, and why that is the right trade
318+
///
319+
/// This is PROCESS-LOCAL, and deliberately so rather than for want of a durable store.
320+
///
321+
/// - The window a restart reopens is bounded by the state's own life: a state that has lapsed is
322+
/// already refused by [`Sealer::open`], so the most a restart can restore is the unredeemed
323+
/// remainder of one [`DEFAULT_TTL_SECS`] window. It is not a standing hole; it closes by itself.
324+
/// - It is not attacker-triggerable. A caller cannot restart the process, and a caller who could
325+
/// has a larger primitive than double-spending one confirmation.
326+
/// - The alternative is a durable spent-nonce table, which means a new `busbar_api::Store` method,
327+
/// which means the plugin ABI — a substantial change to buy the residual, and one this tree is
328+
/// the wrong place to spend: it is scheduled for deletion and rebuild on the `rmcp` SDK, and the
329+
/// rebuilt tree gets to decide where its state lives. The BEHAVIOUR is pinned by test either way,
330+
/// so the decision can be revisited without the property being lost.
331+
///
332+
/// A fleet is the same trade one hop out: two nodes sharing a signing key share the seal but not
333+
/// this ledger, so a redemption on node A does not stop one on node B. That is a real limit and it
334+
/// is written down here rather than discovered; closing it needs shared state, which is the same
335+
/// durable-store decision.
336+
///
337+
/// ## The size of it
338+
///
339+
/// An entry lives at most as long as the state it records, and every call evicts what has lapsed,
340+
/// so the table holds at most the approvals minted in one TTL window. Minting one costs the caller
341+
/// a metered, budget-charged round, so the rate is bounded by governance rather than by this map.
342+
#[derive(Debug, Default)]
343+
pub(crate) struct SpentAskStates {
344+
/// nonce ⇒ the instant after which the entry is meaningless, because the state it records can
345+
/// no longer be opened anyway.
346+
seen: std::sync::Mutex<std::collections::HashMap<String, u64>>,
347+
}
348+
349+
impl SpentAskStates {
350+
pub(crate) fn new() -> Self {
351+
Self::default()
352+
}
353+
354+
/// SPEND this approval. `true` if it had not been spent before; `false` if it had.
355+
///
356+
/// Test-and-set under one lock, and that is not an optimisation: a caller that fires two
357+
/// redemptions of one approval concurrently is the obvious way to attack a check that reads and
358+
/// then writes, and it is the shape the whole gate exists to refuse.
359+
pub(crate) fn spend(&self, nonce: &str, expires_at: u64, now: u64) -> bool {
360+
// Poison-recovering, like every other request-path lock in this process: the data behind it
361+
// is still valid after a panic, and cascading the poison would turn one stray panic into a
362+
// gate that refuses every confirmation for the life of the process.
363+
let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
364+
seen.retain(|_, expiry| *expiry >= now);
365+
seen.insert(nonce.to_string(), expires_at).is_none()
366+
}
367+
}
368+
294369
/// A fresh nonce. `getrandom` is the same fail-closed entropy source key secrets use; a failure is
295370
/// not survivable here, because a predictable nonce is a `multi-round` scenario that passes by
296371
/// accident and a replay window that is wider than it looks.

crates/busbar/src/mcp/callerask.rs

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,20 +238,41 @@ pub(crate) struct Bind<'a> {
238238
pub(crate) now: u64,
239239
}
240240

241-
/// THE DECISION. Pure: config, caller input, and a clock. It is not in scope of any upstream
242-
/// response and cannot be — see the module header.
241+
/// THE APPROVAL MACHINERY: what mints continuation state, and what remembers it was spent.
242+
///
243+
/// One parameter rather than two because they are one thing viewed from either end — a `Sealer` with
244+
/// no ledger issues approvals nothing retires, which is precisely the defect
245+
/// [`askstate::SpentAskStates`] exists to close, and a call site that could pass the first without
246+
/// the second is a call site that can reintroduce it.
247+
#[derive(Clone, Copy)]
248+
pub(crate) struct Approvals<'a> {
249+
/// Mints and opens the sealed `requestState`. `None` is a deployment with no signing key, which
250+
/// refuses to ask at all rather than issue state it could not verify.
251+
pub(crate) sealer: Option<&'a Sealer>,
252+
/// The approvals already redeemed. See [`askstate::SpentAskStates`].
253+
pub(crate) spent: &'a askstate::SpentAskStates,
254+
}
255+
256+
/// THE DECISION. Config, caller input, a clock — and the ledger of approvals already spent. It is
257+
/// not in scope of any upstream response and cannot be — see the module header.
243258
///
244259
/// `rounds` is the operator's ordered list of rounds for THIS capability; an empty list means the
245260
/// capability declares no ask, which is the default and is today's behaviour.
261+
///
262+
/// NOT PURE, and only in one place: the arm that COMPLETES an exchange marks the approval it
263+
/// consumed as spent. That effect lives here rather than at the call site on purpose — a caller that
264+
/// had to remember to record the spend is a caller that can forget, and the check and the record
265+
/// have to be one atomic act or two concurrent redemptions both pass it.
246266
pub(crate) fn decide(
247267
rounds: &[AskRoundCfg],
248268
cap: u32,
249269
caller_capabilities: &serde_json::Value,
250270
retry: Retry<'_>,
251271
bind: Bind<'_>,
252272
args_digest: &str,
253-
sealer: Option<&Sealer>,
273+
approvals: Approvals<'_>,
254274
) -> AskDecision {
275+
let Approvals { sealer, spent } = approvals;
255276
// (1) A capability that declares NO ask never asks, and never accepts state either. Accepting
256277
// state here would mean busbar verifying a blob it had no reason to have minted.
257278
if rounds.is_empty() {
@@ -265,6 +286,10 @@ pub(crate) fn decide(
265286

266287
// (2) WHICH ROUND IS THIS? Read from the sealed state, never from a counter busbar holds between
267288
// requests and never from anything the caller can write. A caller with no state has not started one.
289+
// The approval this request is presenting, if it is presenting one: the nonce that identifies it
290+
// and the instant past which it could not be opened anyway. Carried down to the completion arm,
291+
// which is the only place it is spent.
292+
let mut presented: Option<(String, u64)> = None;
268293
let next_round = match retry.state {
269294
None => {
270295
// `mrtr.mdx` client requirements: a client MUST NOT invent state. Responses without one
@@ -290,6 +315,10 @@ pub(crate) fn decide(
290315
) {
291316
return AskDecision::Refuse(Refusal::StateRejected(e));
292317
}
318+
presented = Some((
319+
opened.nonce.clone(),
320+
opened.issued_at.saturating_add(opened.ttl_secs),
321+
));
293322
opened.round.saturating_add(1)
294323
}
295324
};
@@ -307,6 +336,20 @@ pub(crate) fn decide(
307336
// (4) EVERY ROUND ANSWERED ⇒ the exchange is complete and the call proceeds. This is the ONLY
308337
// arm that dispatches, and reaching it requires a verified state for the last configured round.
309338
let Some(this_round) = rounds.get(next_round as usize) else {
339+
// AND THE APPROVAL IS NOW SPENT. Everything above proves the state was busbar's, was this
340+
// caller's, was for this exact request and has not lapsed — all of which is equally true of
341+
// the SECOND presentation of a state already redeemed. Without this, an operator who gated a
342+
// tool because it moves money got confirm-once, execute-many.
343+
//
344+
// `rounds` is non-empty here (the empty case returned at step 1), so an exchange can only
345+
// complete by presenting state, and `presented` is therefore always `Some`. The `else` is
346+
// the fail-closed reading of a shape that cannot occur rather than a case with a meaning.
347+
let Some((nonce, expires_at)) = presented else {
348+
return AskDecision::Refuse(Refusal::StateRejected(askstate::Rejected::AlreadySpent));
349+
};
350+
if !spent.spend(&nonce, expires_at, bind.now) {
351+
return AskDecision::Refuse(Refusal::StateRejected(askstate::Rejected::AlreadySpent));
352+
}
310353
return AskDecision::Proceed;
311354
};
312355

@@ -428,3 +471,10 @@ fn declared(capabilities: &serde_json::Value, key: &str) -> bool {
428471
#[cfg(test)]
429472
#[path = "tests/callerask_tests.rs"]
430473
mod callerask_tests;
474+
475+
// THE GATE JUDGED FROM OUTSIDE IT: one approval, presented twice, against a real upstream that
476+
// records what it was told to do. Separate from the file above because nothing in it may reach into
477+
// the decision — the claim is about what a caller can make happen, not about which arm answered.
478+
#[cfg(test)]
479+
#[path = "tests/confirm_once_tests.rs"]
480+
mod confirm_once_tests;

0 commit comments

Comments
 (0)