{"schema":"threatline.record/2","record":{"id":"GHSA-qwrm-2cq8-xfr8","aliases":["GHSA-qwrm-2cq8-xfr8"],"summary":"Authorize parent runs before attaching batch-created waitpoints","details":"## Summary\r\n\r\nThe 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.\r\n\r\n## Affected code (HEAD 6bf731b)\r\n\r\nThree unscoped call sites, all reached from public authenticated API routes:\r\n\r\n| # | file:line | route |\r\n|---|---|---|\r\n| A | `apps/webapp/app/runEngine/services/createBatch.server.ts:128-140` | `POST /api/v3/batches` |\r\n| B | `apps/webapp/app/runEngine/services/batchTrigger.server.ts:190-197` | `POST /api/v2/tasks/batch` (small-batch arm) |\r\n| C | `apps/webapp/app/runEngine/services/batchTrigger.server.ts:281-288` | `POST /api/v2/tasks/batch` (large-batch arm) |\r\n\r\nEach does, verbatim:\r\n\r\n```ts\r\nif (body.parentRunId && body.resumeParentOnCompletion) {\r\n  await this._engine.blockRunWithCreatedBatch({\r\n    runId: RunId.fromFriendlyId(body.parentRunId), // raw, caller-supplied friendlyId\r\n    batchId: batch.id,\r\n    environmentId: environment.id,                 // the CALLER's env, not the run's\r\n    projectId: environment.projectId,\r\n    organizationId: environment.organizationId,\r\n  });\r\n}\r\n```\r\n\r\n`body.parentRunId` is never resolved against the caller's tenant. The id flows straight into the engine:\r\n\r\n- `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, ... })`.\r\n- `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.\r\n\r\n## Why the route guard does not stop it\r\n\r\nBoth routes call `canWriteParentRun(...)` before the service (`apps/webapp/app/utils/parentRunAuthorization.server.ts:5`). Its first branch:\r\n\r\n```ts\r\n// A type-level `write:runs` grant covers every run in the environment, so\r\n// resolving the parent could only confirm what we already know.\r\nif (ability.can(\"write\", { type: \"runs\" })) return true;\r\n```\r\n\r\nA 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.\r\n\r\n## Proof this is a bug, not design (sibling paths)\r\n\r\n1. **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:\r\n   ```ts\r\n   // `parentRunId` comes from the request body and isn't re-validated\r\n   // here, so confirm the parent run is in the caller's environment\r\n   // before wiring a waitpoint against it.\r\n   const parentRunInCallerEnv = await runStore.findRun(\r\n     { id: parentRunInternalId, runtimeEnvironmentId: request.environment.id }, ...);\r\n   if (!parentRunInCallerEnv) {\r\n     throw new ServiceValidationError(\"Parent run not found in the calling environment\", 404);\r\n   }\r\n   ```\r\n   This is the same fix, applied to one path and missed on the batch-create paths.\r\n2. 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.\r\n3. 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.\r\n\r\n## Impact\r\n\r\n`POST /api/v3/batches` with `{\"runCount\":1,\"parentRunId\":\"run_<victim>\",\"resumeParentOnCompletion\":true}`:\r\n\r\n- 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).\r\n- 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**.\r\n- On the v2 paths (B/C) there is **no** `scheduleExpireBatch`, so a never-sealed batch leaves the foreign run blocked with no reaper (availability).\r\n- `blockRunWithWaitpoint` has no terminal-state guard, so a completed run can be pushed back to a `SUSPENDED` snapshot.\r\n- A nonexistent `parentRunId` throws (500) versus a real one (202): a cross-tenant existence oracle.\r\n\r\n## Preconditions\r\n\r\n- 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).\r\n- **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).","categories":["Frameworks"],"product":"trigger.dev","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/triggerdotdev/trigger.dev/security/advisories/GHSA-qwrm-2cq8-xfr8","source_modified_at":"2026-10-02T04:29:27Z","fetched_at":"2026-10-23T22:01:05.715934Z","package":{"name":"trigger.dev","ecosystem":null},"range":"<= 4.7.0"}],"fixed_versions":[],"cvss":8.1,"severity":"High","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-02T04:29:27Z","modified_at":"2026-10-02T04:29:27Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/triggerdotdev/trigger.dev/security/advisories/GHSA-qwrm-2cq8-xfr8","fetched_at":"2026-10-23T22:01:05.715934Z","source_modified_at":"2026-10-02T04:29:27Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":"product","confidence":"heuristic","reviewed":false},"change_timestamp":"2026-10-02T04:29:27Z","content_hash":"a3cc8b8361fc52bdc82be33ce92365e59a170e49f44522cbb7f6ca4ef2e1d8b0"}}
