From bd64a25c9721fa1e473ef871ed6e7753b27d88c0 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 11 Jun 2026 20:36:33 +0200 Subject: [PATCH] fix(audit-2026-06-11/H-F1): dispatcher same-app guards on queue + HTTP arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_invoke_request already asserts `script.app_id == row.app_id` (dispatcher.rs:752) before constructing an ExecRequest. The queue dispatcher (`dispatch_one_queue`) and the HTTP outbox arm (`build_http_request`) did not. validate_trigger_target blocks cross- app registration at write time, so the gap was latent; but a hand- edited row, a partial backup restore, or a script re-pointed across apps post-hoc could otherwise execute one app's script under another app's SdkCallCx, breaking the cross-app isolation boundary the SDK relies on. Adds the runtime guard at both sites: * dispatch_one_queue — on mismatch, dead-letter the message so the misfire is observable rather than silently dropped, and short- circuit. * build_http_request — on mismatch, return ResolveTrigger error; the outer dispatch arm logs + drops the row (same path as decode failures). Audit ref: security_audit/02_authz_isolation.md (H-F1). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/manager-core/src/dispatcher.rs | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/manager-core/src/dispatcher.rs b/crates/manager-core/src/dispatcher.rs index 7040895..5be31d7 100644 --- a/crates/manager-core/src/dispatcher.rs +++ b/crates/manager-core/src/dispatcher.rs @@ -311,6 +311,38 @@ impl Dispatcher { Err(e) => return Err(DispatcherError::ResolveTrigger(e.to_string())), }; + // Audit 2026-06-11 H-F1 — same-app guard. validate_trigger_target + // rejects cross-app rows at write time; this is the runtime + // backstop for hand-edited triggers, partial backup restores, or + // a script re-pointed across apps without re-validating the + // existing consumers. Dead-letter the message so the misfire is + // observable rather than executing one app's script under + // another app's SdkCallCx. + if script.app_id != claimed.app_id { + tracing::error!( + script_app = %script.app_id, + claim_app = %claimed.app_id, + script_id = %consumer.script_id, + "queue consumer script belongs to a different app; dead-lettering" + ); + let _ = self + .queue + .dead_letter( + claimed.id, + claimed.claim_token, + claimed.app_id, + &claimed.queue_name, + Some(consumer.trigger_id), + Some(consumer.script_id), + claimed.attempt, + claimed.enqueued_at, + "queue consumer target belongs to a different app", + ) + .await; + drop(permit); + return Ok(()); + } + let principal = self .principals .resolve(consumer.registered_by_principal) @@ -666,6 +698,16 @@ impl Dispatcher { DispatcherError::ResolveTrigger(format!("script {script_id} not found")) })?; + // Audit 2026-06-11 H-F1 sibling — same-app guard mirroring + // build_invoke_request and dispatch_one_queue. A hand-edited + // outbox row could otherwise execute one app's script under + // another app's SdkCallCx. + if script.app_id != row.app_id { + return Err(DispatcherError::ResolveTrigger( + "http outbox target belongs to a different app".into(), + )); + } + let payload: HttpDispatchPayload = serde_json::from_value(row.payload.clone()) .map_err(|e| DispatcherError::ResolveTrigger(format!("decode http payload: {e}")))?;