Skip to content

[WEB-8332] fix(security): block workspace-member mass-assignment - #9460

Merged
sriramveeraghanta merged 8 commits into
previewfrom
web-8332/workspace-member-mass-assignment
Oct 14, 2026
Merged

sriramveeraghanta merged 8 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 (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:

  • View: partial_update rejects (400) any payload other than {"role": ...}, including non-object JSON bodies (previously a 500).
  • Serializer: workspace, is_active and the audit/soft-delete columns are read-only on WorkSpaceMemberSerializer; WorkspaceMemberAdminSerializer now subclasses it, so the protection doesn't depend on this one view.
  • Write: only role/updated_at/updated_by are saved, so a concurrent removal or view_props save isn't reverted by a stale full-row write.
  • Guest downgrade: runs after role validation, inside the same transaction, bumps ProjectMember.updated_at, and is refused (400) when the member is the only member and admin of a project — same rule as leave()/destroy().
  • destroy() guard: the sole-project-admin check compared a user id with a WorkspaceMember id and never fired. All three call sites now share is_sole_admin_of_any_project().

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.

Behaviour changes: extra fields now return 400 instead of being ignored (the web app only sends {role}, and shows the error message in a toast). Removing or demoting a project's only member/admin now returns 400.

Tests

tests/contract/app/test_workspace_member_mass_assignment_app.py — 12 cases: cross-workspace move and is_active rejected with no write; non-object bodies → 400; legitimate role update; demotion downgrades project roles; sole-project-admin demotion and removal rejected; self-update still 400; invalid roles (incl. 5.0, which passed the old int() check) → 400 with no downgrade.

Fail-before: 8 of 12 fail against the previous branch head. Full backend suite on the CE docker test stack: 712 passed.

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 updates now accept only a role change; requests without a role leave the member unchanged.
    • Invalid role values return a client error without changing workspace or project roles.
    • Setting a member’s role to guest also updates their project roles.
  • Tests

    • Added coverage for ignored extra fields, role updates, self-role restrictions, and invalid role values.

…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 lite 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 in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 6da9fe67-beb9-430b-993d-73fcf0f4fc9f

📥 Commits

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

📒 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

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.

📝 Walkthrough

Workspace member partial updates now accept only role, validate the role before side effects, and save role changes with related project-role updates atomically. Contract tests cover mass-assignment prevention, ignored activation changes, role updates, self-role rejection, and invalid roles.

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 project-role updates.
Validate update boundaries
apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py
Contract tests cover cross-workspace reassignment, ignored activation changes, valid role updates, self-role rejection, and invalid roles without project-role changes.

Priority: ⬆️ High

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

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 51ef6

The workspace-member PATCH change is mergeable after normal checks; no actionable risk remains identified.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 51ef6

The change blocks workspace reassignment and activation through this role-update endpoint and makes related role changes atomic. No new security weakness is established, though concurrent membership changes remain an area of uncertainty.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — Before the change, a workspace admin’s submitted fields could reach an all-fields member serializer after authorization against the URL workspace, creating a potential cross-workspace membership-write path. The new allowlist removes workspace from that path.

Trust Boundaries and Controls

  • observed — The endpoint retains workspace-admin permission enforcement and selects the target within the URL workspace. Attacker-supplied workspace and is_active fields no longer reach its serializer write path.

Resilience and Maintainability Implications

  • inferred — Concurrent project-member writes or member deactivation may require coordination with role downgrades: the member is read before the transaction, and no shared lock is visible. The relevant read-and-save pattern predates this PR, so this is not established as a new PR concern.

Hardening Proposals

  • proposed — Coordinate guest-role transitions with project-membership creation and member deactivation, and verify those interleavings with concurrent-transition coverage.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 53.85% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
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.
Title check Passed The title clearly identifies the security fix and the affected workspace-member mass-assignment issue.
Description check Passed The description provides detailed context, implementation changes, behavior changes, test scenarios, results, and the related issue reference. It does not use the template headings or explicit change-…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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

Comment thread apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py Outdated

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

Comment thread apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py Outdated
…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 Outdated
…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.

@mguptahub mguptahub changed the title [WEB-8332] fix(security): block workspace-member mass-assignment (GHSA-f739-39g5-jj49) [WEB-8332] fix(security): block workspace-member mass-assignment Aug 7, 2026
Explanations kept unchanged; only the IDs are removed.

Co-authored-by: Plane AI <noreply@plane.so>
Copilot AI review requested due to automatic review settings August 7, 2026 10:34
@github-actions

github-actions Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

React Doctor skipped this pull request — it changed no React files.

Reviewed by React Doctor for commit 9ee2936.

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.

mguptahub and others added 2 commits October 12, 2026 16:27
- Reject any partial_update payload other than {"role": ...} with 400,
  including non-object JSON bodies (previously 500).
- Mark workspace/is_active/audit columns read-only on the workspace
  member serializers so the fix doesn't rely on the view alone.
- Save only role/updated_at/updated_by so a concurrent removal or
  view_props write isn't reverted by a stale full-row save.
- Block demotion to guest when the member is the sole admin of a
  project, matching leave()/destroy().
- Fix destroy()'s sole-admin guard, which compared a user id with a
  WorkspaceMember id and never fired; share one helper across all three.
- Bump ProjectMember.updated_at on the guest downgrade; use ROLE.GUEST.
- Tests: cover the downgrade, sole-admin guards, non-object bodies and
  an invalid role that passed the old int() check; use shared fixtures.
@sriramveeraghanta
sriramveeraghanta merged commit 4605f00 into preview Oct 14, 2026
16 checks passed
@sriramveeraghanta
sriramveeraghanta deleted the web-8332/workspace-member-mass-assignment branch October 14, 2026 10:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants