Problem

The dispatcher signal route (the one-click visitor outcomes: cancel, back) accepts an instance-scoped capability link and resumes whatever step the instance is parked on at that moment. A signal is written for one specific step, so a link that outlives its step becomes dangerous: clicked (or prefetched) after the run advanced, it injects its outcome into a DIFFERENT parked step. The recent yoyaku payment incident was exactly this shape in a sibling channel: a step-blind resume delivered "paid" into an operator validation step, matched no outgoing flow and silently completed the run with the booking stranded.

This is live behavior, not a theoretical caller: InteractionContext::signalUrl() follows the scope the visitor arrived under, so a step reached through an instance-scoped link renders instance-scoped cancel/back links. Every signal names one step; minting it step-blind is never right.

Hardening

  • InteractionContext::signalUrl() always mints a branch-scoped (token id) capability, whatever scope the visitor arrived under; the parked token is right there in the context.
  • CapabilityResolver::resolveSignal() requires a token-scoped grant: an instance-scoped link may view and dispatch, never signal (denied). The assigned-step guard and the outcome allow-list stay as they are.
  • A stale branch signal (its token already consumed because the step advanced) becomes a harmless no-op: the controller skips the resume and redirects to the branch step page, which follows the branch to its current state. A double click or a link prefetch never 403s and never advances anything.
  • The instance-scoped signalUrl() minting is removed outright from InteractionUrlsInterface, InteractionUrls and the gateways (pre-release, so no deprecation dance).
  • The continuation path gets the same rule: CapabilityContinuationResolver refuses an instance-scoped token as a continuation handle (handles are always minted branch-scoped, so a step link presented as a handle is an attack, not a use case), and ResolvedContinuation::tokenId becomes non-nullable, which removes the webform handler resume-whatever-is-parked fallbacks.

Tests

Kernel coverage: an instance-scoped signal is denied and the step stays parked; a branch-scoped signal resumes exactly its own branch; a stale branch signal no-ops without advancing the run; the disallowed-outcome and assigned-step refusals keep their coverage under the new scope rule.

Issue fork orchestra-3608405

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

mably created an issue. See original summary.

mably’s picture

Status: Active » Needs review

MR !284 implements the hardening.

  • InteractionContext::signalUrl() always mints a branch-scoped capability; the instance-scoped signalUrl() minting is removed from InteractionUrlsInterface, InteractionUrls and both gateways.
  • CapabilityResolver::resolveSignal() denies an instance-scoped grant (it may still view and dispatch) and returns NULL for a stale branch token, so a double click or a prefetch no-ops; the controller then redirects scoped to the signalled branch, so the visitor follows their own branch, never a sibling.
  • Same rule on the continuation path: CapabilityContinuationResolver refuses an instance-scoped token as a handle and ResolvedContinuation::tokenId is non-nullable, which removes the webform handler resume-whatever-is-parked fallbacks.
  • The docs table of token shapes and the threat model gained the scope rule.

Coverage: an instance-scoped signal is denied and the step stays parked; a branch-scoped signal resumes exactly its branch; a stale branch signal no-ops without advancing the run and keeps the redirect on its branch; the disallowed-outcome and assigned-step refusals are re-scoped; a webform submission binding an instance-scoped token neither binds nor resumes.

Verified locally: the full orchestra kernel sweep (23 concurrent batches), the 17 Functional and 6 FunctionalJavascript tests in Docker, phpcs and cspell, plus the yoyaku consumer kernel sweep (its only red is the pre-existing cancel-release failure tracked separately).

  • mably committed e6f53435 on 1.x
    task: #3608405 Harden the interaction dispatcher: a signal must be...
mably’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.