Software Securityhome
Fix a findingSearch
Fix card · A08:2025 Software or Data Integrity Failures

Fix mass assignment in Python and FastAPI

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 18 KB
On this page

Steps

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.pyPython
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 user

With IMPL=vulnerable, 2 of 6 tests fail (2 exploit tests).

The fix

Fixedimpl/fixed.pyPython
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 user

With IMPL=fixed, 6 of 6 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
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 user

With IMPL=wrong, 1 of 6 tests fail (1 exploit test).

Route: PATCH /me validates the body against the implementation's model

app.pyPython
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 regression test

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.

Testtests/test_profile.pyPython
# 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)

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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".

Scanner rules

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.

Scanner rules for Fix mass assignment in Python and FastAPI
ToolRuleWhat it found here
Semgreppython.django.security.injection.mass-assignment.mass-assignmentNo 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
Semgrepp/owasp-top-tenNo 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)
Banditall plugins (default profile)Bandit 1.9.4No 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.

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
fastapi0.142.2
pydantic2.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
pytest9.1.1

Run the tests

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.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# 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 pytest

Each 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.

Download the project ZIP, 18 KB

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

  1. OWASP Mass Assignment Cheat Sheet
  2. OWASP API Security Top 10 2023: API3 Broken Object Property Level Authorization
  3. CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
  4. FastAPI: extra models (separate input, output and database models)
  5. FastAPI: body updates, partial updates with PATCH
  6. Pydantic: models, extra data
  7. Pydantic: configuration API (ConfigDict.extra)

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.