DEV Community

CopperSunDev
CopperSunDev

Posted on • Originally published at coppersun.dev

Mass Assignment in AI-Generated Python APIs

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)