Repository navigation
Bound update wait_timeout_seconds at 60 seconds (#775) - #778
Conversation
A completion wait holds the request open, and wait_timeout_seconds was caller-unbounded on the update route and on bridge update targets, in PHP and natively. Both servers now refuse a value above 60 with 422 validation_failed before the update is admitted, and the control-plane request contract publishes the bound as operations.update.fields.wait_timeout_seconds.maximum. The configured default (DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS) still applies when the field is absent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pinned Workflow 2.13.1 takes DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS as given, so a request without wait_timeout_seconds could still wait for as long as the operator configured. UpdateWaitConfiguration clamps the effective default to the request ceiling at boot, and native's config reader clamps it the same way. Workflow main does the same in UpdateWaitPolicy (durable-workflow/workflow@2a7511501b), not yet released. Tests cover the run-targeted update alias as well as the instance route, and the boot-time clamp from the environment setting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Security review of exact head The direct instance/run routes, bridge route, PHP configured-default clamp, native request/admission rules, native configured-default clamp, published request contract, and their 60/61 regressions satisfy the named paths. One accepted PHP update alias remains outside the bound.
For an
The pinned
There is a second form of the same path to audit: PHP workflow-task
Required successor:
Green CI on this head cannot clear the uncovered ingress. The older #776 and #773 activations remain separately held. |
Security's review of #778 found one more update alias: a service operation bound to a workflow update hands its wait_timeout_seconds to the update admission, and the execute route allows up to 86400 while the workflow-task start_service_operation command allows any nonnegative value. The pinned Workflow 2.13.1 passes that explicit value to WorkflowControlPlane::update unchanged, so the configured-default clamp is no backstop. Both ingresses now refuse a wait above 60 for an operation whose binding is a workflow update (workflow_update or update_workflow), with the update route's validation refusal and before the operation runs: no service call, update command or history is recorded. Other bindings keep the service operation's own wait contract. Native runs no service operation: it answers the execute route 501 and refuses start_service_operation as unsupported_workflow_command. A native test pins that, and the port guide names the bound for the future slice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Security re-review of exact successor 5f78555: the direct update-bound service-operation paths are now covered, but this head remains held for an admission-time TOCTOU gap. Both added guards inspect Required before Security clearance: enforce the ceiling against the same operation resolution used by authoritative service-call admission/execution, or lock and revalidate the exact catalog chain within the same transaction; preserve the 0..86400 contract for other bindings. Add deterministic regression/counterfactual evidence that a binding change cannot bypass the ceiling. Fresh exact-head hosted gates remain required. The prior 256a5ac hold and this successor hold remain in force. |
Security's review of 5f78555 found a check-then-act window: the route checks read the catalog operation before the Workflow package resolves it again to execute, so a concurrent catalog change could turn a checked start_workflow operation into a workflow update and carry a wait above 60. ServerWorkflowControlPlane::update, which every update admission reaches, including the service control plane's update binding after its own lookup, now refuses a wait_timeout_seconds above 60 before admitting. The route checks still answer first with their validation refusal; this is the boundary a raced binding cannot pass. A regression flips the binding between the server's check and execution, on the execute route and the workflow-task command: 61 admits no update, 60 does. Without the admission check the flipped 61 admits the update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Security source re-review of exact head
Precision note: in the forced concurrent-catalog race, the service call may already have been admitted under the old binding and then durably end as a failed call with 409. That audit row is fail-closed; it is not a workflow update admission or an unbounded wait. Do not extend the ordinary precheck's “no service call” claim to that raced case. No further source change is required by this Security review. This does not authorize merge/deploy and does not clear the separate open CodeQL #138 source finding. |
|
Security final clearance for exact head The exact-source TOCTOU review is recorded in the preceding Security comment. The candidate remains byte-identical. Hosted evidence is now terminal:
The holds on This clearance is source/merge scope only. It does not authorize deployment, does not clear #773 or #776, and does not dismiss the separate pre-existing CodeQL #138 finding now tracked in #779. |
|
Merged as
|



Fixes #775.
Problem
An update with
wait_for: completedholds its request open for the caller'swait_timeout_seconds. That field was validated only asnullable|integer|min:1, both onPOST /api/workflows/{id}/update/{name}(and its run-targeted form) and on bridgeupdate_workflowtargets. PHP and native had the same gap, so one request could hold a server worker, or a native request task, for as long as it asked.Change
Both servers now refuse
wait_timeout_secondsabove 60 with422 validation_failed("The wait timeout seconds field must not be greater than 60."). The refusal happens before admission, so no command, update or history is recorded.PHP:
ControlPlaneRequestContract::UPDATE_WAIT_TIMEOUT_SECONDS_MAXis applied as amax:rule inWorkflowController::updateandBridgeAdapterController.Native:
command_requests::UPDATE_WAIT_TIMEOUT_SECONDS_MAXis applied in the update request rules, both the HTTP pre-check and the admission rules.Contract: the control-plane request contract publishes
operations.update.fields.wait_timeout_seconds={minimum: 1, maximum: 60}on both servers. The change is additive, so the contract version stays 1.Docs:
docs/server-reference.mddocuments the bound and the behavior when a wait times out (the update continues and reportswait_timed_out: true).Update-bound service operations (Security review of 256a5ac, comment 6410282701): an operation whose binding is a workflow update (
workflow_updateorupdate_workflow) hands itswait_timeout_secondsto the update admission. The pinned Workflow 2.13.1 passes that value through unchanged. Both service-operation ingresses therefore refuse a wait above 60 for such an operation, with the same validation refusal, before the operation runs:ServiceCatalogController::executeOperation);start_service_operationcommand (WorkerController::authorizeServiceOperationCommands, which runs before admission).The shared check is
App\Support\ServiceOperationUpdateWait. Other bindings keep the service operation's own0..86400contract. Native runs no service operation: it answers the execute route501and refusesstart_service_operationasunsupported_workflow_command. A native test pins both, and the port guide names the bound for the future native slice.Update admission:
ServerWorkflowControlPlane::update, which every update admission reaches (the service control plane resolves it after its own catalog lookup), refuses await_timeout_secondsabove 60 before admitting. The route checks still answer first. This is the boundary a catalog binding changed after those checks cannot pass (Security review of 5f78555). A deterministic regression flips the binding between the server's check and execution: 61 admits no workflow update command, update event or completion wait, and 60 admits one. The raced service call itself can still end durably as a failed call (an audit row), which is fail-closed. With the admission check removed, the raced 61 admits the update.Configured default: the effective
DW_V2_UPDATE_WAIT_COMPLETION_TIMEOUT_SECONDS, used when the request omits the field, is clamped to 60. The pinned Workflow 2.13.1 takes the setting as given, so PHP clamps it at boot inApp\Support\UpdateWaitConfiguration::apply(). Native'sconfig_audit::update_wait_completion_timeout_secondsclamps the same value.Why 60, and why refuse rather than clamp a caller's value:
mainhas the same 60-second refusal and default clamp inUpdateWaitPolicy(durable-workflow/workflow@2a7511501b). That commit is not released and is not in the pinned 2.13.1 artifact, so this PR does not depend on it.Tests
tests/Feature/WorkflowControlPlaneTest.php,tests/Feature/BridgeAdapterControllerTest.php): 61 is refused on the instance route, the run-targeted route and the bridge, with no update command recorded. 60 is admitted, and the contract field is published.tests/Feature/UpdateWaitConfigurationTest.php): a configured 600 becomes 60, while 1, 25 and 60 are kept. Setting the environment variable to 600 and rebooting the application yields 60.max:rule removed, the route tests fail (202 and 200 instead of 422).update_wait_timeout_is_bounded_before_admission: both routes refuse 61, noUpdateAcceptedfollows, one follows after 60, and the contract field is published.tests/Feature/ServiceOperationUpdateWaitTest.php): a real catalog operation bound to a live workflow'sapproveupdate. On the execute route and on the workflow-task command, 61 is refused with no service call, no update command and noUpdateAccepted,UpdateRejectedorServiceCallStartedevent; 60 is admitted with one service call and one update command. Astart_workflow-bound operation still takes 600, and 86401 is refused as before.service_operations_cannot_carry_an_update_wait_past_the_ceilingchecks that the command is refused asunsupported_workflow_commandand that the execute route answers501.Qualification at 378bb18
The native lines below are from 5f78555; 378bb18 changes PHP only.
command_requests,updates_andupdate_wait: 32/32 on each of PostgreSQL 17, MySQL 8 and MariaDB 10.11.update-validation,state-updatesandvisibility-current-runs: pass on all four engines at 256a5ac; the successor adds no change they cover.ServiceOperationUpdateWaitTest4/4ExternalWorkflowUpdateRequestIdTest47/47WorkflowUpdateWorkerRoutingTest13/13WorkflowUpdateValidationTaskTest13/13ServiceExecutionRoutesTest16/16WorkerServiceOperationTest35/35ServiceCatalogControllerTest30/30WorkflowWorkerProtocolTest108/108WorkflowControlPlaneTest54/54BridgeAdapterControllerTest9/9ClusterInfoTest73/73UpdateWaitConfigurationTest3/3Pint passes on every touched PHP file. Hosted CI at this head: 60 passed, 1 skipped.
🤖 Generated with Claude Code