All vulnerabilities
GHSA-qwrm-2cq8-xfr8

trigger.dev: Authorize parent runs before attaching batch-created waitpoints

trigger.devFrameworks
CVSS
8.1High
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed inNone listedNo source names a fixed version yet
Published2 Oct 2026Updated 2 Oct 2026GitHub

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'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)

  1. 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.

  1. 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.
  2. 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).

Text from GitHub Advisory Database

Affected versions 1

  • trigger.dev<= 4.7.0GitHub