Give each write route its own Pydantic body model that declares only the fields that caller may change (here displayName and bio), never the model that describes the whole stored user, and copy model_dump(exclude_none=True) onto the record. extra="forbid" turns any other key into a 422. Set role and tenantId only in server-side code.
- Find every path operation that writes a body onto a stored object: record.update(body.model_dump()), Model(**body.model_dump()), setattr loops and update(Model).values(**data), and note which Pydantic model each body uses.
- Split shared models: one input model per action with only the fields that caller may set (ProfileUpdate with displayName and bio, both optional for PATCH), separate from the output model and from the admin's model that declares role.
- Set model_config = ConfigDict(extra="forbid") on input models if you want unexpected keys refused with 422; the default, extra="ignore", drops them silently, which is also safe.
- Copy only the model's fields onto the record, for example user.update(changes.model_dump(exclude_none=True)), or assign them one by one on an ORM object.
- Set role, tenant_id, owner_id and similar fields only in server-side code: an admin route behind its own permission dependency, sign-up, an invitation.
- Add regression tests that send an ordinary update plus role, plus tenantId and plus case variants of both, then read the stored record rather than only the response.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
class ProfileUpdate(BaseModel):
displayName: str | None = None
bio: str | None = None
role: str | None = None
tenantId: str | None = None
def update_profile(user: dict, changes: ProfileUpdate) -> dict:
user.update(changes.model_dump(exclude_unset=True))
return userWith IMPL=vulnerable, 2 of 6 tests fail (2 exploit tests).
The fix
class ProfileUpdate(BaseModel):
"""The fields a user may change on their own profile. role and tenantId are not
here: only server-side code sets them. extra="forbid" turns any other key into a
422 instead of silently ignoring it (the default, extra="ignore", is also safe)."""
model_config = ConfigDict(extra="forbid")
displayName: str | None = None
bio: str | None = None
def update_profile(user: dict, changes: ProfileUpdate) -> dict:
user.update(changes.model_dump(exclude_none=True)) # displayName and bio, nothing else
return userWith IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
class ProfileUpdate(BaseModel):
displayName: str | None = None
bio: str | None = None
role: str | None = None
tenantId: str | None = None
PROTECTED_FIELDS = {"role"} # set by admins only
def update_profile(user: dict, changes: ProfileUpdate) -> dict:
user.update(changes.model_dump(exclude_unset=True, exclude=PROTECTED_FIELDS))
return userWith IMPL=wrong, 1 of 6 tests fail (1 exploit test).
Route: PATCH /me validates the body against the implementation's model
def current_user(user_id: Annotated[str | None, Depends(session_user_id)]) -> dict:
user = users.get(user_id) if user_id is not None else None
if user is None:
raise HTTPException(status_code=401, detail="sign in first")
return user
# The body model comes from the implementation: FastAPI validates the JSON body
# against it before the function runs. PATCH /me always edits the signed-in user.
@app.patch("/me")
def update_me(changes: impl.ProfileUpdate, user: Annotated[dict, Depends(current_user)]) -> dict:
return impl.update_profile(user, changes)The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: a signed-in member sends extra fields in an ordinary profile update.
def assert_server_fields_unchanged(res, users, seeded_alice) -> None:
"""The server may ignore the extra fields (200) or refuse the request (400, or 422,
FastAPI's status for a body that fails validation). Either way the stored record
keeps the role and tenant the server gave it, and gains no fields the client named."""
assert res.status_code in (200, 400, 422), f"expected 200, 400 or 422, got {res.status_code}: {res.text}"
stored = users["alice"]
assert stored["role"] == "member", f"role changed: {stored}"
assert stored["tenantId"] == "red", f"tenantId changed: {stored}"
assert stored.keys() == seeded_alice.keys(), f"the stored record gained fields: {stored}"
def test_exploit_role_in_the_body_does_not_make_a_member_an_admin(patch_me, users, seeded_alice):
assert_server_fields_unchanged(patch_me("alice", '{"displayName": "Alice Martin", "role": "admin"}'), users, seeded_alice)
def test_exploit_tenant_id_in_the_body_does_not_move_the_user_to_another_tenant(patch_me, users, seeded_alice):
assert_server_fields_unchanged(patch_me("alice", '{"displayName": "Alice Martin", "tenantId": "blue"}'), users, seeded_alice)
def test_exploit_case_variants_role_and_tenant_id_do_not_change_the_record_either(patch_me, users, seeded_alice):
assert_server_fields_unchanged(
patch_me("alice", '{"displayName": "Alice Martin", "Role": "admin", "TenantId": "blue"}'), users, seeded_alice)- Reusing the model that describes the stored user as the PATCH body, with every field made optional so a partial update can change anything, is the FastAPI form of this bug: role and tenantId pass validation because the model declares them. That is this card's vulnerable implementation.
- Excluding a few names when you copy, as in model_dump(exclude={"role"}), is a blocklist. This card's wrong implementation excludes role and lets tenantId through, and every field added to the model later is writable too.
- response_model and return-type filtering only control what goes out; they do nothing about what the request may set.
- extra="allow" keeps undeclared keys and model_dump() returns them, which defeats the model; a body typed dict[str, Any] or Body() with no model does the same.
- Pydantic matches field names exactly, so the case variant Role is just an undeclared key, ignored or, with extra="forbid", refused. An alias with validate_by_name=True accepts two spellings of the same field, which is fine for editable fields only.
- With SQLAlchemy, setattr(obj, key, value) for key, value in body.model_dump().items() assigns whatever mapped attributes the model declares; the model is the allowlist, so keep it narrow.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs mass-assignment/python-fastapi does this for you).
- Against your own API, sign in as an ordinary member, replay a normal profile update with role, tenantId and their case variants added, then read the record from the database: role and tenantId must be unchanged and no new fields stored.
- Read the OpenAPI schema at /openapi.json: the request body schema of each write route should list only the fields that caller may set, and additionalProperties: false shows extra="forbid".
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.django.security.injection.mass-assignment.mass-assignment | No findings. The rule is for Django ($MODEL.objects.create(**request.$W) and $OBJ.update(**request.$W) with save()); a Pydantic body model whose model_dump() is copied onto a record is outside its patterns.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> app.py impl |
| Semgrep | p/owasp-top-ten | No mass assignment findings on any implementation. p/default's only finding was the deliberate importlib.import_module in app.py that picks the implementation.Checked: Semgrep 1.180.0 (CE) |
| Bandit | all plugins (default profile)Bandit 1.9.4 | No findings. Bandit has no mass assignment plugin, so its silence says nothing about this bug.Checked: Bandit 1.9.4; bandit -r impl app.py |
semgrep-rules has one FastAPI rule (python/fastapi/security/wildcard-cors) and its only Python rule tagged CWE-915 is Django's; github/codeql main at b5b8165 has no Python query tagged CWE-915. The regression tests are the control.
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5) |
| fastapi | 0.142.2 |
| pydantic | 2.13.5 (pydantic-core 2.46.5) |
| starlette (SessionMiddleware) | 1.7.0 |
| itsdangerous (signs the session cookie) | 2.2.0 |
| httpx2 (TestClient transport, test only) | 2.13.1 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the mass-assignment/python-fastapifolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs mass-assignment/python-fastapi runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python CPython 3.14.8
- fastapi 0.142.2
- pydantic 2.13.5
- starlette 1.7.0
- pytest 9.1.1
- semgrep 1.180.0
- bandit 1.9.4
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 2 of 6 tests fail (2 exploit tests); wrong: 1 of 6 tests fail (1 exploit test)
- Test results
- fixed 6/6 pass; vulnerable 2/6 fail (2 exploit tests: role and tenantId; Pydantic ignores the case variants Role and TenantId as undeclared keys); wrong 1/6 fail (1 exploit test: tenantId), offline after install
- Dependency freshness
- FastAPI 0.142.2 (2026-09-30) and Pydantic 2.13.5 (2026-08-28) rather than 0.143.0 and 2.14.0 (2026-10-08), which were two days old and fix no security issue. Starlette 1.7.0 dates from 2026-09-23, itsdangerous 2.2.0 from 2024-04-16, httpx2 2.13.1 from 2026-09-23. The test-only iniconfig is pinned at 2.3.0 because 2.3.1 is from 2026-10-06. OSV reported no known vulnerabilities for any pinned package.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Mass Assignment Cheat Sheet
- OWASP API Security Top 10 2023: API3 Broken Object Property Level Authorization
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
- FastAPI: extra models (separate input, output and database models)
- FastAPI: body updates, partial updates with PATCH
- Pydantic: models, extra data
- Pydantic: configuration API (ConfigDict.extra)