[INFRA-774] fix(security): scope WorkSpaceMemberSerializer to block cross-workspace member reassignment via PATCH - #9705
Conversation
…er on WorkSpaceMemberSerializer WorkSpaceMemberSerializer declared fields = "__all__" with no read_only_fields, so DRF auto-generated a writable workspace FK. WorkSpaceMemberViewSet.partial_update passes raw request.data straight into the serializer with no scrubbing, so a workspace ADMIN could PATCH any other active member's row with a workspace field pointing at a foreign workspace's UUID — moving that row (with whatever role was also in the body) into the foreign workspace with no invitation, no consent from its owner, and no audit trail. Add workspace/member (plus the usual created_by/updated_by/created_at/ updated_at) to read_only_fields on WorkSpaceMemberSerializer and its siblings WorkspaceMemberMeSerializer/WorkspaceMemberAdminSerializer — same model, same footgun shape, even though the latter two are only ever instantiated read-only today. Co-authored-by: Plane AI <noreply@plane.so>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthroughWorkspace-member serializers now share read-only field configuration for workspace, member, audit fields, ChangesWorkspace member protection
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to The change narrows workspace-member updates to prevent cross-workspace reassignment while preserving legitimate role changes, with regression tests covering the protected behavior. No actionable merge-blocking risk remains beyond normal checks and review. Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Description checkExplanation The description clearly explains the vulnerability, fix, scope, regression tests, and verification results. It does not use all template headings, but it provides the required core information and remains complete and on-topic.
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
|
Linked to Plane Work Item(s) This comment was auto-generated by Plane |
Address /code-review finding on PR #9705: the identical 7-entry read_only_fields list was duplicated verbatim across all three WorkspaceMember-backed serializers. Extracted to WORKSPACE_MEMBER_READ_ONLY_FIELDS so a future field addition/removal can't silently drift between them and reopen the same write-scoping gap. Co-authored-by: Plane AI <noreply@plane.so>
|
Addressed the /code-review finding in 926e84c: extracted the identical 7-entry No correctness issues were found — the review traced every call site of all three serializers and confirmed Related workspace serializer/view suites still green (45 passed) after the refactor. |
There was a problem hiding this comment.
Pull request overview
This PR hardens the Django REST Framework workspace member PATCH endpoint to prevent cross-workspace reassignment of WorkspaceMember rows (a cross-tenant privilege escalation vector), and adds a regression test to ensure the issue does not recur.
Changes:
- Marked
workspaceandmember(and standard audit fields) as read-only onWorkSpaceMemberSerializerand related serializers to prevent cross-workspace row moves via PATCH. - Added a contract regression test covering the attempted cross-workspace move plus a positive control ensuring role-only PATCH still works.
- Applied incidental
ruff formatreflow to a pre-existing validation error raise.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| apps/api/plane/app/serializers/workspace.py | Makes key WorkspaceMember FK fields read-only to prevent cross-tenant member reassignment via PATCH. |
| apps/api/plane/tests/contract/app/test_workspace_member_cross_tenant_reassignment.py | Adds regression coverage for the cross-workspace PATCH exploit and validates legitimate role updates still succeed. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…rializers Address Copilot review findings on PR #9705: - WorkSpaceMemberSerializer (and its siblings) also exposed is_active and deleted_at as writable via fields = "__all__". Every legitimate place that flips these fields (WorkSpaceMemberViewSet.destroy/.leave, invite acceptance) does so via direct model-field assignment, never through this serializer — so this was a side channel letting an admin PATCH around destroy()'s own safety checks (self-removal, role-outranking, last-project-admin orphaning) and its ProjectMember deactivation cascade. deleted_at is worse: the default manager filters on it, so setting it directly silently vanishes the row from every normal queryset with a forgeable timestamp and no audit trail. - Added both fields to WORKSPACE_MEMBER_READ_ONLY_FIELDS. Two new regression tests, fail-before verified (both fail against the pre-this-commit code: is_active flips, and the deleted_at row genuinely vanishes from WorkspaceMember.objects). - Fixed a docstring inaccuracy: partial_update lives on WorkSpaceMemberViewSet, not on the serializer. Co-authored-by: Plane AI <noreply@plane.so>
|
Addressed both Copilot findings in 07739f2:
Full related suite still green (47 passed), ruff clean. |
Summary
WorkSpaceMemberSerializerdeclaredfields = "__all__"with noread_only_fields, so DRF auto-generated a writableworkspaceFK.WorkSpaceMemberViewSet.partial_updatepasses rawrequest.datastraight into the serializer with no scrubbing.WorkspaceMemberrow with aworkspacefield pointing at a foreign workspace's UUID — moving that row (with whateverrolewas also in the body, e.g. 20/admin) into the foreign workspace. No invitation flow, no consent from the target workspace's owner, no audit trail.workspace/member(plus the usualcreated_by/updated_by/created_at/updated_at) toread_only_fieldsonWorkSpaceMemberSerializer, matching the pattern already used onWorkSpaceSerializerright above it in the same file. Applied the same fix to the siblingWorkspaceMemberMeSerializer/WorkspaceMemberAdminSerializerfor defense-in-depth — same model, same footgun shape, even though today they're only ever instantiated read-only.validate_name's raise statement) got reformatted byruff format— unrelated to this fix, kept since it matches the project's canonical style.Test plan
apps/api/plane/tests/contract/app/test_workspace_member_cross_tenant_reassignment.py: admin cannot move another member's row into a foreign workspace via PATCH (with a positive control that the legitimate role-only PATCH still works)git stashon the fix — the cross-workspace-move test fails against the pre-fix code (row actually moves), passes afterruff check/ruff format --checkcleanSummary by CodeRabbit
Bug Fixes
Tests