SYS://FIELD-REPORT-06
You Wrote req.body Straight to the Database
The forms validated. The error messages were polite. Type a bad email and the field turned red before you could submit. It looked like an app that took input seriously — and it took input so seriously that it wrote every byte of it straight into the database, no questions asked. Including the byte that says who’s an admin.
This is a representative audit — the same shape I hit over and over on apps built with Claude Code, Cursor, Bolt, and v0. Names and paths are changed, but every finding is real-shaped and drawn from the kind of report the launchworthy skill produces. Call the app ledgerly: a Remix + Prisma expense tracker, built with Claude Code, Postgres behind it, about to onboard its first paying team.
The frontend was careful. The backend trusted everyone.
Zero domains passing does not mean ledgerly is a bad app. It’s a nice app. It means it’s finished-looking, which is the exact state in which people invite a client and start entering real money.
And I want to be honest about the severity up front, because it’s the interesting part: the worst finding here is not a [CRITICAL]. It’s a [HIGH]. Nobody’s anon key is leaking, no service_role is in the bundle. The thing that ends ledgerly is quieter than that, and it’s sitting in a place a demo will never reveal.
The finding that ends the app: mass assignment
Here’s the create-expense action, near enough to verbatim:
// app/routes/expenses.new.tsx:18
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const data = Object.fromEntries(form);
const expense = await prisma.expense.create({ data });
return redirect(`/expenses/${expense.id}`);
}
Object.fromEntries(form) takes whatever the request contains and hands the whole object to Prisma. On the happy path the form sends amount, note, category. Fine. But the request is not the form. The request is whatever the attacker decides to send.
Nothing here says which fields are allowed. So if the Expense model — or a relation Prisma will happily connect — carries a field the server was supposed to own, the client can now set it. Add userId to the payload and you write an expense onto someone else’s account. Worse, the same pattern was on the profile-update action, where the model had an isAdmin column. One extra field in a curl, and the user is staff.
CRITICAL · CAUGHT
req.body written straight to Prisma — mass assignment on every mutation
No mutating handler in ledgerly validates its input. Object.fromEntries(form) flows straight into prisma.create / prisma.update, so the client controls every column the model exposes — including userId and isAdmin. An attacker doesn’t need a form field for it; they add the key to the request. This is [HIGH]: no input validation on mutating entry points, and business rules enforced only client-side.
The severity is [HIGH], not [CRITICAL], and I’m not going to inflate it for drama. But [HIGH] in this checklist means fix this week — before real users, not after. Two [HIGH]s stack here: no validation anywhere, and a permission decision (isAdmin) that the code only ever “enforces” by not rendering the checkbox. Not rendering a checkbox is not enforcement. It’s decoration.
The fix is not clever, and that’s the point: validate on the server, whitelist the shape, and never spread an untrusted object into your ORM.
THE FIX
Define the exact shape you accept with Zod, parse at the top of every mutating handler, and destructure only the fields you named. Reject everything else. userId comes from the session, never the body. Fields like isAdmin are simply not in the schema, so there is no way for the client to reach them.
// app/routes/expenses.new.tsx
import { z } from "zod";
const CreateExpense = z.object({
amount: z.coerce.number().positive().max(1_000_000),
note: z.string().max(500).optional(),
category: z.enum(["travel", "meals", "software", "other"]),
});
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request); // from session, not body
const form = Object.fromEntries(await request.formData());
const parsed = CreateExpense.safeParse(form);
if (!parsed.success) {
return json({ errors: parsed.error.flatten() }, { status: 400 });
}
const expense = await prisma.expense.create({
data: { ...parsed.data, userId }, // only named fields + trusted owner
});
return redirect(`/expenses/${expense.id}`);
}Whitelist, don’t blacklist. You define what’s allowed and drop the rest, rather than trying to strip fields you can think of. isAdmin never appears because you never invited it.
The false alarm: “but the frontend already validates it”
This is the objection that kills the fix, and it sounds completely reasonable. The form does validate. There’s a Zod resolver on the client, red fields, the works. So why do the same work twice?
Because the two “validations” defend against entirely different things, and only one of them is a security control.
FALSE POSITIVE
'The frontend validates the input, so the server doesn't need to.'
This is wrong, and it’s the tell of someone thinking about typos instead of attackers. Client-side validation is a UX feature — it stops honest users fat-fingering an email. It runs in a browser the attacker fully controls and can simply skip.
The attacker does not use your frontend. They open DevTools and edit the request, or they never load your page at all — they curl the endpoint directly:
curl -X POST https://ledgerly.app/expenses/new \
-d 'amount=5&category=other&userId=SOMEONE_ELSE&isAdmin=true'Your React form never runs. Your resolver never runs. The only code between that request and your database is the server handler — and in ledgerly, that handler validated nothing. Client validation is a nicety; the server is the only place that counts.
The distinction is the whole game:
- client validation → UX. Catches mistakes for people acting in good faith. Trivially bypassed.
- server validation → security. The one gate the attacker cannot route around, because every request funnels through it.
You want both. But if you only get one, keep the server one. It’s the only one that’s ever protected anything.
The fix, in order
THE FIX · IN ORDER
Harden every mutating handler
- Grep for the leak. Find every handler that reads
formData()/request.json()/req.bodyand uses it without a schema. In Remix that’s everyaction; in a Remix resource route it’s thePOST/PUT/PATCH/DELETEhandlers. - Add a Zod schema per handler, defining the exact fields you accept.
safeParseat the top; return400with the flattened issues on failure. - Move trusted fields out of the body.
userIdfrom the session,isAdminnever from input at all. Destructure only named fields into Prisma — never spread the raw object. - Enforce the rule server-side. Any permission or pricing decision that currently lives in the component gets re-checked in the handler, because the component is editable in DevTools.
Verify it by hand
Send a malformed and a hostile payload with curl — a negative amount, an extra userId, isAdmin=true. Confirm you get a 400 with validation issues, not a 500 with a stack trace and not a 200 that quietly wrote the row. Then check the database: the admin flag should be exactly what you set it to server-side, untouched by the request. That empty result is your evidence.
The rest of the punch list
The mass-assignment fix is the blocker. Two more Backend & Data findings ride along in the same handlers, and they’re cheap to close while you’re already in there:
FIX THIS WEEK
After the validation fix
- [HIGH] Raw error objects returned to the client. A failed Prisma call in
app/routes/expenses.$id.tsxbubbled the exception straight into the response, leaking table names, column names, and the query. Catch it, log the real error server-side, return a generic shape. Never echo the stack trace. - [MEDIUM] Everything returns
200. Bad input, not-found, and server errors all came back200 OKwith an error blob in the body. Return meaningful status codes —400for bad input,404for missing,500for server faults — so clients, monitoring, and you can tell success from failure.
Three Backend findings, ranked. One of them lets a stranger become an admin; two are the same afternoon’s work once you’re already editing those handlers. That ranking is the point — worst-first, so you fix the thing that ends the business before the thing that annoys a linter.
ASK YOURSELF
“It works fine for me.” Of course it does. You’re one user on fast wifi with a warm cache and no malice — you fill the form the way the form wants to be filled. Production is a hundred strangers on bad connections, one of whom is curious what happens if they add isAdmin=true to the request. “Works for me” is the starting line, not the finish.
Why this keeps happening
None of this means Claude Code did something foolish, or that ledgerly’s builder was careless. AI builders are extraordinary at getting you to looks finished, and a form with client-side validation looks finished. It compiles, it’s polite, it turns fields red at the right moments. The gap between that and safe is invisible from the driver’s seat, because you’re the honest user the client validation was written for. You never send the hostile request. Why would you?
That gap is why launchworthy exists. It plays bouncer at the door of production: it reads every mutating handler, knows that a red-fielded form is UX and not a security boundary, refuses to mark input “validated” when the only validator runs in a browser the attacker owns, and hands you the schema, not just the scolding. The forms validating was never the question. The question was what happens when someone skips the form — and on your own screen, logged in as yourself, nobody ever does.