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