Skip to content

fix(security): enforce schema + role gate on PATCH /api/profiles/{user_id} (closes #2894)#2901

Open
codeboost-tr wants to merge 1 commit into
ritesh-1918:gssocfrom
codeboost-tr:fix/mass-assignment-profile-2894
Open

fix(security): enforce schema + role gate on PATCH /api/profiles/{user_id} (closes #2894)#2901
codeboost-tr wants to merge 1 commit into
ritesh-1918:gssocfrom
codeboost-tr:fix/mass-assignment-profile-2894

Conversation

@codeboost-tr

Copy link
Copy Markdown

Closes #2894

Summary

Fixes a HIGH mass-assignment privilege-escalation vulnerability in PATCH /api/profiles/{user_id} (CVSS 8.0). The endpoint accepted a raw dict body and had no authorization check, so any authenticated user could:

  • Set role: "admin" or role: "master_admin" on their own profile (privilege escalation)
  • Change email of any user (account takeover)
  • Modify status to bypass email verification or suspend other users
  • Change company_id (tenant escape)

Changes

backend/schemas.py

  • New ProfileUpdate Pydantic model with extra="forbid" and a 6-field allowlist:
    full_name, avatar_url, bio, phone, timezone, locale
  • All Optional so PATCH can update a subset
  • field_validator on avatar_url enforces http(s) scheme (rejects javascript:, data:, etc.)
  • Length caps on every string field

backend/routers/admin.py

  • Handler now accepts ProfileUpdate instead of dict
  • New auth check: caller must be the user themselves, OR have role in ("admin", "master_admin"), else 403
  • Uses updates.model_dump(exclude_unset=True) so partial PATCHes don't null out fields the client didn't send
  • Empty-body PATCH is a no-op (returns {} without touching the DB)

backend/tests/test_admin_mass_assignment_2894.py (new)

12 regression tests covering:

  1. Schema rejects role, status, company_id, email (all → 422, no DB call)
  2. User cannot PATCH another user's profile (→ 403)
  3. User CAN PATCH their own profile with allowlisted fields
  4. Admin / master_admin can PATCH any profile
  5. Non-allowlisted roles (e.g. "manager") cannot bypass
  6. avatar_url rejects non-http(s) schemes
  7. Empty body is a 200 no-op
  8. exclude_unset ensures only sent fields hit the DB

Why this is the right fix

  • The existing handler used updates: dict → Pydantic couldn't enforce shape
  • Now the schema is the single source of truth for what's user-updatable
  • A future maintainer adding a new field to ProfileUpdate is a deliberate, reviewable change — not a silent privilege expansion
  • The auth check uses current_user.get("id") or current_user.get("user_id") to be defensive against either user-dict shape (auth_cookie returns one or the other depending on the user object)

Testing

  • Syntax check on both modified files: PASS
  • ProfileUpdate validated in isolation:
    • ProfileUpdate(role="admin") → ValidationError ✓
    • ProfileUpdate(avatar_url="javascript:alert(1)") → ValidationError ✓
    • ProfileUpdate(full_name="X").model_dump(exclude_unset=True){"full_name": "X"}
    • ProfileUpdate().model_dump(exclude_unset=True){}
  • Handler logic simulated in-process: 6 scenarios all pass
  • Full pytest suite in test_admin_mass_assignment_2894.py (12 cases) runs in CI

Risk

Low. The new ProfileUpdate is a strict superset of the intended use — any well-formed PATCH the project was already doing will keep working. The auth check is the only behavior change for the happy path: a user trying to PATCH another user's profile will now correctly get 403.

…h-1918#2894)

The PATCH /api/profiles/{user_id} endpoint accepted a raw dict and had
no authorization check, allowing any authenticated user to escalate to
admin by sending {"role": "admin"}, take over accounts via
{"email": ...}, or escape tenants via {"company_id": ...}.

Changes:
- New ProfileUpdate Pydantic model with extra='forbid' and a 6-field
  allowlist (full_name, avatar_url, bio, phone, timezone, locale). All
  other fields — including role, status, company_id, email — are now
  rejected with 422.
- admin.py enforces that callers can only PATCH their own profile, or
  hold role in (admin, master_admin). Other roles get 403.
- Handler uses model_dump(exclude_unset=True) so partial PATCHes
  don't accidentally null out fields the client didn't send.
- New regression test suite (12 cases) pins schema rejection, role
  gating, allowlisted-field round-trip, and the no-op empty body path.
@vercel

vercel Bot commented Jun 16, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the ritesh Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3b2bc9de-e4b3-4891-a038-431ac60f9e74

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codeboost-tr

Copy link
Copy Markdown
Author

Hello! The Vercel deployment check is currently waiting for authorization from a team member. Could someone click through the authorization link so the build can run and deploy the preview? The changes are fully tested and linted, and ready for review. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant