Repository navigation
ref(lint): Flag ORM lookups that may match NULL rows - #129532
Merged
Merged
Conversation
This was referenced Nov 2, 2026
gricha
force-pushed
the
ref/mypy-none-lookup
branch
from
November 2, 2026 21:31
02857ab to
ad91bd2
Compare
gricha
force-pushed
the
ref/mypy-none-lookup
branch
from
November 2, 2026 21:32
ad91bd2 to
01ee7e3
Compare
gricha
force-pushed
the
ref/mypy-none-lookup
branch
from
November 2, 2026 21:44
01ee7e3 to
df3c8c4
Compare
Contributor
Sentry Snapshot Testing
|
gricha
force-pushed
the
ref/mypy-none-lookup
branch
2 times, most recently
from
November 2, 2026 22:16
12a5c55 to
cbb07a4
Compare
gricha
force-pushed
the
ref/mypy-none-lookup
branch
2 times, most recently
from
November 2, 2026 22:51
69cf385 to
769300e
Compare
This was referenced Nov 2, 2026
gricha
force-pushed
the
ref/mypy-none-lookup
branch
2 times, most recently
from
November 2, 2026 23:08
5de3e7c to
22905eb
Compare
gricha
marked this pull request as ready for review
November 2, 2026 23:11
gricha
force-pushed
the
ref/mypy-none-lookup
branch
from
November 2, 2026 23:18
22905eb to
d65b589
Compare
gricha
force-pushed
the
ref/mypy-none-lookup
branch
2 times, most recently
from
November 3, 2026 20:09
2f56c3b to
f52b457
Compare
gricha
force-pushed
the
ref/mypy-none-lookup
branch
from
November 3, 2026 20:51
f52b457 to
eb624bf
Compare
cvxluo
approved these changes
Nov 3, 2026
gricha
force-pushed
the
ref/mypy-none-lookup
branch
from
November 3, 2026 21:10
eb624bf to
0dae3f6
Compare
gricha
added a commit
that referenced
this pull request
Nov 3, 2026
… user (#129538) An API key with `member:admin` could invite owners when the org had a pending owner invite. `get_allowed_org_roles` finds the caller's member row with `user_id=request.user.id`. For an API key that's `None`, which matched pending invites (member rows with no user yet). With one invite, the key got that invitee's role limits, so a pending owner invite let it invite owners. Pending owner invites occur without anyone inviting an owner: when an org starts requiring 2FA, non-compliant members, owners included, are turned back into pending invites. With several invites, `.get()` raised `MultipleObjectsReturned` and the invite request 500'd. The same helper backs the member details and invite-request endpoints. The lookup now goes through `owned_by_acting_user`, which matches nothing without a user, so a caller with no user falls through to the existing no-member case (no roles), the same as when there are no pending invites. Users are unaffected. This also closes a team-restriction bypass in the invite POST. Its "members can only invite into their own teams" check looks up the requester's teams with `user_id=request.user.id`, so with one pending invite a key with only `member:invite` could invite into that invite's teams. That check is only reached when the caller has roles to invite with, and after this fix a caller with no user has none, so it is rejected before the team check. Found by the `request.user.id` None-lookup mypy check in #129532. Stacked on the request.user stack, so `organization_member_utils.py` also leaves #129402's request.user allowlist (23 to 22). Evidence: the two new tests fail on master: ``` E assert 201 == 400 (response: {"email":"jane@gmail.com",...,"orgRole":"owner","pending":true,...}) E assert 500 == 400 (MultipleObjectsReturned: get() returned more than one OrganizationMember -- it returned 2!) FAILED ...::OrganizationMemberListPostTest::test_api_key_does_not_get_a_pending_invites_roles FAILED ...::OrganizationMemberListPostTest::test_api_key_with_several_pending_invites 2 failed, 62 deselected ``` With the fix: `2 passed`, and `test_organization_member_index.py`, `test_organization_member_details.py`, `tests/sentry/models/test_organizationmember.py`: `182 passed`. Production check (Sentry's own telemetry, last 90 days; spans are sampled, counts below are sampled spans): - Invite POST (`members/`): 215 sampled requests, none without a user. Invite-request POST/PUT: 9 sampled, none without a user. Member details PUT already rejects callers with no user. - No `OrganizationMember.MultipleObjectsReturned` errors on any member or invite-request route. - Nothing seen relying on the current behavior. Sampling is about 1 in 200 on the invite POST, so rare API-key invites could be missed.
gricha
force-pushed
the
ref/mypy-none-lookup
branch
from
November 3, 2026 23:19
0dae3f6 to
beed6a9
Compare
Adds lookupMayMatchNullRows to the decision-model linter's security suite. An exact lookup like filter(user_id=x) with x None means user_id IS NULL, which matched pending invites and orphaned rows for callers with no user. The rule warns on changed code and says how to decide what None should mean.
gricha
force-pushed
the
ref/mypy-none-lookup
branch
from
November 4, 2026 00:44
beed6a9 to
8e53441
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
lookupMayMatchNullRowsto the decision-model linter's security suite.An exact ORM lookup whose value can be None, like
filter(user_id=request.user.id)for an API key, compiles touser_id IS NULLand matches rows with no user: pending invites, deleted users' exports, token-created rows. That caused #129441, #129442 and #129443, and the two fixes below this PR (#129538, #129540). Flake8 rule S032 only catches a literalacting_user_id(...)keyword, so it missed all of them.The rule runs on changed files only, at
warn. Its report says how to fix it: decide whether None means no rows (check for None first, orowned_by_acting_user()for the caller's own rows), no filter, or NULL rows (pass None or__isnull=True).An earlier version of this PR was a mypy plugin. On top of the request.user stack it found 147 such lookups in 74 files; that list is the cleanup worklist, handled in follow-up PRs instead of an allowlist here.
Checked locally with this config: the full security suite in diff mode, as CI runs it, on 12 diffs. Seven add one of this week's bugs and five add lookups that are fine. The threshold is 0.7.
request.user.id(#129442)is_memberprojects byrequest.user.id(#129443)request.user.id(#129538)request.user.id(#129540)member.user_id(#129600)__isnull=Falseguardapplication_id is Noneguarddist_id=dist.id if dist else Noneproject_id=None)pipeline_hash(commented as meant)The two misses: #129441's None comes through an untyped parameter and a
==, and #129602 depends onSentryApp.applicationbeing nullable, which the diff doesn't show. A diff that only deletes a guard isn't checked either, since the linter asks about added lines. An earlier, more abstract wording flagged none of the seven.Reviewer cases (re-measured Nov 2, changeset mode, full
securitysuite):UserEmail.objects.filter(user_id=authenticator.user_id), NOT NULL columnfilter(project_id=project_id)where None means the global user optionorganization_service.check_membership_by_id(user_id=request.user.id), lookup behind a service callThe two false-positive risks stay under the 0.7 threshold. A rewording that limits the rule to nullable columns, exempts "None means global" and mentions service calls dropped two real bugs below it (#129540 to 0.63, #129600 to 0.55) and still missed the service call. A lighter one kept 5 of 8 but left #129540 and #129600 at 0.72 and 0.70. So the wording stays; lookups behind service calls are a known gap.