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:
@@ -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}")))?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user