[INFRA-772] fix(security): stop trusting body-supplied created_by/created_at on external API

Three duplicate/related reports, one mechanism: POST/PATCH handlers for
work items, comments and issue links let a caller set created_by and
created_at directly from the request body, even though the model already
auto-populates created_by correctly on create (BaseModel.save() reads the
authenticated user via crum). The escalation: forge created_by via PATCH
on an existing issue, then pass IssueDetailAPIEndpoint.delete's "admin OR
creator" gate as a plain project member — deleting arbitrary work items.

Root cause had two independent halves:

- Four view-level blocks (issue POST, issue PUT's external-id upsert
  create branch, comment POST, issue-link POST) re-fetched the row a
  serializer.save() had just correctly created — turning it into an
  UPDATE, where BaseModel.save()'s auto-created_by protection does not
  apply — then overwrote created_by/created_at straight from
  request.data. All four removed entirely; the initial serializer.save()
  already produces the right created_by unaided.
- IssueSerializer never marked created_by read-only (unlike updated_by
  right beside it, and unlike created_at, which Django's auto_now_add
  protects independently). That let a plain PATCH on an *existing* issue
  set created_by directly via the serializer — this is what actually
  makes the delete-escalation chain possible, since PATCH never touched
  the view-level override blocks at all. Added to
  IssueSerializer.Meta.read_only_fields.

Comment and issue-link PATCH already used properly-protected serializers
(created_by already read-only, or absent from fields entirely) — their
only gap was the POST-path override, now removed.

Verified via fail-before: reverted the fix, 5 of 6 new tests correctly
failed (forged attacker-controlled created_by landing in the DB); the
2 that passed regardless are intentional (a same-value no-op and a
positive control). Confirmed IssueDetailAPIEndpoint.put has no route
mounted anywhere (only ["get", "post"] on IssueListCreateAPIEndpoint) —
cleaned up the same override there for consistency, but it's unreachable
dead code, not a live vector.

Tests: 6 new (apps/api/plane/tests/contract/api/test_created_by_forgery.py).
Broader regression sweep (issue/comment/link/notification suites, 49
tests) green. ruff clean.

Co-authored-by: Plane AI <noreply@plane.so>
This commit is contained in:
Manish Gupta
2026-08-28 09:33:16 +05:30
parent ddac107ae0
commit 63f4a7c4da
3 changed files with 256 additions and 26 deletions

View File

@@ -69,7 +69,22 @@ class IssueSerializer(BaseSerializer):
class Meta:
model = Issue
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at", "completed_at"]
# SECURITY: created_by must stay read-only, matching updated_by right beside it.
# created_by is a plain FK with no auto_now_add-style protection, so leaving it
# out of this list let any project member set an issue's creator to an arbitrary
# user via PATCH — serializer.update() setattrs it before BaseModel.save() runs,
# and BaseModel.save() only auto-protects created_by on create (self._state.adding),
# never on update. That forged created_by then passed IssueDetailAPIEndpoint.delete's
# "creator can delete" check, letting a member delete work items only an admin should.
read_only_fields = [
"id",
"workspace",
"project",
"created_by",
"updated_by",
"updated_at",
"completed_at",
]
exclude = ["description_json", "description_stripped"]
def validate(self, data):

View File

@@ -489,11 +489,6 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
)
serializer.save()
# Refetch the issue
issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=serializer.data["id"]).first()
issue.created_at = request.data.get("created_at", timezone.now())
issue.created_by_id = request.data.get("created_by", request.user.id)
issue.save(update_fields=["created_at", "created_by"])
# Track the issue
issue_activity.delay(
@@ -703,19 +698,6 @@ class IssueDetailAPIEndpoint(BaseAPIView):
# issue activity worker event as created
if serializer.is_valid():
serializer.save()
# Refetch the issue
issue = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
pk=serializer.data["id"],
).first()
# If any of the created_at or created_by is present, update
# the issue with the provided data, else return with the
# default states given.
issue.created_at = request.data.get("created_at", timezone.now())
issue.created_by_id = request.data.get("created_by", request.user.id)
issue.save(update_fields=["created_at", "created_by"])
issue_activity.delay(
type="issue.activity.created",
@@ -1198,8 +1180,6 @@ class IssueLinkListCreateAPIEndpoint(BaseAPIView):
serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(serializer.instance.id, serializer.instance.url)
link = IssueLink.objects.get(pk=serializer.instance.id)
link.created_by_id = request.data.get("created_by", request.user.id)
link.save(update_fields=["created_by"])
issue_activity.delay(
type="link.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
@@ -1481,11 +1461,6 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView):
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id, actor=request.user)
issue_comment = IssueComment.objects.get(pk=serializer.instance.id)
# Update the created_at and the created_by and save the comment
issue_comment.created_at = request.data.get("created_at", timezone.now())
issue_comment.created_by_id = request.data.get("created_by", request.user.id)
issue_comment.actor_id = request.data.get("created_by", request.user.id)
issue_comment.save(update_fields=["created_at", "created_by"])
issue_activity.delay(
type="comment.activity.created",

View File

@@ -0,0 +1,240 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Regression tests for the created_by/created_at forgery class on the
external API — authorship and audit-trail forgery via body-controlled fields
on issue, comment and issue-link create/update endpoints.
Root cause: view-level code re-fetched a just-created row (turning it into an
update, where BaseModel.save()'s auto-created_by protection does not apply)
and then wrote created_by/created_at straight from request.data. Separately,
IssueSerializer never marked created_by read-only, so a plain PATCH on an
existing issue could set it directly via the serializer.
Fixed by: removing the four view-level override blocks (issue POST, issue PUT
upsert-create branch, comment POST, issue-link POST) entirely — the initial
serializer.save() already produces the correct created_by via BaseModel's
crum-based auto-set on create, which needs no help — and adding created_by to
IssueSerializer.Meta.read_only_fields, closing the PATCH vector directly.
The escalation chain: forge created_by via PATCH, then pass
IssueDetailAPIEndpoint.delete's "admin OR creator" gate as a plain member.
Test `test_patch_then_delete_forgery_chain_is_blocked` exercises the whole
chain end to end, not just the individual forgery.
"""
from uuid import uuid4
import pytest
from rest_framework import status
from plane.db.models import APIToken, Issue, IssueComment, IssueLink, Project, ProjectMember, State
def _create_issue_as(creator, **kwargs):
"""BaseModel.save() sets created_by from crum's current request/user, and
there is no active request in a fixture — get_current_user() returns None
there, and the model then *nulls* created_by regardless of what the
constructor was given. save(created_by_id=...) is the documented escape
hatch for exactly this case (see BaseModel.save's signature)."""
obj = Issue(**kwargs)
obj.save(created_by_id=creator.id)
return obj
pytestmark = pytest.mark.contract
@pytest.fixture
def admin_user(db, create_user):
"""The project admin and original creator of everything in these tests."""
return create_user
@pytest.fixture
def member_user(db):
"""A second, lower-privileged account — the attacker in every test here."""
from plane.db.models import User
unique = uuid4().hex[:8]
user = User.objects.create(email=f"member-{unique}@plane.so", username=f"member_{unique}")
user.set_password("member-password")
user.save()
return user
@pytest.fixture
def project(db, workspace, admin_user, member_user):
project = Project.objects.create(
name="Test Project",
identifier="TPF",
workspace=workspace,
created_by=admin_user,
)
ProjectMember.objects.create(project=project, member=admin_user, role=20, is_active=True)
ProjectMember.objects.create(project=project, member=member_user, role=15, is_active=True)
return project
@pytest.fixture
def state(db, workspace, project):
return State.objects.create(name="Todo", project=project, workspace=workspace, group="backlog", default=True)
@pytest.fixture
def issue(db, workspace, project, state, admin_user):
"""An issue created by the admin — member_user must never be able to
reattribute this to themselves."""
return _create_issue_as(
admin_user,
name="Admin's issue",
workspace=workspace,
project=project,
state=state,
)
@pytest.fixture
def member_api_client(api_client, member_user):
"""External-API client authenticated as the low-privilege member."""
token = APIToken.objects.create(user=member_user, label="Member Token", token=f"member-token-{uuid4().hex}")
api_client.credentials(HTTP_X_API_KEY=token.token)
return api_client
def _issues_url(slug, project_id):
return f"/api/v1/workspaces/{slug}/projects/{project_id}/issues/"
def _issue_detail_url(slug, project_id, pk):
return f"/api/v1/workspaces/{slug}/projects/{project_id}/issues/{pk}/"
def _links_url(slug, project_id, issue_id):
return f"/api/v1/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/links/"
def _comments_url(slug, project_id, issue_id):
return f"/api/v1/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/comments/"
@pytest.mark.django_db
class TestIssueCreateIgnoresBodyCreatedBy:
def test_post_sets_created_by_to_the_caller_not_the_body(
self, member_api_client, workspace, project, state, admin_user, member_user
):
url = _issues_url(workspace.slug, project.id)
response = member_api_client.post(
url,
{"name": "Spoofed issue", "state": str(state.id), "created_by": str(admin_user.id)},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED, f"got {response.status_code}: {response.data!r}"
created = Issue.objects.get(pk=response.data["id"])
assert created.created_by_id == member_user.id, (
"created_by must be the authenticated caller regardless of what the body requested"
)
assert created.created_by_id != admin_user.id
# No test for IssueDetailAPIEndpoint.put's external_id-upsert create branch:
# confirmed against apps/api/plane/api/urls/work_item.py that
# IssueListCreateAPIEndpoint is only ever mounted with
# http_method_names=["get", "post"] (both old_url_patterns and
# new_url_patterns) — put() is unreachable dead code. Cleaned up the same
# override there anyway for consistency, but there's no route to test it
# through.
@pytest.mark.django_db
class TestIssuePatchCannotForgeCreatedBy:
def test_patch_cannot_reattribute_to_the_caller(
self, member_api_client, workspace, project, issue, admin_user, member_user
):
"""A project member may edit fields on an issue they don't own, but
created_by must remain the original creator (admin_user here) — it is
not a field this endpoint's contract lets anyone change. Targets
member_user specifically (not admin_user, the value already in place)
so a no-op forgery attempt can't masquerade as a passing test."""
url = _issue_detail_url(workspace.slug, project.id, issue.id)
response = member_api_client.patch(
url, {"name": "Edited by member", "created_by": str(member_user.id)}, format="json"
)
assert response.status_code == status.HTTP_200_OK, f"got {response.status_code}: {response.data!r}"
issue.refresh_from_db()
assert issue.created_by_id == admin_user.id, (
"created_by must be immutable after creation — a PATCH must never reattribute an issue"
)
assert issue.name == "Edited by member", "the legitimate field in the same request must still apply"
@pytest.mark.django_db
class TestForgeryThenDeleteChainIsBlocked:
def test_patch_then_delete_is_still_403_for_a_plain_member(
self, member_api_client, workspace, project, issue, admin_user, member_user
):
"""The full escalation chain: forge created_by via PATCH, then rely on
the DELETE handler's "creator can delete" gate. Since the PATCH no
longer forges anything, the DELETE must still refuse a plain member.
"""
patch_url = _issue_detail_url(workspace.slug, project.id, issue.id)
member_api_client.patch(patch_url, {"created_by": str(member_user.id)}, format="json")
issue.refresh_from_db()
assert issue.created_by_id != member_user.id, "forgery must not have landed before we even try the delete"
delete_url = _issue_detail_url(workspace.slug, project.id, issue.id)
response = member_api_client.delete(delete_url)
assert response.status_code == status.HTTP_403_FORBIDDEN, (
f"a plain member who is not the real creator must not be able to delete; got {response.status_code}"
)
assert Issue.objects.filter(pk=issue.id).exists(), "the issue must not have been deleted"
def test_real_creator_can_still_delete_their_own_issue(
self, member_api_client, workspace, project, state, member_user
):
"""Positive control: the fix must not break the legitimate creator-delete path."""
own_issue = _create_issue_as(
member_user, name="Member's own issue", workspace=workspace, project=project, state=state
)
url = _issue_detail_url(workspace.slug, project.id, own_issue.id)
response = member_api_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT, f"got {response.status_code}: {response.data!r}"
@pytest.mark.django_db
class TestCommentCreateIgnoresBodyCreatedBy:
def test_post_comment_sets_created_by_to_the_caller(
self, member_api_client, workspace, project, issue, admin_user, member_user
):
url = _comments_url(workspace.slug, project.id, issue.id)
response = member_api_client.post(
url,
{"comment_html": "<p>please approve the payment</p>", "created_by": str(admin_user.id)},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED, f"got {response.status_code}: {response.data!r}"
comment = IssueComment.objects.get(pk=response.data["id"])
assert comment.created_by_id == member_user.id
assert comment.created_by_id != admin_user.id
assert comment.actor_id == member_user.id, "actor (the audit-log identity) must also be the real caller"
@pytest.mark.django_db
class TestIssueLinkCreateIgnoresBodyCreatedBy:
def test_post_link_sets_created_by_to_the_caller_not_null_or_forged(
self, member_api_client, workspace, project, issue, admin_user, member_user
):
url = _links_url(workspace.slug, project.id, issue.id)
response = member_api_client.post(
url,
{"title": "Spec doc", "url": "https://example.com/spec", "created_by": str(admin_user.id)},
format="json",
)
assert response.status_code == status.HTTP_201_CREATED, f"got {response.status_code}: {response.data!r}"
link = IssueLink.objects.get(pk=response.data["id"])
assert link.created_by_id == member_user.id, "must be the real caller, not forged and not NULL"
assert link.created_by_id != admin_user.id