GHSA-4672-HWV6-GQ62
Vulnerability from github – Published: 2026-10-02 22:42 – Updated: 2026-10-02 22:42Summary
Trigger.dev isolates each project into multiple environments (dev, staging, prod, and per-PR preview branches), each with its own secret API key — the environment is a trust boundary (a dev/preview/CI key is lower-trust than a prod key). Most API routes enforce this by scoping resource lookups to the authenticated key's environment (where: { friendlyId, runtimeEnvironmentId: auth.environment.id }).
The deployment cancel path does not. DeploymentService.getDeployment() scopes the lookup by projectId only — never environmentId — so a secret key for any environment in a project can cancel a deployment belonging to any other environment of the same project, including production. The deployment GET route, by contrast, is env-scoped — so the same key that is 404'd when trying to read a prod deployment can nonetheless cancel it. That asymmetry is the bug.
Affected
apps/webapp, HEAD 5d99457 (current main). Affects self-hosted and cloud.
Root cause
apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts authenticates to an environment and calls deploymentService.cancelDeployment(authenticatedEnv, deploymentId, ...).
apps/webapp/app/v3/services/deployment.server.ts:
public cancelDeployment(authenticatedEnv: Pick<AuthenticatedEnvironment,"projectId">, friendlyId, ...) {
return this.getDeployment(authenticatedEnv.projectId, friendlyId) // projectId only
.andThen(validateDeployment) // rejects only FINAL statuses
.andThen(cancelDeployment); // updateMany -> status CANCELED
}
private getDeployment(projectId: string, friendlyId: string) {
return this._prisma.workerDeployment.findFirst({
where: { friendlyId, projectId }, // <-- NO environmentId filter
});
}
cancelDeployment accepts authenticatedEnv but its type is literally Pick<AuthenticatedEnvironment,"projectId"> — it discards the environment identity. validateDeployment blocks only FINAL_DEPLOYMENT_STATUSES, so any in-progress deployment (PENDING/INSTALLING/BUILDING/DEPLOYING) is cancellable.
Contrast — the correctly env-scoped sibling api.v1.deployments.$deploymentId.ts (GET):
const deployment = await prisma.workerDeployment.findFirst({
where: { friendlyId: deploymentId, environmentId: authenticatedEnv.id }, // env-scoped
});
Reads are env-scoped; the cancel mutation is not. The same getDeployment(authenticatedEnv.projectId, …) helper also backs the deployment progress methods (deployment.server.ts:172,329), so the projectId-only scope is a small class.
Runtime PoC (proven on the self-host stack)
Seeded one project with a prod env (key tr_prod_…) and a dev env (key tr_dev_…) and one WorkerDeployment (deployment_pocvictim, status DEPLOYING) in the prod env. As the dev key:
status BEFORE : DEPLOYING
GET /api/v1/deployments/deployment_pocvictim (dev key) -> 404 (env-scoped read DENIES it)
POST /api/v1/deployments/deployment_pocvictim/cancel (dev key) -> 204
status AFTER : CANCELED (prod deploy canceled by the dev key)
CONTROL: same cancel with a DIFFERENT project's key -> 404 (projectId scope blocks cross-project)
The dev key cannot read the prod deployment (404) yet cancels it (204 → CANCELED); a different project's key is correctly 404'd — so the gap is precisely cross-environment within a project.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 4.5.5"
},
"package": {
"ecosystem": "npm",
"name": "trigger.dev"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.5.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-639"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-02T22:42:40Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\n\nTrigger.dev isolates each project into multiple **environments** (`dev`, `staging`, `prod`, and per-PR `preview` branches), each with its **own secret API key** \u2014 the environment is a trust boundary (a `dev`/preview/CI key is lower-trust than a `prod` key). Most API routes enforce this by scoping resource lookups to the authenticated key\u0027s environment (`where: { friendlyId, runtimeEnvironmentId: auth.environment.id }`).\n\nThe deployment **cancel** path does not. `DeploymentService.getDeployment()` scopes the lookup by **`projectId` only** \u2014 never `environmentId` \u2014 so a secret key for *any* environment in a project can cancel a deployment belonging to *any other* environment of the same project, including **production**. The deployment **GET** route, by contrast, *is* env-scoped \u2014 so the same key that is **404\u0027d when trying to read** a prod deployment can nonetheless **cancel** it. That asymmetry is the bug.\n\n### Affected\n\n`apps/webapp`, HEAD `5d99457` (current `main`). Affects self-hosted and cloud.\n\n### Root cause\n\n`apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts` authenticates to an environment and calls `deploymentService.cancelDeployment(authenticatedEnv, deploymentId, ...)`.\n\n`apps/webapp/app/v3/services/deployment.server.ts`:\n```ts\npublic cancelDeployment(authenticatedEnv: Pick\u003cAuthenticatedEnvironment,\"projectId\"\u003e, friendlyId, ...) {\n return this.getDeployment(authenticatedEnv.projectId, friendlyId) // projectId only\n .andThen(validateDeployment) // rejects only FINAL statuses\n .andThen(cancelDeployment); // updateMany -\u003e status CANCELED\n}\nprivate getDeployment(projectId: string, friendlyId: string) {\n return this._prisma.workerDeployment.findFirst({\n where: { friendlyId, projectId }, // \u003c-- NO environmentId filter\n });\n}\n```\n\n`cancelDeployment` accepts `authenticatedEnv` but its type is literally `Pick\u003cAuthenticatedEnvironment,\"projectId\"\u003e` \u2014 it discards the environment identity. `validateDeployment` blocks only `FINAL_DEPLOYMENT_STATUSES`, so any in-progress deployment (`PENDING`/`INSTALLING`/`BUILDING`/`DEPLOYING`) is cancellable.\n\n**Contrast \u2014 the correctly env-scoped sibling** `api.v1.deployments.$deploymentId.ts` (GET):\n```ts\nconst deployment = await prisma.workerDeployment.findFirst({\n where: { friendlyId: deploymentId, environmentId: authenticatedEnv.id }, // env-scoped\n});\n```\nReads are env-scoped; the cancel mutation is not. The same `getDeployment(authenticatedEnv.projectId, \u2026)` helper also backs the deployment *progress* methods (`deployment.server.ts:172,329`), so the projectId-only scope is a small class.\n\n### Runtime PoC (proven on the self-host stack)\n\nSeeded one project with a `prod` env (key `tr_prod_\u2026`) and a `dev` env (key `tr_dev_\u2026`) and one `WorkerDeployment` (`deployment_pocvictim`, status `DEPLOYING`) in the **prod** env. As the **dev** key:\n\n```\nstatus BEFORE : DEPLOYING\nGET /api/v1/deployments/deployment_pocvictim (dev key) -\u003e 404 (env-scoped read DENIES it)\nPOST /api/v1/deployments/deployment_pocvictim/cancel (dev key) -\u003e 204\nstatus AFTER : CANCELED (prod deploy canceled by the dev key)\nCONTROL: same cancel with a DIFFERENT project\u0027s key -\u003e 404 (projectId scope blocks cross-project)\n```\n\nThe dev key cannot *read* the prod deployment (404) yet *cancels* it (204 \u2192 CANCELED); a different project\u0027s key is correctly 404\u0027d \u2014 so the gap is precisely cross-environment within a project.",
"id": "GHSA-4672-hwv6-gq62",
"modified": "2026-10-02T22:42:40Z",
"published": "2026-10-02T22:42:40Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/triggerdotdev/trigger.dev/security/advisories/GHSA-4672-hwv6-gq62"
},
{
"type": "WEB",
"url": "https://github.com/triggerdotdev/trigger.dev/pull/4316"
},
{
"type": "WEB",
"url": "https://github.com/triggerdotdev/trigger.dev/commit/6997aeb05e27d2db47f9eda01fdc8a17c81a1ae0"
},
{
"type": "PACKAGE",
"url": "https://github.com/triggerdotdev/trigger.dev"
},
{
"type": "WEB",
"url": "https://github.com/triggerdotdev/trigger.dev/releases/tag/v4.5.6"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L",
"type": "CVSS_V3"
}
],
"summary": "Trigger.dev: Cross-environment deployment cancel"
}
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.