Never copy request.get_json() onto a record with dict.update(), a setattr loop or Model(**data). Validate it with a Pydantic model that declares only the fields users may edit (here displayName and bio; undeclared keys are ignored by default) and copy model_dump(exclude_none=True). Set role and tenantId in server-side code, never from the request.
- Find every view that writes request data onto a stored object: record.update(request.json), for key, value in data.items(): setattr(obj, key, value), Model(**request.json), session.execute(update(Model).values(**data)) and the like, including form handlers that use request.form the same way.
- For each action, declare a Pydantic model with only the fields that caller may change, here ProfileUpdate with displayName and bio, both optional for PATCH. Give the admin screen its own model and view rather than adding role to this one.
- Validate with Model.model_validate(request.get_json()) and answer 400 on ValidationError. request.get_json() already answers 415 when the content type is not JSON and 400 when the JSON is malformed.
- Copy only the model's fields onto the record, for example record.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 view behind its own permission check, 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
def update_profile(user: dict, data):
user.update(data)
return userWith IMPL=vulnerable, 3 of 6 tests fail (3 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. Pydantic ignores keys a model does not
declare (extra="ignore" is the default)."""
displayName: str | None = None
bio: str | None = None
def update_profile(user: dict, data):
try:
changes = ProfileUpdate.model_validate(data)
except ValidationError:
return {"error": "displayName and bio must be strings"}, 400
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
PROTECTED_FIELDS = {"role"} # set by admins only
def update_profile(user: dict, data):
user.update({key: value for key, value in data.items() if key not in PROTECTED_FIELDS})
return userWith IMPL=wrong, 2 of 6 tests fail (2 exploit tests).
Route: PATCH /me edits the signed-in user's own record
@app.before_request
def load_signed_in_user() -> None:
# Flask signs the session cookie with SECRET_KEY, so a client cannot change
# user_id. PATCH /me always edits the signed-in user's own record.
g.user = users.get(session.get("user_id"))
@app.patch("/me")
def update_me():
if g.user is None:
return {"error": "sign in first"}, 401
return impl.update_profile(g.user, request.get_json())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). 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), f"expected 200 or 400, got {res.status_code}: {res.get_data(as_text=True)}"
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)- A blocklist such as PROTECTED_FIELDS = {"role"} only removes the names you thought of. This card's wrong implementation filters role and lets tenantId through, and every field added to the record later is writable too.
- dict.update() and setattr loops store any key the client sends: in this card's tests the vulnerable and wrong views store Role and TenantId next to role and tenantId. With SQLAlchemy, setattr(user, key, value) assigns any mapped attribute named in the request, role and tenant_id included.
- Pydantic's extra='allow' keeps undeclared keys and model_dump() returns them, which defeats the model. extra='ignore' (the default) drops them; extra='forbid' raises a ValidationError you can turn into a 400 if you would rather refuse such requests.
- Pydantic matches field names exactly, so the case variant Role is just an undeclared key. An alias combined with validate_by_name=True accepts two spellings of the same field, which is fine for editable fields; the mistake is declaring role or tenantId on any model that users can submit.
- Use one model per action. Reusing the admin's UserUpdate model, which declares role, for PATCH /me reopens the hole as soon as the view copies model_dump() onto the record.
- Hidden form fields and read-only inputs are not protection: the client can send any JSON it likes.
- 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-flask 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.
- Do not rely on a scanner: Semgrep's only Python mass assignment rule is for Django, Bandit has no such check and CodeQL has no Python query for it. The regression tests are the control.
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 on app.py or impl/*.py. The rule is for Django: it matches $MODEL.objects.create(**request.$W) and $OBJ.update(**request.$W) followed by $OBJ.save(). A dict.update(data) with data = request.get_json() in a Flask view is outside its patterns.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | p/owasp-top-ten | No findings on app.py or impl/*.py.Checked: Semgrep 1.180.0 (CE) |
| Bandit | all plugins (default profile)Bandit 1.9.4 | No findings on app.py or impl/*.py. Bandit has no mass assignment plugin, so its silence says nothing about this bug.Checked: Bandit 1.9.4 |
In semgrep-rules develop at 9bbf021 the only Python rule tagged CWE-915 is Django's mass-assignment; nothing covers Flask, SQLAlchemy or Pydantic. 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 | Flask 3.1.3 |
| flask | 3.1.3 |
| pydantic | 2.13.5 (pydantic-core 2.46.5) |
| werkzeug | 3.1.9 |
| 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-flaskfolder.
python3.14 -m venv .venv
.venv/bin/python -m pip install --require-hashes -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-flask runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python 3.14.8
- pip 26.2.1
- flask 3.1.3
- pydantic 2.13.5
- pydanticCore 2.46.5
- werkzeug 3.1.9
- pytest 9.1.1
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 3 of 6 tests fail (3 exploit tests); wrong: 2 of 6 tests fail (2 exploit tests)
- Test results
- fixed 6/6 pass; vulnerable 3/6 fail (all 3 exploit tests); wrong 2/6 fail (the tenantId and case-variant exploit tests: the blocklist lets tenantId through, and dict.update stores Role and TenantId as new keys); offline in a no-network sandbox, after installs with warm and with empty package caches
- Dependency freshness
- requirements.txt pins all 18 packages with sha256 hashes for every published file (regenerate with ../.tools/pin_requirements.py). pydantic 2.13.5 and pydantic-core 2.46.5 (2026-08-28) are used rather than 2.14.0 and 2.50.0 (2026-10-08), which were two days old and fix no listed security issue. Werkzeug 3.1.9 (2026-09-27) is taken at 13 days old because it fixes GHSA-g6x2-hccm-hh4m, as in the other Flask cards. OSV listed no advisories for the pinned versions. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.
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
- Pydantic: Models, extra data
- Pydantic: ConfigDict.extra (ignore, allow, forbid)
- Flask API: Request.get_json