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
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
Comment #3
mably commentedMR !284 implements the hardening.
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).
Comment #5
mably commented