GHSA-QXPP-QJG8-X4JV
Vulnerability from github – Published: 2026-10-02 19:31 – Updated: 2026-10-02 19:31Summary
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.
Remediation
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.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "trigger.dev"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.5.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-639"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-02T19:31:17Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\nThe dashboard replay action authorizes the source run (it must belong to the caller\u0027s 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\u0027s or project\u0027s environment, creating a task run there that consumes the victim tenant\u0027s queue and compute and pollutes their run history.\n\n### Details\nThe 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:\n```js\nconst replayRunService = new ReplayTaskRunService();\nconst newRun = await replayRunService.call(taskRun, {\n environmentId: submission.value.environment, // attacker-controlled, unvalidated\n payload: submission.value.payload,\n ...\n});\n```\n`submission.value.environment` comes from ReplayRunData (apps/webapp/app/v3/replayTask.ts: `environment: z.string().optional()`), so it is fully client-controlled.\n\nReplayTaskRunService resolves it with no ownership check, apps/webapp/app/v3/services/replayTaskRun.server.ts:26-28:\n```js\nconst authenticatedEnvironment = await findEnvironmentById(\n overrideOptions.environmentId ?? existingTaskRun.runtimeEnvironmentId\n);\n```\nfindEnvironmentById, apps/webapp/app/models/runtimeEnvironment.server.ts:197-211:\n```js\nexport async function findEnvironmentById(id) {\n const environment = await $replica.runtimeEnvironment.findFirst({\n where: { id }, // no membership / org / project filter\n include: authIncludeWithParent,\n });\n if (!environment || environment.project.deletedAt !== null) return null;\n return toAuthenticated(environment);\n}\n```\nThe 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\u0027s 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).\n\n### PoC\nAs 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):\n```http\nPOST /resources/taskruns/\u003cown runParam\u003e/replay\nContent-Type: application/x-www-form-urlencoded\n\nenvironment=\u003cenvironment id of the victim org/project\u003e\u0026failedRedirect=/...\n```\nThe replay resolves the victim environment with no membership check and calls TriggerTaskService against it, creating a run in the victim tenant.\n\nLive-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:\n```\n[002 replay findEnvironmentById] attacker(orgA) supplied env_B_victim -\u003e {\"id\":\"env_B_victim\",\"organization_id\":\"orgB\",\"project_id\":\"proj_B\",\"deleted_at\":null}\nCROSS-TENANT: CONFIRMED (resolved another org\u0027s env with no membership check -\u003e replay would run there)\n```\nThe lookup returned another organization\u0027s 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.\n\n### Impact\nAn authenticated user can create task runs in another tenant\u0027s environment (including production), consuming that tenant\u0027s 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.\n\n### Remediation\nIn the replay action (or in ReplayTaskRunService), require the resolved target environment to belong to the source run\u0027s project/organization, for example assert `environment.projectId === existingTaskRun.projectId`, or re-validate the supplied environment id against the caller\u0027s membership (findEnvironmentBySlug with the validated projectId, or a members.some.userId filter), mirroring the constraint the loader already applies to the picker.",
"id": "GHSA-qxpp-qjg8-x4jv",
"modified": "2026-10-02T19:31:17Z",
"published": "2026-10-02T19:31:17Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/triggerdotdev/trigger.dev/security/advisories/GHSA-qxpp-qjg8-x4jv"
},
{
"type": "WEB",
"url": "https://github.com/triggerdotdev/trigger.dev/pull/4199"
},
{
"type": "WEB",
"url": "https://github.com/triggerdotdev/trigger.dev/commit/34b1a181c2a1d33a53ebab88f84b05f81fea4254"
},
{
"type": "PACKAGE",
"url": "https://github.com/triggerdotdev/trigger.dev"
},
{
"type": "WEB",
"url": "https://github.com/triggerdotdev/trigger.dev/releases/tag/v4.5.2"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L",
"type": "CVSS_V3"
}
],
"summary": "Trigger.dev: Run replay injects a task run into an attacker-chosen environment (cross-tenant write)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.