Skip to content

Authorize parent runs before attaching batch-created waitpoints

High
carderne published GHSA-qwrm-2cq8-xfr8 Oct 2, 2026

Package

No package listed

Affected versions

<= 4.7.0

Patched versions

>= 4.7.0

Description

Summary

The 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.

Affected code (HEAD 6bf731b)

Three unscoped call sites, all reached from public authenticated API routes:

# file:line route
A apps/webapp/app/runEngine/services/createBatch.server.ts:128-140 POST /api/v3/batches
B apps/webapp/app/runEngine/services/batchTrigger.server.ts:190-197 POST /api/v2/tasks/batch (small-batch arm)
C apps/webapp/app/runEngine/services/batchTrigger.server.ts:281-288 POST /api/v2/tasks/batch (large-batch arm)

Each does, verbatim:

if (body.parentRunId && body.resumeParentOnCompletion) {
  await this._engine.blockRunWithCreatedBatch({
    runId: RunId.fromFriendlyId(body.parentRunId), // raw, caller-supplied friendlyId
    batchId: batch.id,
    environmentId: environment.id,                 // the CALLER's env, not the run's
    projectId: environment.projectId,
    organizationId: environment.organizationId,
  });
}

body.parentRunId is never resolved against the caller's tenant. The id flows straight into the engine:

  • 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, ... }).
  • 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.

Why the route guard does not stop it

Both routes call canWriteParentRun(...) before the service (apps/webapp/app/utils/parentRunAuthorization.server.ts:5). Its first branch:

// A type-level `write:runs` grant covers every run in the environment, so
// resolving the parent could only confirm what we already know.
if (ability.can("write", { type: "runs" })) return true;

A 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.

Proof this is a bug, not design (sibling paths)

  1. 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:
    // `parentRunId` comes from the request body and isn't re-validated
    // here, so confirm the parent run is in the caller's environment
    // before wiring a waitpoint against it.
    const parentRunInCallerEnv = await runStore.findRun(
      { id: parentRunInternalId, runtimeEnvironmentId: request.environment.id }, ...);
    if (!parentRunInCallerEnv) {
      throw new ServiceValidationError("Parent run not found in the calling environment", 404);
    }
    This is the same fix, applied to one path and missed on the batch-create paths.
  2. 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.
  3. 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.

Impact

POST /api/v3/batches with {"runCount":1,"parentRunId":"run_<victim>","resumeParentOnCompletion":true}:

  • 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).
  • 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.
  • On the v2 paths (B/C) there is no scheduleExpireBatch, so a never-sealed batch leaves the foreign run blocked with no reaper (availability).
  • blockRunWithWaitpoint has no terminal-state guard, so a completed run can be pushed back to a SUSPENDED snapshot.
  • A nonexistent parentRunId throws (500) versus a real one (202): a cross-tenant existence oracle.

Preconditions

  • 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).
  • 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).

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
None
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:N/I:H/A:H

CVE ID

No known CVE

Weaknesses

No CWEs

Credits