Skill Details
Runtime
(claude-5-sonnet)
Example user message
[userMessage]: Our pentest report flagged a Server Action and I do not fully understand the finding. This is the whole thing, in app/invoices/actions.ts: "use server" export async function markPaid(formData: FormData) { const id = formData.get("invoiceId") await prisma.invoice.update({ where: { id: id }, data: { paidAt: new Date() }, }) revalidatePath("/invoices") } The form that calls it only renders on the invoice detail page, and that page already redirects anyone who is not signed in. So I assumed the session check was covered. The report says any authenticated user can mark any invoice paid, including invoices belonging to other companies, by replaying the request with a different id. Is that actually true given the page guard? And if I add a check, does it go in the page or in the action? Next 15, App Router, Prisma, multi-tenant with an organizationId column on every table.
Example agent response
## Scope
app/invoices/actions.ts, one Server Action performing a write. The page guard on the invoice detail page is irrelevant to this file: it protects a render, not an endpoint. Yes, the finding is real.
## Findings
1. app/invoices/actions.ts — Server Actions rule 1. The action is a public HTTP endpoint. Next.js compiles it to a POST route with a generated action id. Anyone holding a session cookie can post to that id directly with curl. The page never runs, so the page guard never runs.
2. app/invoices/actions.ts — Server Actions rule 5. The write is scoped by invoice id alone. Nothing ties the row to the caller's organization, which is the cross-tenant part of the finding.
3. app/invoices/actions.ts — Server Actions rule 2. `formData.get` returns a string, a File, or null. That value goes straight into the query. A null reaches Prisma as a null filter.
4. app/invoices/actions.ts — Caching rule 2. `revalidatePath` on the whole invoices path drops more cache than this write touched.
## Patch
Authorization moves into the query itself rather than being a separate read-then-write, which would leave a window between the check and the update:
"use server"
import { z } from "zod"
import { revalidateTag } from "next/cache"
import { getSession } from "@/lib/session"
import { prisma } from "@/lib/prisma.server"
const MarkPaidInput = z.object({
invoiceId: z.string().uuid(),
})
export async function markPaid(formData: FormData) {
const session = await getSession()
if (!session) {
return { ok: false, error: "Not signed in." }
}
const parsed = MarkPaidInput.safeParse({
invoiceId: formData.get("invoiceId"),
})
if (!parsed.success) {
return { ok: false, error: "That invoice id is not valid." }
}
const result = await prisma.invoice.updateMany({
where: {
id: parsed.data.invoiceId,
organizationId: session.organizationId,
paidAt: null,
},
data: { paidAt: new Date() },
})
if (result.count === 0) {
return { ok: false, error: "Invoice not found or already paid." }
}
revalidateTag("invoices")
return { ok: true }
}
Three things changed beyond the session check. `updateMany` with the tenant column in the where clause makes authorization part of the write, so there is no gap between checking and updating. The `paidAt: null` condition makes a replayed request a no-op instead of a second payment record. And the error message is deliberately identical for a wrong id and another company's id, so the endpoint does not confirm which invoice numbers exist.
Add `import "server-only"` to lib/prisma.server.ts while you are in there. It is the rule that stops a future refactor from pulling the client into a browser bundle.
## Follow-ups
- Every other action in this codebase has the same shape. Grep for `formData.get` and check each one for a tenant column in the where clause. The pentest found one because they stopped at one.
- Tag the invoice fetches with the `invoices` tag, otherwise the revalidation call has nothing to invalidate.
- The form needs to render the returned error. Wire it through `useActionState` so a failed write is visible instead of silent.
- This review cannot confirm the fix. Replay the original curl request against a staging build with a second organization's invoice id and confirm you get the not-found result.
Or get it with Select10 Select prompts/mo — under $2 each
Learn moreBy purchasing this skill, you agree to our terms of service
CLAUDE-5-SONNET
Cursor rule pack for reviewing React and Next.js App Router code: server versus client component boundaries, use client placement, hydration mismatch sources, Server Action input validation, secret leakage across the RSC boundary, Content-Security-Policy nonce wiring, Suspense and streaming placement, revalidate and cache choices, and client bundle weight. It writes rules, findings, and patches only, and does not run your build, start a dev server, or deploy.
...more
Added 2 weeks ago
