SEC2-047: Removing the last members of a record-owning group is check-then-act without a lock, so concurrent removals can orphan its records #989
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#989
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-10-16 in a read-only security review of
postuloat56fc9d531, checked by a second, independent pass.Severity: Informational · Category: Business Logic · Reference: CWE-367 (Time-of-check Time-of-use Race Condition); OWASP A04:2021 (Insecure Design)
Location:
src/postulo/core/groups.py:110-122(_before_leaving)src/postulo/core/groups.py:143-154(membership_changed,pre_remove)src/postulo/core/views_access.py:90-98(members set insidetransaction.atomic(), no lock)Issue
The module promises that "the last member of a group that still owns records cannot leave it". The check reads the membership table and the removal then writes it, inside the request transaction, with no
select_for_updateon the group or its memberships. On PostgreSQL two requests removing the two remaining members (two administrators on Server settings → Groups, or an administrator and the member leaving from Your groups) both see one member staying, both pass, and the group is left owning companies, contacts or listings with nobody in it. SQLite's single writer makes it moot there, asaccounts/adapter.py:21-27notes for the invitation lock.Impact
Records owned by a group nobody belongs to are reachable only through the operator's conflicts page, and
jobs/migrations/0036andcore/migrations/0037refuse to reverse ("has no member to give them to") while such a group exists. Integrity rather than exposure, and it needs PostgreSQL and two simultaneous actions, hence Informational.Fix
In
membership_changedonpre_remove/pre_clear, lock each group inpairsbefore_before_leavingruns (Group.objects.select_for_update().get(pk=group.pk); both callers are inside an atomic block), the wayaccounts/adapter.py:33-36locks the invitation, so the second removal waits for the first's commit and then sees no member staying. Django ignoresselect_for_updateon SQLite, so no branch is needed there. Done when: a test in the PostgreSQL CI job removes the last two members from two connections and one of them is refused (a SQLite unit test cannot show it).Related
#896 (closed) - introduced the rule. #826 (closed) - fixed the same shape of race for the first-account-becomes-administrator check with
select_for_update.