Skip to content

Missing run authorization allows cross-tenant suspension and resume-data injection

High
carderne published GHSA-xrp6-5fpv-4fxf Sep 14, 2026

Package

triggerdotdev/trigger.dev

Affected versions

<= 4.6.0

Patched versions

>= 4.6.0

Description

Summary

POST /engine/v1/runs/:runFriendlyId/waitpoints/tokens/:waitpointFriendlyId/wait validates that the
waitpoint belongs to the caller's environment, but never validates the run. The run id is
decoded straight from the URL and handed to the engine.

Any holder of an ordinary secret API key can therefore block any run in any organization on a
waitpoint that exists only in the attacker's environment — a waitpoint the victim cannot see, list
or complete.

The route's sibling in the same directory does this correctly, which is what makes this an
oversight rather than a design decision.

Details

apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts:

const waitpointId = WaitpointId.toId(params.waitpointFriendlyId);
const runId = RunId.toId(params.runFriendlyId);          // :23  pure string decode, no lookup

try {
  //check permissions                                    // :26  the comment says permissions...
  const waitpoint = await resolveWaitpointThroughReadThrough({
    waitpointId,
    environmentId: authentication.environment.id,
    read: (client) => client.waitpoint.findFirst({
      where: { id: waitpointId, environmentId: authentication.environment.id },   // :33-36
    }),
  });
  if (!waitpoint) { throw json({ error: "Waitpoint not found" }, { status: 404 }); }

  const _result = await engine.blockRunWithWaitpoint({
    runId,                                               // :52  the victim's, never checked
    waitpoints: [waitpointId],
    projectId: authentication.environment.project.id,    // :54  the ATTACKER's project
    organizationId: authentication.environment.organization.id,
  });

RunId.toId is a pure friendlyId→id string transform. The //check permissions block at :26-38
covers only the waitpoint, which the attacker legitimately owns.

Nothing downstream re-checks. engine.blockRunWithWaitpoint
(internal-packages/run-engine/src/engine/index.ts:2050-2085) forwards its arguments unchanged to
waitpointSystem.blockRunWithWaitpoint, which does:

let snapshot = await getLatestExecutionSnapshot(prisma, runId, this.$.runStore);   // :283-287
...
const { pendingCount } = await this.coordinator.registerBlocks({
  runId, waitpointIds: $waitpoints, projectId, ...                                 // :292-300
});

getLatestExecutionSnapshot (executionSnapshotSystem.ts:208-217) takes an optional
environmentId and only scopes when it is supplied:

export async function getLatestExecutionSnapshot(
  prisma, runId, runStore?, environmentId?              // :208-212
) {
  ... prisma.taskRunExecutionSnapshot.findFirst({
        where: { runId, isValid: true, ...(environmentId ? { environmentId } : {}) },  // :217

This call site omits it. The parameter exists precisely because this scoping is needed elsewhere —
it is simply not passed here.

The guarded sibling is the adjacent file.
apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts makes the same
engine.blockRunWithWaitpoint call and first does:

const run = await runStore.findRun(
  { id: runId, runtimeEnvironmentId: authentication.environment.id },   // :28-34
  prisma
);
if (!run) {
  throw new Response("You don't have permissions for this run", { status: 401 });  // :36-38
}
...
const _waitResult = await engine.blockRunWithWaitpoint({ runId: run.id, ... });    // :56-57

Two routes, same directory, same engine call. One resolves the run scoped by
runtimeEnvironmentId and returns 401 "You don't have permissions for this run"; the other
passes the raw URL parameter. The sibling's error string states the invariant this route breaks.

allowJWT is false on this route (apiBuilder.server.ts), so a public token will not reach it — a
plain secret key from any self-signup environment will.

PoC

Verified by driving the real RunEngine against real Postgres and Redis containers through the
project's own @internal/testcontainers harness — the same containerTest /
setupAuthenticatedEnvironment / setupBackgroundWorker fixtures your 12 existing waitpoint tests
use. Nothing is copied or reimplemented: the test imports RunEngine and setup.ts from the repo.
Two organizations, each with its own project and environment.

=== CONTROL — the check the adjacent route performs ===
  findRun({id: victimRun, runtimeEnvironmentId: attackerEnv}) -> null  => sibling route returns 401

=== EXPLOIT — what the waitpoint-token wait route does ===
  engine.blockRunWithWaitpoint({runId: VICTIM, projectId: ATTACKER}) -> ok

=== RESULT ===
  victim run execution status : EXECUTING_WITH_WAITPOINTS
  TaskRunWaitpoint row exists : true
  ...its projectId            : cmtcvx68l000b5d1w930ir951   <- the ATTACKER's project
  attacker projectId          : cmtcvx68l000b5d1w930ir951
  victim   projectId          : cmtcvx68e00045d1w59vkv2gc

  >>> another org's run is blocked on a waitpoint it cannot see, list or complete

vitest: 1 passed (1)

The CONTROL line is the exact lookup wait.duration.ts:28-38 performs before calling the same
engine method. It returns null across tenants — which is why that route answers
401 "You don't have permissions for this run". The EXPLOIT line is the call
...waitpoints.tokens.$waitpointFriendlyId.wait.ts:51-56 makes with the run id decoded straight
from the URL at :23.

Over HTTP the equivalent is:

POST /engine/v1/runs/run_VICTIM/waitpoints/tokens/waitpoint_ATTACKER/wait
Authorization: Bearer tr_prod_<attacker key>
-> 200 {"success": true}

POST /engine/v1/runs/run_VICTIM/wait/duration          # adjacent route, same attacker
-> 401 "You don't have permissions for this run"

Run ids are not secrets in this product. They appear in dashboard URLs, SDK and CI logs,
trigger() responses shared between teams, and inside public access tokens handed to browsers.

The test file is included with this report and drops into
internal-packages/run-engine/src/engine/tests/.

Impact

As an attacker holding only a free-tier secret API key for my own environment, I could freeze any
other organization's task run indefinitely
— it blocks on a waitpoint that exists only in my
environment, has no timeout, and that the victim has no API surface to discover or clear. Recovery
requires operator intervention.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:H

CVE ID

No known CVE

Weaknesses

Authorization Bypass Through User-Controlled Key

The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data. Learn more on MITRE.

Missing Authorization

The product does not perform an authorization check when an actor attempts to access a resource or perform an action. Learn more on MITRE.

Credits