Mass assignment vulnerabilities in AI-generated API endpoints
By Seaworthy · June 4, 2026
Photo by Mathyas Kurmann on Unsplash
In 2012, a researcher named Egor Homakov submitted a pull request to the Rails project on GitHub. He didn't change any code. Instead, he exploited a mass assignment flaw in GitHub itself to add his SSH key directly to the Rails repository, bypassing all normal access controls. The root cause: GitHub was passing user-submitted form data straight into an ActiveRecord update_attributes call without filtering it first. Homakov elevated his own privileges by simply including public_key[user_id]= in the request body.
That was 2012. The pattern is still everywhere.
How LLMs reproduce this bug
Ask any AI coding assistant to scaffold a user profile update endpoint. In Express with Prisma or TypeORM, you'll get something like this:
// What AI gives you
app.put('/users/:id', async (req, res) => {
const user = await prisma.user.update({
where: { id: req.params.id },
data: req.body, // <-- every field in the request body goes to the DB
});
res.json(user);
});
This passes the entire request body into the ORM update call. If your User table has an isAdmin column, or a role column, or a subscriptionTier column, an attacker sends { "isAdmin": true } and they're an admin. No SQL injection, no session hijacking, no exploit kit. Just a JSON body with an extra field.
The reason AI produces this pattern isn't negligence — it's pattern completion. The training data is full of tutorials that use req.body directly because it's concise, it runs, and it demonstrates the ORM feature being taught. Security properties aren't the point of those tutorials. The model learned from them anyway.
The fix: allowlist validation with Zod
The correct approach is to define exactly what fields you'll accept, validate them, and pass only the validated subset to the ORM. Zod is the most ergonomic way to do this in TypeScript:
import { z } from 'zod';
import { Request, Response } from 'express';
// Only these fields can be updated via this endpoint
const UpdateProfileSchema = z.object({
displayName: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
avatarUrl: z.string().url().optional(),
});
type UpdateProfileDTO = z.infer<typeof UpdateProfileSchema>;
app.put('/users/:id', async (req: Request, res: Response) => {
const parseResult = UpdateProfileSchema.safeParse(req.body);
if (!parseResult.success) {
return res.status(400).json({ errors: parseResult.error.flatten() });
}
// parseResult.data contains ONLY the allowlisted fields
const user = await prisma.user.update({
where: { id: req.params.id },
data: parseResult.data,
});
res.json(user);
});
Zod's safeParse strips unknown fields by default. Even if the caller sends { "displayName": "Alice", "isAdmin": true }, the isAdmin field simply doesn't appear in parseResult.data. The ORM never sees it.
This is the DTO (Data Transfer Object) pattern. The schema is not just type information — it's a security boundary.
Validation layer vs ORM layer
Some ORMs let you define field-level guards, and some developers treat that as the primary protection. I think that's the wrong instinct. Stripping unknown fields at the ORM layer is useful as a defence-in-depth measure, but it's the wrong place to own this responsibility.
Here's why: your ORM model has a God's-eye view of the schema. Your endpoint has a specific, limited contract with one caller for one purpose. The allowlist should live at the endpoint level, because different endpoints have different contracts over the same entity. An admin API that genuinely can update role should have a different schema than the user self-service endpoint that cannot.
If you push the filtering logic into the ORM model, you either end up with a single overly-permissive model or you start sprinkling conditional logic that's hard to audit.
class-validator if you're in NestJS
If your project uses NestJS, the ecosystem convention is class-validator with the ValidationPipe. The mental model is the same: define what you accept, reject everything else.
import { IsOptional, IsString, MaxLength, IsUrl } from 'class-validator';
import { Body, Put, Param, UsePipes, ValidationPipe } from '@nestjs/common';
export class UpdateProfileDto {
@IsOptional()
@IsString()
@MaxLength(100)
displayName?: string;
@IsOptional()
@IsString()
@MaxLength(500)
bio?: string;
@IsOptional()
@IsUrl()
avatarUrl?: string;
}
@Put(':id')
@UsePipes(new ValidationPipe({ whitelist: true }))
async updateProfile(
@Param('id') id: string,
@Body() dto: UpdateProfileDto,
) {
return this.usersService.update(id, dto);
}
The { whitelist: true } option on ValidationPipe is the critical part. Without it, the pipe validates the declared fields but passes unknown ones through anyway. whitelist: true strips them.
Testing this in Postman
Verifying the protection is simple. Create a Postman collection with two requests against your update endpoint:
- A legitimate request with only the permitted fields. Expect
200. - An attack request that adds
"isAdmin": true(or"role": "admin", or whatever privileged field exists on your model). Expect200— but then fetch the user and verifyisAdminwas not changed.
The second test is the one that matters. A 400 response would also be acceptable if your schema uses strict() mode, but the non-negotiable outcome is that the privileged field must not be persisted. Test the side effect, not just the status code.
A positive test case for the vulnerability would look like this in Postman's test tab:
pm.test('isAdmin field not persisted', function () {
const user = pm.response.json();
pm.expect(user.isAdmin).to.equal(false);
});
Run this against your endpoint before and after adding the DTO schema. If it fails before and passes after, your fix is real.
Mass assignment is one of those bugs that feels embarrassing in retrospect, but the AI-generated code problem makes it freshly relevant: models that learned from tutorial code will keep producing it. Seaworthy flags this pattern automatically.
This article was generated by AI and summarises publicly available sources.
Seaworthy scans your repo for the issues covered in articles like this one.
Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.