fix(audit-2026-06-11/H-F1): dispatcher same-app guards on queue + HTTP arms

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-11 20:36:33 +02:00
parent 2af8ee49ba
commit bd64a25c97

View File

@@ -311,6 +311,38 @@ impl Dispatcher {
Err(e) => return Err(DispatcherError::ResolveTrigger(e.to_string())), 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 let principal = self
.principals .principals
.resolve(consumer.registered_by_principal) .resolve(consumer.registered_by_principal)
@@ -666,6 +698,16 @@ impl Dispatcher {
DispatcherError::ResolveTrigger(format!("script {script_id} not found")) 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()) let payload: HttpDispatchPayload = serde_json::from_value(row.payload.clone())
.map_err(|e| DispatcherError::ResolveTrigger(format!("decode http payload: {e}")))?; .map_err(|e| DispatcherError::ResolveTrigger(format!("decode http payload: {e}")))?;