|
| 1 | +--- |
| 2 | +title: "Incident Report: Organizations Team privileges" |
| 3 | +description: We responded to an incident related to privileges persisting |
| 4 | + via Organization Teams after Members are removed from Organizations. |
| 5 | +authors: |
| 6 | + - ewdurbin |
| 7 | +date: 2025-04-14 |
| 8 | +tags: |
| 9 | + - transparency |
| 10 | + - security |
| 11 | +--- |
| 12 | + |
| 13 | +On April 14, 2025 <[email protected]> was notified of a potential security concern |
| 14 | +relating to privileges granted to a PyPI User via Organization Teams membership |
| 15 | +persisting after the User was removed from the PyPI Organization the Team belongs to. |
| 16 | + |
| 17 | +We validated the report as a true finding, identified all cases where this scenario |
| 18 | +had occurred, notified impacted parties, and released a fix. |
| 19 | +A full audit determined that all instances were accounted for, |
| 20 | +with no unauthorized actions taken as a result of the issue. |
| 21 | + |
| 22 | +<!-- more --> |
| 23 | + |
| 24 | +## Timeline of events |
| 25 | + |
| 26 | +- 2025-04-14 16:37 UTC |
| 27 | + A PyPI User who has been testing out our Organizations features noticed the issue |
| 28 | + and reported it according to our [Security Policy](https://pypi.org/security/) |
| 29 | + |
| 30 | +- 2025-04-14 17:02 UTC |
| 31 | + PyPI Security acknowledges receipt. |
| 32 | +- 2025-04-14 17:22 UTC |
| 33 | + PyPI Security validates the report as a true finding. |
| 34 | +- 2025-04-14 17:58 UTC |
| 35 | + PyPI Security validating test and hot fix prepared for internal review. |
| 36 | +- 2025-04-14 18:30 UTC |
| 37 | + PyPI Security removes invalid Team Membership and notifies the owners of the only |
| 38 | + other actively impacted Organization. |
| 39 | + [public PR](https://github.com/pypi/warehouse/pull/17957) opened with fix. |
| 40 | +- 2025-04-14 18:33 UTC |
| 41 | + Hot fix is merged. |
| 42 | +- 2025-04-14 18:39 UTC |
| 43 | + Hot fix deployed and live on PyPI. |
| 44 | +- 2025-04-14 19:06 UTC |
| 45 | + Security audit complete, validating that only two instances of this had |
| 46 | + occurred, with no unauthorized actions taken as a result of the persisted |
| 47 | + privileges. |
| 48 | + |
| 49 | +## Details |
| 50 | + |
| 51 | +PyPI Organizations have been a feature on PyPI since they were first enabled |
| 52 | +on April 20, 2023. |
| 53 | +This issue was introduced in the initial development of Organizations features, |
| 54 | +and was mitigated April 14, 2025. |
| 55 | + |
| 56 | +PyPI Organizations are quickly seeing more use as we (finally) exit our public beta |
| 57 | +period. In the last month we have gone from 70 Community Organization beta testers |
| 58 | +to 1,935 active Organizations[^1], so it is of little surprise that we are surfacing a few |
| 59 | +more issues as a result. |
| 60 | + |
| 61 | +Thanks to PyPI's strong test coverage identifying and validating the issue was rather |
| 62 | +trivial, and getting a fix prepared and out the door was straight forward. |
| 63 | + |
| 64 | +In total, this incident was resolved in 2 hours and 2 minutes from the time of report. |
| 65 | + |
| 66 | +## Response |
| 67 | + |
| 68 | +Given that this is an otherwise straightforward bug, I thought I would take a moment |
| 69 | +to share how the issue was validated as well as how we audited. |
| 70 | +I've replaced the specific organization, team, and user strings below, |
| 71 | +but otherwise all of this is copied and pasted from the terminal session used |
| 72 | +as I worked this report. |
| 73 | + |
| 74 | +I spun up a local development environment of |
| 75 | +[pypi/warehouse](https://github.com/pypi/warehouse) |
| 76 | +from the current `main` branch locally and followed the reporter's steps to reproduce: |
| 77 | + |
| 78 | +> The basic reproduce steps were: |
| 79 | +> |
| 80 | +> 1. Add a user to an organization as a member |
| 81 | +> 2. Add that member to a organization team |
| 82 | +> 3. Remove the member from the organization |
| 83 | +
|
| 84 | +Noting that indeed, the User's team role persisted, and they could continue to act |
| 85 | +with those privileges on PyPI. |
| 86 | + |
| 87 | +At that point the reporter and PyPI Administrators team were notified that we had a |
| 88 | +finding, and that review would be needed shortly to get a fix merged and deployed. |
| 89 | + |
| 90 | +From there, I added a |
| 91 | +[failing test](https://github.com/pypi/warehouse/pull/17957/commits/33707f0ad72e4d2efacf85fd0488e0c42fca47e6) |
| 92 | +which further validated the issue, and got to work creating a |
| 93 | +[patch](https://github.com/pypi/warehouse/pull/17957/commits/34a40178ee7d0e048e45867a9d8f76497f68da8c) |
| 94 | +which turned the test green. |
| 95 | + |
| 96 | +Now, with time to wait while a volunteer PyPI Admin returned I focused on assessing |
| 97 | +if this was actively impacting any other organizations: |
| 98 | + |
| 99 | +``` |
| 100 | +warehouse=> select |
| 101 | + o.name as organization, |
| 102 | + t.name as team_name, |
| 103 | + u.username as user, |
| 104 | + tr. role_name as team_role, |
| 105 | + ors. role_name as organization role |
| 106 | +from |
| 107 | + team_roles tr |
| 108 | + join teams t |
| 109 | + on t.id=tr.team_id |
| 110 | + join organizations o |
| 111 | + on t.organization_id=o.id |
| 112 | + join users u |
| 113 | + on u.id=tr.user_id |
| 114 | + left outer join organization roles ors |
| 115 | + on ors.organization_id=t.organization_id and ors.user_id=tr.user_id |
| 116 | +where |
| 117 | + ors. role_name is null; |
| 118 | + organization | team_name | user | team_role | organization_role |
| 119 | +--------------+-------------+-----------+-----------+------------------- |
| 120 | + spam | Spam-owners | spamlover | Member | |
| 121 | +(1 row) |
| 122 | +``` |
| 123 | + |
| 124 | +This query showed me that one instance of a User having an Organization Team Role |
| 125 | +_without_ being a Member of that Organization still existed on PyPI[^2]. |
| 126 | +The reporter made clear that they had already resolved the instance from their testing. |
| 127 | + |
| 128 | +I drafted a notice to the five users with role `Owner` on the impacted Organization, |
| 129 | +and took a moment to realize that this was our first time emailing Organization |
| 130 | +Owners as a group, and that we needed to account for the fact that Users on PyPI |
| 131 | +do not necessarily already know one-another's email addresses, as it is not required |
| 132 | +to invite them to a Project or Organization. A quick gut-check in the PyPI Moderators |
| 133 | +channel validated my plan to `Bcc:` all the Owners rather than `To:` them as a |
| 134 | +group.[^3] |
| 135 | + |
| 136 | +By that point, the volunteer PyPI Administrator was available to review the PR and |
| 137 | +drafted e-mail. We notified the impacted Organization, and then coordinated to |
| 138 | +open the PR publicly and approve/merge it hastily before completing a more in-depth |
| 139 | +audit. |
| 140 | + |
| 141 | +Luckily this audit was straightforward using our internal security records |
| 142 | +combined with the fact that there has been minimal churn in the Organization membership |
| 143 | +in the short time that Organizations has been in broader use. |
| 144 | + |
| 145 | +``` |
| 146 | +warehouse=> select |
| 147 | + o.name, time, tag, u.username |
| 148 | +from |
| 149 | + organization_events oe |
| 150 | + join users u |
| 151 | + on (additional->>'target_user_id')::uuid=u.id |
| 152 | + join organizations o on oe.source_id=o.id |
| 153 | +where |
| 154 | + tag in ('organization:team_role:remove', 'organization:organization_role:remove') |
| 155 | +order by time; |
| 156 | + name | time | tag | username |
| 157 | +------------+----------------------------+---------------------------------------+------------------- |
| 158 | + lumberjack | 2023-05-02 03:01:18.935901 | organization:organization_role:remove | sirrobin |
| 159 | + holygrail | 2023-07-06 12:55:43.261593 | organization:organization_role:remove | blackknight |
| 160 | + ni | 2023-09-18 12:07:17.389244 | organization:organization_role:remove | shrubbery |
| 161 | + parrot | 2024-02-04 19:23:25.354344 | organization:organization_role:remove | exparrot |
| 162 | + spam | 2024-08-24 01:40:22.405746 | organization:organization_role:remove | spamlover |
| 163 | + spam | 2025-02-09 18:14:13.891224 | organization:team_role:remove | eggandspam |
| 164 | + albatross | 2025-03-07 06:55:29.446617 | organization:organization_role:remove | nudge |
| 165 | + albatross | 2025-03-07 06:55:37.271176 | organization:organization_role:remove | wink |
| 166 | + cheese | 2025-03-13 18:25:54.650905 | organization:team_role:remove | gorgonzola |
| 167 | + cheese | 2025-03-13 18:26:02.525162 | organization:team_role:remove | camembert |
| 168 | + ministry | 2025-03-20 07:53:45.616404 | organization:organization_role:remove | sillywalks |
| 169 | + argument | 2025-03-31 15:52:18.186223 | organization:organization_role:remove | contradiction |
| 170 | + fishslap | 2025-04-14 15:12:14.023183 | organization:organization_role:remove | danceking |
| 171 | + fishslap | 2025-04-14 15:24:54.208641 | organization:organization_role:remove | danceking |
| 172 | + fishslap | 2025-04-14 15:27:22.954624 | organization:team_role:remove | danceking |
| 173 | +``` |
| 174 | + |
| 175 | +Here, we see the `spamlover` user being removed from the `spam` Organization |
| 176 | +on `2024-08-24`, without being removed from the team, confirming our finding from the |
| 177 | +earlier query. |
| 178 | + |
| 179 | +We also see the User `danceking` from the `fishslap` Organization being removed from |
| 180 | +the Organization multiple times, before the reporter removed them from their assigned |
| 181 | +Team. |
| 182 | + |
| 183 | +This allowed us to confirm that beyond the already identified incidents, |
| 184 | +no other Organizations had found this problem before without letting us know. |
| 185 | + |
| 186 | +## Thanks |
| 187 | + |
| 188 | +First and foremost, thanks to our reporters, Matthew Treinish and Jake Lishman |
| 189 | +of IBM Quantum for finding and reporting this issue. |
| 190 | + |
| 191 | +We are grateful for the entire community of security researchers and users who |
| 192 | +find and report security issues to PyPI in accordance with our |
| 193 | +[Security Policy](https://pypi.org/security/). |
| 194 | +PyPI relies on the efforts of our community to help us find and resolve issues like |
| 195 | +these before they become critical issues. |
| 196 | +Cooperation between all parties helps to improve the security of open source, |
| 197 | +and none of us could do it alone. |
| 198 | + |
| 199 | +The tools and capabilities we've evolved in PyPI over the past six years have really |
| 200 | +come to be an asset in situations like these. I'm grateful to all the contributors |
| 201 | +and admins who have helped us to build them 💜. |
| 202 | + |
| 203 | +--- |
| 204 | + |
| 205 | +_Ee Durbin is the Director of Infrastructure at |
| 206 | +the Python Software Foundation. |
| 207 | +They have been contributing to keeping PyPI online, available, and |
| 208 | +secure since 2013._ |
| 209 | + |
| 210 | +[^1]: |
| 211 | + As of writing, there are 6,682 remaining Organization Requests to review. |
| 212 | + |
| 213 | +[^2]: |
| 214 | + It also showed me that our modeling could certainly be improved. |
| 215 | + In general all the joins are fine, but the fact that a `TeamRole` is directly |
| 216 | + related to a `User` rather than to their `OrganizationRole` allowed for this |
| 217 | + disconnect in the first place. |
| 218 | + |
| 219 | +[^3]: |
| 220 | + Another thing to work on moving forward. We recently added some "in-app" messaging |
| 221 | + for PyPI Admins and Support to contact users regarding Organization Requests, |
| 222 | + which could be useful for group communication with Organization Owners. |
0 commit comments