Skip to content

[WEB-8332] fix(security): block workspace-member mass-assignment (GHSA-f739-39g5-jj49)#9460

Open
mguptahub wants to merge 4 commits into
previewfrom
web-8332/workspace-member-mass-assignment
Open

[WEB-8332] fix(security): block workspace-member mass-assignment (GHSA-f739-39g5-jj49)#9460
mguptahub wants to merge 4 commits into
previewfrom
web-8332/workspace-member-mass-assignment

Conversation

@mguptahub

@mguptahub mguptahub commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

CRITICAL — fixes a cross-tenant workspace takeover (GHSA-f739-39g5-jj49, WEB-8332). Confirmed-vulnerable on origin/preview.

WorkSpaceMemberViewSet.partial_update passed request.data straight into WorkSpaceMemberSerializer (fields = "__all__", only nested member read-only). workspace, role, and is_active were therefore mass-assignable. An admin of an attacker-owned workspace could PATCH a member row setting workspace=victim-workspace UUID and role=20 → relocate a controlled account into the victim workspace as an admin with no invitation. Since all authorization derives from WorkspaceMember(workspace, member, role, is_active) rows, this is a full cross-tenant takeover. @allow_permission([ADMIN], level="WORKSPACE") authorizes against the URL slug but does not stop the write from moving the row elsewhere.

Fix

The endpoint's only legitimate mutation is role, so the writable payload is restricted to {"role": ...} in the view:

allowed_data = {}
if "role" in request.data:
    allowed_data["role"] = request.data.get("role")
serializer = WorkSpaceMemberSerializer(workspace_member, data=allowed_data, partial=True)

Why not fields=("id","member","role")? DynamicBaseSerializer.__init__ pops the fields= kwarg and immediately overwrites it with self.expand (serializers/base.py), so fields= never restricts writes or output. The view-level allowlist is the reliable fix and is the only write path through this serializer (list/retrieve are read-only), so no other consumer is affected. The self-role-update guard and guest role-cascade are preserved; is_active is only changed via destroy().

Tests

New tests/contract/app/test_workspace_member_mass_assignment_app.py — 4 cases (cross-workspace move blocked; is_active=false no-ops; legitimate role update works; self-update still 400). Fail-before verified on the CE docker test stack: 2 attack tests failed unpatched → 4 pass patched; 12/12 in a regression run with sibling member/authz suites.

Related finding (separate ticket — NOT in this PR)

The root cause exposed a latent bug: DynamicBaseSerializer's fields= kwarg is discarded (base.py: fields = self.expand), so every caller that passes fields=(...) expecting to limit output (e.g. list/retrieve here) actually returns all model columns. That's an app-wide over-disclosure surface needing its own scoped audit + FE-compat check — filed separately; deliberately not touched here (changing base.py would enforce output filtering app-wide and likely break the frontend).

Summary by CodeRabbit

  • Bug Fixes

    • Workspace member PATCH is now restricted to role-only updates, preventing unauthorized changes to other fields (including workspace association and deactivation).
    • Attempts to move a member to another workspace via PATCH are blocked, and related role effects no longer impact unrelated membership.
    • Non-integer role inputs now return a client error without cascading guest-role behavior.
  • Tests

    • Added contract regression coverage for mass-assignment prevention, ignored deactivation attempts, valid role updates, self-role restrictions, and error handling.

…A-f739-39g5-jj49)

WorkSpaceMemberViewSet.partial_update passed request.data verbatim into
WorkSpaceMemberSerializer (fields="__all__"), making workspace, role, and
is_active mass-assignable. A workspace admin could PATCH a member row setting
workspace=<victim UUID> and role=20, relocating a controlled account into the
victim workspace as admin — full cross-tenant takeover.

The endpoint's only legitimate mutation is `role`, so restrict the writable
payload to {"role": ...}. Note: passing fields=("id","member","role") does NOT
work — DynamicBaseSerializer discards the fields= kwarg (base.py) — so the
allowlist is enforced in the view instead. Preserves the self-role-update guard
and the guest role-cascade. is_active is only changed via destroy(), not here.

Adds 4 contract tests; fail-before verified (2 attack tests failed unpatched → 4 pass).

Co-authored-by: Plane AI <noreply@plane.so>
Copilot AI review requested due to automatic review settings July 22, 2026 11:49
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 99b212ff-74de-4096-a30a-9f078ea76c67

📥 Commits

Reviewing files that changed from the base of the PR and between cff6438 and 63d1515.

📒 Files selected for processing (2)
  • apps/api/plane/app/views/workspace/member.py
  • apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/api/plane/app/views/workspace/member.py
  • apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py

📝 Walkthrough

Walkthrough

Workspace member partial updates now whitelist role, validate before side effects, and save role changes with related guest-role updates atomically. Contract tests cover mass-assignment prevention, valid and invalid roles, ignored activation changes, and self-update restrictions.

Changes

Workspace member update security

Layer / File(s) Summary
Restrict and validate workspace member PATCH fields
apps/api/plane/app/views/workspace/member.py
partial_update accepts only role, validates it before cascading changes, and atomically saves the workspace member and guest-role updates.
Validate update boundaries
apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py
Contract tests verify cross-workspace reassignment and deactivation are ignored, legitimate role updates succeed, self-role updates remain forbidden, and invalid roles return 400 without cascading changes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • makeplane/plane#9367: Both changes harden member partial updates against unauthorized activation changes.

Suggested reviewers: pablohashescobar, dheeru0198

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the security fix for workspace-member mass-assignment.
Description check ✅ Passed The description covers summary, fix, tests, and references, though the Type of Change section is missing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch web-8332/workspace-member-mass-assignment

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.

@makeplane

makeplane Bot commented Jul 22, 2026

Copy link
Copy Markdown

Linked to Plane Work Item(s)

This comment was auto-generated by Plane

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py`:
- Around line 99-107: Extend the mixed-payload assertions in the workspace
member update test to verify that the permitted role change is applied by
asserting puppet_member.role equals 20 after refresh_from_db(), while retaining
the existing assertions that the workspace remains unchanged and no victim
member is created.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 65e256f5-7f4d-4580-8509-f4bf08174ff0

📥 Commits

Reviewing files that changed from the base of the PR and between a8e53b6 and 46670e6.

📒 Files selected for processing (2)
  • apps/api/plane/app/views/workspace/member.py
  • apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a critical security vulnerability (GHSA-f739-39g5-jj49 / WEB-8332) where WorkSpaceMemberViewSet.partial_update allowed workspace admins to mass-assign sensitive WorkspaceMember fields (e.g. workspace, is_active) and perform cross-tenant workspace takeover by relocating a controlled member row into another workspace.

Changes:

  • Restricts WorkSpaceMemberViewSet.partial_update to only accept role in the writable payload (view-level allowlist).
  • Adds contract regression tests covering the mass-assignment takeover vector and ensuring intended role updates still work.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
apps/api/plane/app/views/workspace/member.py Adds a view-level allowlist to prevent mass-assignment of WorkspaceMember fields via PATCH.
apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py Adds contract regression tests to prevent cross-workspace moves and verify allowed role updates.

Comment thread apps/api/plane/app/views/workspace/member.py Outdated
…opilot #9460)

partial_update cascaded the guest project-role downgrade BEFORE the serializer
was validated/saved, using a manual int() cast that 500s on non-integer input.
Reorder: validate the serializer first, then cascade using
serializer.validated_data["role"] inside a transaction.atomic() with save(), so a
bad role returns 400 (not 500) and project roles can't be downgraded when the
member update doesn't persist. Adds a non-integer-role 400 test.

Co-authored-by: Plane AI <noreply@plane.so>
Copilot AI review requested due to automatic review settings July 22, 2026 12:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py`:
- Around line 162-179: Extend test_non_integer_role_is_400_not_500 by creating a
related ProjectMember for target_member with a non-guest role before the invalid
PATCH, then refresh it and assert its role remains unchanged alongside
target_member.role. Use the existing project-member creation helpers and role
symbols visible in the test module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1339de50-6ecc-41a5-857f-0a45edaba3bb

📥 Commits

Reviewing files that changed from the base of the PR and between 46670e6 and ea8a667.

📒 Files selected for processing (2)
  • apps/api/plane/app/views/workspace/member.py
  • apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api/plane/app/views/workspace/member.py

…id update (CodeRabbit #9460)

Address two CodeRabbit review comments:

- Mixed-payload takeover test now also asserts the permitted field (role) in
  the same payload WAS applied (role == 20), proving the fix filters the
  payload rather than rejecting it wholesale.
- test_non_integer_role_is_400_not_500 now seeds a non-guest ProjectMember and
  asserts it stays role 15 after the invalid PATCH, so a regression that let
  the guest project-role cascade run on a failed update would be caught.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 09:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread apps/api/plane/app/views/workspace/member.py
…ld (Copilot #9460)

When a PATCH carries only forbidden keys (e.g. {"is_active": false}), allowed_data
is empty and the previous code still called serializer.save() — a no-op write that
bumped updated_at/updated_by and ran through the save path for nothing.

Early-return the current serialized member (200) when there are no allowed fields,
so a forbidden-only payload has no side effects. Strengthened the is_active test to
assert updated_at is unchanged (fail-before verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 09:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

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.

2 participants