Skip to content

Commit ecc8ffe

Browse files
committed
fix(webapp): require user auth + org-membership on replay action
The `action` handler in resources.taskruns.$runParam.replay.ts had no `requireUser`/`requireUserId` call and the underlying PG findFirst was keyed only on `friendlyId` — any request with a valid runParam could POST a replay against any run. The buffered fallback inherited the same gap. Mirrors the canonical pattern from resources.taskruns.$runParam.cancel.ts: - `await requireUser(request)` at the top of the action. - PG findFirst scoped by `project.organization.members.some.userId`. - Buffered path verifies org membership via `orgMember.findFirst` against the snapshot's orgId before synthesising the TaskRun. Devin-follow-up on PR #3757 (🚩 finding on commit bc6de3e). Surfaces as a pre-existing PG-side auth gap that the new buffered surface would have extended. Target PR: phase-3-dashboard.
1 parent 30181d9 commit ecc8ffe

1 file changed

Lines changed: 32 additions & 0 deletions

File tree

apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
247247
}
248248

249249
export const action: ActionFunction = async ({ request, params }) => {
250+
// Dashboard auth: identical pattern to resources.taskruns.$runParam.cancel.ts.
251+
// The loader above this action already gates with `requireUser`, but
252+
// Remix's action runs independently — without this call any request
253+
// with a valid runParam could submit a replay. The PG findFirst below
254+
// also adds the org-membership filter so a PAT can't replay another
255+
// org's run, and the buffered fallback verifies org membership via
256+
// orgMember.findFirst against the snapshot's orgId.
257+
const user = await requireUser(request);
258+
const userId = user.id;
250259
const { runParam } = ParamSchema.parse(params);
251260

252261
const formData = await request.formData();
@@ -260,6 +269,15 @@ export const action: ActionFunction = async ({ request, params }) => {
260269
const pgRun = await prisma.taskRun.findFirst({
261270
where: {
262271
friendlyId: runParam,
272+
project: {
273+
organization: {
274+
members: {
275+
some: {
276+
userId,
277+
},
278+
},
279+
},
280+
},
263281
},
264282
include: {
265283
runtimeEnvironment: {
@@ -285,6 +303,20 @@ export const action: ActionFunction = async ({ request, params }) => {
285303
const buffer = getMollifierBuffer();
286304
const entry = buffer ? await buffer.getEntry(runParam) : null;
287305
if (entry) {
306+
// Same org-membership gate as the PG path above. Without this
307+
// any authenticated user who knows a runId could replay the
308+
// buffered run across orgs.
309+
const member = await prisma.orgMember.findFirst({
310+
where: { userId, organizationId: entry.orgId },
311+
select: { id: true },
312+
});
313+
if (!member) {
314+
return redirectWithErrorMessage(
315+
submission.value.failedRedirect,
316+
request,
317+
"Run not found"
318+
);
319+
}
288320
const synthetic = await findRunByIdWithMollifierFallback({
289321
runId: runParam,
290322
environmentId: entry.envId,

0 commit comments

Comments
 (0)