Summary
POST /engine/v1/runs/:runFriendlyId/waitpoints/tokens/:waitpointFriendlyId/wait validates that the
waitpoint belongs to the caller's environment, but never validates the run. The run id is
decoded straight from the URL and handed to the engine.
Any holder of an ordinary secret API key can therefore block any run in any organization on a
waitpoint that exists only in the attacker's environment — a waitpoint the victim cannot see, list
or complete.
The route's sibling in the same directory does this correctly, which is what makes this an
oversight rather than a design decision.
Details
apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts:
const waitpointId = WaitpointId.toId(params.waitpointFriendlyId);
const runId = RunId.toId(params.runFriendlyId); // :23 pure string decode, no lookup
try {
//check permissions // :26 the comment says permissions...
const waitpoint = await resolveWaitpointThroughReadThrough({
waitpointId,
environmentId: authentication.environment.id,
read: (client) => client.waitpoint.findFirst({
where: { id: waitpointId, environmentId: authentication.environment.id }, // :33-36
}),
});
if (!waitpoint) { throw json({ error: "Waitpoint not found" }, { status: 404 }); }
const _result = await engine.blockRunWithWaitpoint({
runId, // :52 the victim's, never checked
waitpoints: [waitpointId],
projectId: authentication.environment.project.id, // :54 the ATTACKER's project
organizationId: authentication.environment.organization.id,
});
RunId.toId is a pure friendlyId→id string transform. The //check permissions block at :26-38
covers only the waitpoint, which the attacker legitimately owns.
Nothing downstream re-checks. engine.blockRunWithWaitpoint
(internal-packages/run-engine/src/engine/index.ts:2050-2085) forwards its arguments unchanged to
waitpointSystem.blockRunWithWaitpoint, which does:
let snapshot = await getLatestExecutionSnapshot(prisma, runId, this.$.runStore); // :283-287
...
const { pendingCount } = await this.coordinator.registerBlocks({
runId, waitpointIds: $waitpoints, projectId, ... // :292-300
});
getLatestExecutionSnapshot (executionSnapshotSystem.ts:208-217) takes an optional
environmentId and only scopes when it is supplied:
export async function getLatestExecutionSnapshot(
prisma, runId, runStore?, environmentId? // :208-212
) {
... prisma.taskRunExecutionSnapshot.findFirst({
where: { runId, isValid: true, ...(environmentId ? { environmentId } : {}) }, // :217
This call site omits it. The parameter exists precisely because this scoping is needed elsewhere —
it is simply not passed here.
The guarded sibling is the adjacent file.
apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.ts makes the same
engine.blockRunWithWaitpoint call and first does:
const run = await runStore.findRun(
{ id: runId, runtimeEnvironmentId: authentication.environment.id }, // :28-34
prisma
);
if (!run) {
throw new Response("You don't have permissions for this run", { status: 401 }); // :36-38
}
...
const _waitResult = await engine.blockRunWithWaitpoint({ runId: run.id, ... }); // :56-57
Two routes, same directory, same engine call. One resolves the run scoped by
runtimeEnvironmentId and returns 401 "You don't have permissions for this run"; the other
passes the raw URL parameter. The sibling's error string states the invariant this route breaks.
allowJWT is false on this route (apiBuilder.server.ts), so a public token will not reach it — a
plain secret key from any self-signup environment will.
PoC
Verified by driving the real RunEngine against real Postgres and Redis containers through the
project's own @internal/testcontainers harness — the same containerTest /
setupAuthenticatedEnvironment / setupBackgroundWorker fixtures your 12 existing waitpoint tests
use. Nothing is copied or reimplemented: the test imports RunEngine and setup.ts from the repo.
Two organizations, each with its own project and environment.
=== CONTROL — the check the adjacent route performs ===
findRun({id: victimRun, runtimeEnvironmentId: attackerEnv}) -> null => sibling route returns 401
=== EXPLOIT — what the waitpoint-token wait route does ===
engine.blockRunWithWaitpoint({runId: VICTIM, projectId: ATTACKER}) -> ok
=== RESULT ===
victim run execution status : EXECUTING_WITH_WAITPOINTS
TaskRunWaitpoint row exists : true
...its projectId : cmtcvx68l000b5d1w930ir951 <- the ATTACKER's project
attacker projectId : cmtcvx68l000b5d1w930ir951
victim projectId : cmtcvx68e00045d1w59vkv2gc
>>> another org's run is blocked on a waitpoint it cannot see, list or complete
vitest: 1 passed (1)
The CONTROL line is the exact lookup wait.duration.ts:28-38 performs before calling the same
engine method. It returns null across tenants — which is why that route answers
401 "You don't have permissions for this run". The EXPLOIT line is the call
...waitpoints.tokens.$waitpointFriendlyId.wait.ts:51-56 makes with the run id decoded straight
from the URL at :23.
Over HTTP the equivalent is:
POST /engine/v1/runs/run_VICTIM/waitpoints/tokens/waitpoint_ATTACKER/wait
Authorization: Bearer tr_prod_<attacker key>
-> 200 {"success": true}
POST /engine/v1/runs/run_VICTIM/wait/duration # adjacent route, same attacker
-> 401 "You don't have permissions for this run"
Run ids are not secrets in this product. They appear in dashboard URLs, SDK and CI logs,
trigger() responses shared between teams, and inside public access tokens handed to browsers.
The test file is included with this report and drops into
internal-packages/run-engine/src/engine/tests/.
Impact
As an attacker holding only a free-tier secret API key for my own environment, I could freeze any
other organization's task run indefinitely — it blocks on a waitpoint that exists only in my
environment, has no timeout, and that the victim has no API surface to discover or clear. Recovery
requires operator intervention.
Summary
POST /engine/v1/runs/:runFriendlyId/waitpoints/tokens/:waitpointFriendlyId/waitvalidates that thewaitpoint belongs to the caller's environment, but never validates the run. The run id is
decoded straight from the URL and handed to the engine.
Any holder of an ordinary secret API key can therefore block any run in any organization on a
waitpoint that exists only in the attacker's environment — a waitpoint the victim cannot see, list
or complete.
The route's sibling in the same directory does this correctly, which is what makes this an
oversight rather than a design decision.
Details
apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.waitpoints.tokens.$waitpointFriendlyId.wait.ts:RunId.toIdis a pure friendlyId→id string transform. The//check permissionsblock at:26-38covers only the waitpoint, which the attacker legitimately owns.
Nothing downstream re-checks.
engine.blockRunWithWaitpoint(
internal-packages/run-engine/src/engine/index.ts:2050-2085) forwards its arguments unchanged towaitpointSystem.blockRunWithWaitpoint, which does:getLatestExecutionSnapshot(executionSnapshotSystem.ts:208-217) takes an optionalenvironmentIdand only scopes when it is supplied:This call site omits it. The parameter exists precisely because this scoping is needed elsewhere —
it is simply not passed here.
The guarded sibling is the adjacent file.
apps/webapp/app/routes/engine.v1.runs.$runFriendlyId.wait.duration.tsmakes the sameengine.blockRunWithWaitpointcall and first does:Two routes, same directory, same engine call. One resolves the run scoped by
runtimeEnvironmentIdand returns 401 "You don't have permissions for this run"; the otherpasses the raw URL parameter. The sibling's error string states the invariant this route breaks.
allowJWTis false on this route (apiBuilder.server.ts), so a public token will not reach it — aplain secret key from any self-signup environment will.
PoC
Verified by driving the real
RunEngineagainst real Postgres and Redis containers through theproject's own
@internal/testcontainersharness — the samecontainerTest/setupAuthenticatedEnvironment/setupBackgroundWorkerfixtures your 12 existing waitpoint testsuse. Nothing is copied or reimplemented: the test imports
RunEngineandsetup.tsfrom the repo.Two organizations, each with its own project and environment.
The CONTROL line is the exact lookup
wait.duration.ts:28-38performs before calling the sameengine method. It returns
nullacross tenants — which is why that route answers401 "You don't have permissions for this run". The EXPLOIT line is the call
...waitpoints.tokens.$waitpointFriendlyId.wait.ts:51-56makes with the run id decoded straightfrom the URL at
:23.Over HTTP the equivalent is:
Run ids are not secrets in this product. They appear in dashboard URLs, SDK and CI logs,
trigger()responses shared between teams, and inside public access tokens handed to browsers.The test file is included with this report and drops into
internal-packages/run-engine/src/engine/tests/.Impact
As an attacker holding only a free-tier secret API key for my own environment, I could freeze any
other organization's task run indefinitely — it blocks on a waitpoint that exists only in my
environment, has no timeout, and that the victim has no API surface to discover or clear. Recovery
requires operator intervention.