trigger.dev: Authorize parent runs before attaching batch-created waitpoints
What fixes it
No source in this snapshot names a fixed version.
Description
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'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 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-cachedandWaitbranch, blocks the parent run from the samerequest.body.options.parentRunIdand 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 toundefinedand is ignored. - The other
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/batches with {"runCount":1,"parentRunId":"run_<victim>","resumeParentOnCompletion":true}:
- The victim run gets a
TaskRunWaitpointedge and is transitioned toSUSPENDED/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 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. - On the v2 paths (B/C) there is no
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.- A nonexistent
parentRunIdthrows (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).
Text from GitHub Advisory Database
Affected versions 1
- trigger.dev<= 4.7.0GitHub