Summary
The batch-creation endpoints block a caller-supplied parentRunId against a waitpoint without confirming the parent run belongs to the caller's environment. Combined with a guard that short-circuits for any unrestricted API key, a holder of a normal environment API key (including a low-trust DEVELOPMENT / preview / CI key) can suspend, indefinitely block, and inject a forced error into a run they do not own — a run in another environment of the same project (production), or, given a known run id, a run belonging to another organization.
Affected code (HEAD 6bf731b)
Three unscoped call sites, all reached from public authenticated API routes:
| # |
file:line |
route |
| A |
apps/webapp/app/runEngine/services/createBatch.server.ts:128-140 |
POST /api/v3/batches |
| B |
apps/webapp/app/runEngine/services/batchTrigger.server.ts:190-197 |
POST /api/v2/tasks/batch (small-batch arm) |
| C |
apps/webapp/app/runEngine/services/batchTrigger.server.ts:281-288 |
POST /api/v2/tasks/batch (large-batch arm) |
Each does, verbatim:
if (body.parentRunId && body.resumeParentOnCompletion) {
await this._engine.blockRunWithCreatedBatch({
runId: RunId.fromFriendlyId(body.parentRunId), // raw, caller-supplied friendlyId
batchId: batch.id,
environmentId: environment.id, // the CALLER's env, not the run's
projectId: environment.projectId,
organizationId: environment.organizationId,
});
}
body.parentRunId is never resolved against the caller's tenant. The id flows straight into the engine:
RunEngine.blockRunWithCreatedBatch (internal-packages/run-engine/src/engine/index.ts:1961) creates a BATCH waitpoint with the caller's environmentId/projectId, then calls blockRunWithWaitpoint({ runId, organizationId, ... }).
WaitpointSystem.blockRunWithWaitpoint (internal-packages/run-engine/src/engine/systems/waitpointSystem.ts:347) loads the run via getLatestExecutionSnapshot(prisma, runId) with no environment/organization predicate, resolves residency with the caller-supplied organizationId, then coordinator.registerBlocks({ runId, ... }) writes a TaskRunWaitpoint edge keyed on the victim run id — no ownership check anywhere in the chain. A new execution snapshot is written transitioning the victim run to SUSPENDED / EXECUTING_WITH_WAITPOINTS, stamped with the attacker's organizationId, and sendNotificationToWorker tells the victim's worker to suspend.
Why the route guard does not stop it
Both routes call canWriteParentRun(...) before the service (apps/webapp/app/utils/parentRunAuthorization.server.ts:5). Its first branch:
// A type-level `write:runs` grant covers every run in the environment, so
// resolving the parent could only confirm what we already know.
if (ability.can("write", { type: "runs" })) return true;
A normal environment API key is authenticated with permissiveAbility (internal-packages/rbac/src/ability.ts:10, can: () => true; assigned at internal-packages/rbac/src/bearerCredentials.ts:279). So ability.can("write", { type: "runs" }) is true, the guard returns immediately, and the env/org-scoped resolveRunForMutation on the next line is never reached. The comment's premise holds only if the callee then confirms the run is in the environment — which the three sites above do not. Restricted/granular additional keys and public JWTs are correctly blocked here (they fall through to the scoped resolver); the broad credential is the one that escapes the tenant.
Proof this is a bug, not design (sibling paths)
- The same operation, hardened, one file away.
apps/webapp/app/runEngine/concerns/idempotencyKeys.server.ts:485-499, the idempotency-cached andWait branch, blocks the parent run from the same request.body.options.parentRunId and guards it explicitly:
// `parentRunId` comes from the request body and isn't re-validated
// here, so confirm the parent run is in the caller's environment
// before wiring a waitpoint against it.
const parentRunInCallerEnv = await runStore.findRun(
{ id: parentRunInternalId, runtimeEnvironmentId: request.environment.id }, ...);
if (!parentRunInCallerEnv) {
throw new ServiceValidationError("Parent run not found in the calling environment", 404);
}
This is the same fix, applied to one path and missed on the batch-create paths.
- The single-trigger path (
apps/webapp/app/runEngine/services/triggerTask.server.ts) resolves the parent with { id, runtimeEnvironmentId: environment.id }; a foreign parent resolves to undefined and is ignored.
- The other
blockRunWithWaitpoint callers (engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts, engine.v1.runs.$runFriendlyId.wait.duration.ts) env-scope the run first and 401 on miss.
Impact
POST /api/v3/batches with {"runCount":1,"parentRunId":"run_<victim>","resumeParentOnCompletion":true}:
- The victim run gets a
TaskRunWaitpoint edge and is transitioned to SUSPENDED / EXECUTING_WITH_WAITPOINTS (integrity: a running/queued task the attacker doesn't own is suspended).
- On the v3 path,
scheduleExpireBatch (createBatch.server.ts:130) fires after BATCH_SEAL_TIMEOUT_MS (default 1,800,000 ms) and batchSystem.expireBatch completes the waitpoint with isError: true — injecting an attacker-caused error into the victim's run.
- On the v2 paths (B/C) there is no
scheduleExpireBatch, so a never-sealed batch leaves the foreign run blocked with no reaper (availability).
blockRunWithWaitpoint has no terminal-state guard, so a completed run can be pushed back to a SUSPENDED snapshot.
- A nonexistent
parentRunId throws (500) versus a real one (202): a cross-tenant existence oracle.
Preconditions
- Any valid unrestricted environment API key (
tr_dev_…, tr_preview_…, tr_prod_…) for an environment the attacker controls, including a free-tier org on Cloud. Public JWTs and restricted/granular additional keys do not work (they hit the scoped resolver).
- Cross-environment (the realistic vector, no id-guessing): a DEVELOPMENT/preview/CI env key can target PRODUCTION run ids of the same org — those ids are visible on the dashboard to every org member, and dev keys are per-member and widely distributed in
.env/CI. This is the "lower-trust key reaching production" boundary (cf. GHSA-4672).
Summary
The batch-creation endpoints block a caller-supplied
parentRunIdagainst a waitpoint without confirming the parent run belongs to the caller's environment. Combined with a guard that short-circuits for any unrestricted API key, a holder of a normal environment API key (including a low-trust DEVELOPMENT / preview / CI key) can suspend, indefinitely block, and inject a forced error into a run they do not own — a run in another environment of the same project (production), or, given a known run id, a run belonging to another organization.Affected code (HEAD 6bf731b)
Three unscoped call sites, all reached from public authenticated API routes:
apps/webapp/app/runEngine/services/createBatch.server.ts:128-140POST /api/v3/batchesapps/webapp/app/runEngine/services/batchTrigger.server.ts:190-197POST /api/v2/tasks/batch(small-batch arm)apps/webapp/app/runEngine/services/batchTrigger.server.ts:281-288POST /api/v2/tasks/batch(large-batch arm)Each does, verbatim:
body.parentRunIdis never resolved against the caller's tenant. The id flows straight into the engine:RunEngine.blockRunWithCreatedBatch(internal-packages/run-engine/src/engine/index.ts:1961) creates a BATCH waitpoint with the caller'senvironmentId/projectId, then callsblockRunWithWaitpoint({ runId, organizationId, ... }).WaitpointSystem.blockRunWithWaitpoint(internal-packages/run-engine/src/engine/systems/waitpointSystem.ts:347) loads the run viagetLatestExecutionSnapshot(prisma, runId)with no environment/organization predicate, resolves residency with the caller-suppliedorganizationId, thencoordinator.registerBlocks({ runId, ... })writes aTaskRunWaitpointedge keyed on the victim run id — no ownership check anywhere in the chain. A new execution snapshot is written transitioning the victim run toSUSPENDED/EXECUTING_WITH_WAITPOINTS, stamped with the attacker'sorganizationId, andsendNotificationToWorkertells the victim's worker to suspend.Why the route guard does not stop it
Both routes call
canWriteParentRun(...)before the service (apps/webapp/app/utils/parentRunAuthorization.server.ts:5). Its first branch:A normal environment API key is authenticated with
permissiveAbility(internal-packages/rbac/src/ability.ts:10,can: () => true; assigned atinternal-packages/rbac/src/bearerCredentials.ts:279). Soability.can("write", { type: "runs" })istrue, the guard returns immediately, and the env/org-scopedresolveRunForMutationon the next line is never reached. The comment's premise holds only if the callee then confirms the run is in the environment — which the three sites above do not. Restricted/granular additional keys and public JWTs are correctly blocked here (they fall through to the scoped resolver); the broad credential is the one that escapes the tenant.Proof this is a bug, not design (sibling paths)
apps/webapp/app/runEngine/concerns/idempotencyKeys.server.ts:485-499, the idempotency-cachedandWaitbranch, blocks the parent run from the samerequest.body.options.parentRunIdand guards it explicitly:apps/webapp/app/runEngine/services/triggerTask.server.ts) resolves the parent with{ id, runtimeEnvironmentId: environment.id }; a foreign parent resolves toundefinedand is ignored.blockRunWithWaitpointcallers (engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts,engine.v1.runs.$runFriendlyId.wait.duration.ts) env-scope the run first and 401 on miss.Impact
POST /api/v3/batcheswith{"runCount":1,"parentRunId":"run_<victim>","resumeParentOnCompletion":true}:TaskRunWaitpointedge and is transitioned toSUSPENDED/EXECUTING_WITH_WAITPOINTS(integrity: a running/queued task the attacker doesn't own is suspended).scheduleExpireBatch(createBatch.server.ts:130) fires afterBATCH_SEAL_TIMEOUT_MS(default 1,800,000 ms) andbatchSystem.expireBatchcompletes the waitpoint withisError: true— injecting an attacker-caused error into the victim's run.scheduleExpireBatch, so a never-sealed batch leaves the foreign run blocked with no reaper (availability).blockRunWithWaitpointhas no terminal-state guard, so a completed run can be pushed back to aSUSPENDEDsnapshot.parentRunIdthrows (500) versus a real one (202): a cross-tenant existence oracle.Preconditions
tr_dev_…,tr_preview_…,tr_prod_…) for an environment the attacker controls, including a free-tier org on Cloud. Public JWTs and restricted/granular additional keys do not work (they hit the scoped resolver)..env/CI. This is the "lower-trust key reaching production" boundary (cf. GHSA-4672).