Summary
Trigger.dev: Run replay injects a task run into an attacker-chosen environment (cross-tenant write)
The dashboard replay action authorizes the source run (it must belong to the caller's org), but the target environment for the replayed run is taken verbatim from the request body and is never checked for org or project membership. The environment lookup used by the replay path filters by id only. As a result, an authenticated user can replay one of their own runs into another organization's or project's environment, creating a task run there that consumes the victim tenant's queue and compute and pollutes their run history.
Details
The replay action member-scopes the source run correctly (apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts, the findFirst on taskRun filtered by project.organization.members.some.userId), but passes the target environment straight from the submitted form, resources.taskruns.$runParam.replay.ts:344-346:
const replayRunService = new ReplayTaskRunService();
const newRun = await replayRunService.call(taskRun, {
environmentId: submission.value.environment, // attacker-controlled, unvalidated
payload: submission.value.payload,
...
});
submission.value.environment comes from ReplayRunData (apps/webapp/app/v3/replayTask.ts: environment: z.string().optional()), so it is fully client-controlled.
ReplayTaskRunService resolves it with no ownership check, apps/webapp/app/v3/services/replayTaskRun.server.ts:26-28:
const authenticatedEnvironment = await findEnvironmentById(
overrideOptions.environmentId ?? existingTaskRun.runtimeEnvironmentId
);
findEnvironmentById, apps/webapp/app/models/runtimeEnvironment.server.ts:197-211:
export async function findEnvironmentById(id) {
const environment = await $replica.runtimeEnvironment.findFirst({
where: { id }, // no membership / org / project filter
include: authIncludeWithParent,
});
if (!environment || environment.project.deletedAt !== null) return null;
return toAuthenticated(environment);
}
The resolved environment is then handed to TriggerTaskService.call (apps/webapp/app/v3/services/triggerTask.server.ts), which trusts the passed authenticatedEnvironment and performs no further authorization. The replay loader does constrain the environment picker to the run's own project (replay.ts around 178-184), but the action does not re-impose that constraint, so the server-side control is missing (UI-gated only).
PoC
As an authenticated user who owns a run in their own org (run param runParam), and who knows a target environment id belonging to another org or project (a CUID, obtained or guessed):
POST /resources/taskruns/<own runParam>/replay
Content-Type: application/x-www-form-urlencoded
environment=<environment id of the victim org/project>&failedRedirect=/...
The replay resolves the victim environment with no membership check and calls TriggerTaskService against it, creating a run in the victim tenant.
Live-validated: a Postgres database was seeded with two tenants (orgA with env_A, orgB with env_B_victim) and the verbatim findEnvironmentById lookup (findFirst joining the environment to its project, WHERE id = $1, the exact query the replay path runs) was executed with the victim env id as an orgA attacker. Captured:
[002 replay findEnvironmentById] attacker(orgA) supplied env_B_victim -> {"id":"env_B_victim","organization_id":"orgB","project_id":"proj_B","deleted_at":null}
CROSS-TENANT: CONFIRMED (resolved another org's env with no membership check -> replay would run there)
The lookup returned another organization's environment with no membership filter. That the replay action passes the client-supplied environmentId straight into this lookup (and TriggerTaskService trusts the returned env) is verified at source.
Impact
An authenticated user can create task runs in another tenant's environment (including production), consuming that tenant's queue concurrency and compute and inserting entries into their run history. Practical exploitation requires knowing a target environment id (not enumerable) and, for the injected run to actually execute rather than error, a task identifier that exists in the target environment; this bounds reliability but not the unauthorized cross-tenant write itself.
GHSA-QXPP-QJG8-X4JV has a CVSS score of 7.1 (High). The vector is network-reachable, low privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (4.5.2); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
In the replay action (or in ReplayTaskRunService), require the resolved target environment to belong to the source run's project/organization, for example assert environment.projectId === existingTaskRun.projectId, or re-validate the supplied environment id against the caller's membership (findEnvironmentBySlug with the validated projectId, or a members.some.userId filter), mirroring the constraint the loader already applies to the picker.
Frequently Asked Questions
- What is GHSA-QXPP-QJG8-X4JV? GHSA-QXPP-QJG8-X4JV is a high-severity security vulnerability in trigger.dev (npm), affecting versions < 4.5.2. It is fixed in 4.5.2.
- How severe is GHSA-QXPP-QJG8-X4JV? GHSA-QXPP-QJG8-X4JV has a CVSS score of 7.1 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of trigger.dev are affected by GHSA-QXPP-QJG8-X4JV? trigger.dev (npm) versions < 4.5.2 is affected.
- Is there a fix for GHSA-QXPP-QJG8-X4JV? Yes. GHSA-QXPP-QJG8-X4JV is fixed in 4.5.2. Upgrade to this version or later.
- Is GHSA-QXPP-QJG8-X4JV exploitable, and should I be worried? Whether GHSA-QXPP-QJG8-X4JV is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether GHSA-QXPP-QJG8-X4JV is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix GHSA-QXPP-QJG8-X4JV? Upgrade
trigger.devto 4.5.2 or later.