Mass Assignment in AI-Generated Python APIs
AI assistants write FastAPI endpoints that blindly map request bodies to database rows — including is_admin and role. Here's why it happens and how to stop it.
The FastAPI endpoint your AI assistant just wrote accepts a UserUpdate model and calls .update(**request.dict()). Every field in that model goes straight to the database row. That includes is_admin.
This is mass assignment, and it’s one of the quieter ways an AI-generated API hands over the keys. The code compiles, the tests pass, and a user with a JSON editor can promote themselves to admin before you ship.
The Simplest FastAPI Pattern That Over-Exposes
BrassCoders sees this pattern repeatedly in AI-generated Python APIs: a Pydantic model that doubles as both the user-facing request schema and the database update map.
class UserUpdate(BaseModel):
name: str
email: str
is_admin: bool
role: str
account_tier: str
@router.put("/users/{user_id}")
async def update_user(user_id: int, request: UserUpdate, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
for field, value in request.dict().items():
setattr(user, field, value)
db.commit()
return user
The loop over request.dict().items() writes every field in the model to the User ORM object. is_admin, role, account_tier — all of them. The endpoint doesn’t check which caller is allowed to set which fields. It doesn’t check whether the authenticated user is modifying themselves or someone else. It maps and commits.
This is the path of least resistance, and it’s the path an AI assistant takes when asked to wire up a user update endpoint without an authorization spec.
Why AI Assistants Write It This Way
BrassCoders surfaces this over-exposure pattern in FastAPI projects because AI assistants satisfy “update this user” with the smallest possible mapping — request body to database row — and skip the authorization question entirely. That’s the root cause.
An AI assistant generating a FastAPI update endpoint has one goal: satisfy the prompt. Explicit field allowlisting requires a decision the prompt didn’t specify: which fields can a caller set, and which are off-limits? That’s an authorization question. Authorization questions require knowing your data model’s privilege hierarchy. The AI assistant doesn’t know it. You do — but if you didn’t say, the assistant filled the gap with the simplest implementation available.
Pydantic v2 renamed .dict() to .model_dump(), but the vulnerability travels with both. The pattern looks slightly different — request.model_dump() — and works identically. The field names still go to the database.
The Admin Escalation Path
BrassCoders is built to give an AI triage layer the context it needs to spot this class of failure. OWASP API Security Top 10 2023 names it API3:2023 — Broken Object Property Level Authorization — and it covers both read-side over-exposure (returning fields the caller shouldn’t see) and write-side over-exposure (accepting fields the caller shouldn’t set). Mass assignment is the write-side case.
The API3:2023 entry on the OWASP API Security site has the full taxonomy. The escalation path is short. A user sends:
{
"name": "Alice",
"email": "alice@example.com",
"is_admin": true,
"role": "superuser",
"account_tier": "enterprise"
}
The endpoint has no field-level authorization check. The ORM sets every attribute. The commit succeeds. Alice is now an admin. No error surfaces, no log entry marks the escalation, no validator rejects the payload. The only signal is a database row with a changed is_admin column — which you’d only notice if you were looking.
FastAPI and Pydantic validate that the fields are the right types. They don’t validate that the caller is allowed to set those fields. That distinction matters here.
What BrassCoders Can Catch
BrassCoders doesn’t have a deterministic scanner rule that fires specifically on .update(**request.dict()) or .update(**request.model_dump()). Scanner coverage is something BrassCoders is explicit about: it reports what its 12 scanners deterministically detect, not patterns it infers from context.
What the scanners do catch in AI-generated API code: hardcoded secrets and API keys in route handlers, weak JWT implementations (including the algorithm='none' variant), SQL injection via f-string query construction, and missing rate limiting on POST endpoints. These fire as deterministic findings in the BrassCoders YAML output.
The mass assignment pattern is different. It’s a structural authorization problem, not a code-level pattern that regex or AST rules can pin without false-positive risk. A naive rule that flags any setattr loop in a FastAPI route would fire on legitimate dynamic field updates that have proper authorization checks upstream. Context is needed to distinguish the two.
That context is exactly what the AI triage layer brings. When Claude Code reads a BrassCoders YAML report for a FastAPI project, it sees the full file-level scan results: what fields the UserUpdate model contains, where the endpoint lives, what Bandit and Pylint flagged nearby. With that context, spotting .update(**request.dict()) adjacent to privilege fields is a straightforward observation — the kind the AI assistant should make in triage, not in the scanner.
This is the division of labor BrassCoders is built around. The scanner is the deterministic pattern reporter. The AI triage layer — your Claude Code or Cursor session reading the .brass YAML — is the context-aware layer that connects findings to your actual authorization model.
The Fix: Explicit Field Inclusion
BrassCoders emits YAML findings that give your AI assistant full context on which fields a model exposes — and the fix is replacing the field loop with an explicit allowlist. Two clean approaches.
Option 1: Explicit field update
@router.put("/users/{user_id}")
async def update_user(
user_id: int,
request: UserUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.id == user_id).first()
# Only caller-safe fields
user.name = request.name
user.email = request.email
db.commit()
return user
Option 2: Separate schemas for caller-visible and admin-visible fields
class UserUpdatePublic(BaseModel):
name: str
email: str
class UserUpdateAdmin(BaseModel):
name: str
email: str
is_admin: bool
role: str
account_tier: str
Route the admin variant behind an admin-only dependency. The public variant never carries privilege fields, so mass assignment against it produces no escalation path. The FastAPI documentation for Pydantic schemas in update routes covers the exclude_unset pattern for partial updates — worth reading alongside this fix.
Either approach forces you to answer the authorization question the AI assistant skipped. Which fields can a regular caller set? Which require admin permissions? The explicit field list is the answer made visible in code. An auditor reading the route sees it immediately. A code reviewer spots missing fields. The AI triage layer, reading BrassCoders output, can verify the allowlist matches the data model it scanned.
The pattern is common enough that OWASP ranks it third among API security failures in 2023 — ahead of injection and broken authentication for that category. The remediation is simple. The gap between simple code and correct code is the authorization question an AI assistant skipped without notice.
BrassCoders runs on macOS, Linux, and Windows (WSL2). Install with:
pip install brasscoders
brasscoders scan /path/to/your/api
The OSS core is Apache 2.0 and free. BrassCoders Paid adds AI-powered enrichment for $12/dev/month — 50M tokens included, cancel any time via brasscoders portal.
Frequently Asked Questions
What is mass assignment?
Mass assignment is a vulnerability where an API endpoint accepts a request body and writes every field directly to a database record — including fields the caller should never control, like is_admin, role, or account_tier. If those fields appear in the Pydantic model, they get written. No permission check stops them.
Why do AI assistants generate mass assignment vulnerabilities?
An AI assistant satisfying 'update this user' will reach for the simplest mapping that makes the code work. Writing all fields from the request body to the database row does that. Explicitly listing safe fields, then checking which caller gets to set which ones, requires understanding the authorization model — context the AI assistant doesn't have unless you provide it.
Can a user set their own is_admin flag through mass assignment?
Yes, if is_admin is present in the Pydantic request model and the endpoint calls .update(**request.dict()) or .update(**request.model_dump()), the field goes straight into the database write. The user submits {"is_admin": true} in the JSON body and the escalation succeeds silently — no error, no log entry, no rejected request.
How does BrassCoders help with mass assignment?
BrassCoders doesn't have a deterministic rule that fires specifically on mass assignment patterns. What it does: it runs 12 static-analysis scanners and emits their combined output as YAML, which Claude Code or Cursor then reads as context for triage. The AI triage layer — not BrassCoders — is the right place to spot .update(**request.dict()) next to a model containing privilege fields. BrassCoders sets up that triage with accurate, unambiguous findings.
What is OWASP API3:2023 — Broken Object Property Level Authorization?
OWASP API3:2023, Broken Object Property Level Authorization, covers APIs that expose more object properties than the caller should be able to read or write. Mass assignment is the write-side failure: the endpoint lets callers overwrite fields they don't own. It's the third entry in the OWASP API Security Top 10 2023 list, which means it's common enough to rank ahead of injection and authentication failures in real-world API audits.