Isolation sweep: staff-only routes, ids outside the address and the API download of a sent document are never tried #422
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#422
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-09-29 in a read-only review of postulo at
95348908d, following eachEXCUSEDentry intests/security/test_isolation_sweep.pyto the test it names, checking what each factory makes, and looking for a cross-account test for every id a request carries outside its address.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atcore/server_views.py:212(PersonAdminView),:235(PersonActiveView),:259(PersonDeleteView) and:384(PersonUsernameView);jobs/forms.py:86-87(OwnerScopedModelForm.scope_querysets);tests/test_server_settings.py:47-51(test_every_section_is_for_administrators_only) and:303(the member's GET ofserver:person_username). The sweep itself, the API routers,accounts/views.py:284and the other tests cited have not moved.Related to #232 (closed), which asked for a sweep of "every view and queryset" and delivered the sweep of ids in a URL, with its
EXCUSEDlist. This issue adds what that sweep excuses without proof or never tries. The code is correct today in every case below: each view has its staff gate and each lookup is narrowed. What is missing is a test that would fail if one of them stopped being so, on the boundary the threat model ranks first.1. Staff-only routes are excused by citing tests that never ask them as a member
tests/security/test_isolation_sweep.py:40-49(EXCUSED, seven entries whose reason is "the gate is" in another file);tests/test_invites.py:217-226(test_invitation_management_requires_staff, parametrised overinvite_listandinvite_createonly, GET only);tests/test_server_settings.py:45-49(test_every_section_is_for_administrators_only, overSECTION_URLS, none of theperson_*routes);tests/test_server_settings.py:255andtests/test_account_deletion.py:251-254(a member's GET only); the views atcore/server_views.py:211(PersonAdminView),:234(PersonActiveView),:258(PersonDeleteView),:383(PersonUsernameView) andaccounts/views.py:284(InviteRevokeView).server:person_adminorserver:person_activeas an ordinary member, by either method.accounts:invite_revokeis only ever posted to by staff.server:person_deleteandserver:person_usernameare asked as a member only with GET. If a refactor swappedStaffRequiredMixinon one of these views forLoginRequiredMixin, a signed-in member could reach the POST that grants administrator rights, suspends or deletes an account, renames a person or withdraws an invitation, and the whole suite would stay green. Members are tested onperson_recovery(tests/test_recovery_links.py:235-240) andperson_plugins(tests/test_plugin_policy.py:272-280), but not in the fileEXCUSEDnames.tests/security/test_staff_only.py. Walk everyserver:route and everyaccounts:invite_*route exceptinvite_accept, filling an argument with another account's pk or a real invitation's pk. Sign in an ordinary member and send GET and an empty POST to each; assert 403 (302 for an anonymous visitor). Assert thatis_staff,is_active,usernameand theInviterows did not change. Point the sevenEXCUSEDreasons at that file.2. Ids that arrive in an API body, a form choice or a formset row are never tried as another account's
tests/security/test_isolation_sweep.py:415-433(patterns_with_arguments, which finds only ids in an address);api/routers/interviews.py:97-106(_contacts),:109-113(add_interview),:151-152(change_interview);api/routers/reminders.py:38-44(add_reminder),:72-82(change_reminder);jobs/forms.py:85-86(OwnerScopedModelForm.scope_querysets, a no-op markedpragma: no cover);tests/test_api.py:337-345("application_id": 999);tests/test_interviews.py:724-741(the "stranger" contact is made withowner=user);docs/THREAT-MODEL.md:34-35.application_idandcontact_idson the interview API andapplication_idon the reminder API have no cross-account test anywhere. The nearest tests use an id that does not exist, or the same person's contact at another company. If a later change looked the application up withApplication.objectsinstead ofowned(request, ...), a token holder could attach interviews or reminders to another account's application and the response would echo that application's company and role back. No test would fail. The same holds for forms:scope_querysetsdoes nothing in the base class, so each subclass has to remember to narrow every related field, and nothing checks that it did. Postal addresses, telephone numbers and web links have no address of their own and are edited through inline formsets, and no test posts another account's row id into one. The referral fields (tests/test_referrals.py:309-331) and the listing named injobs:capture_bind(tests/security/test_listing_history.py) are already tested this way; they show the pattern the other fields lack.docs/THREAT-MODEL.md:34says of postal addresses that "the sweep intests/covers it". It cannot: no URL names a postal address.tests/security/test_isolation_sweep.pylisting route, method, field and a factory for the other person's record: interviewsapplication_id(POST) andcontact_ids(POST and PATCH), remindersapplication_id(POST and PATCH). For each, assert that the answer is identical to the one for a missing id and that nothing was created or changed.OwnerScopedModelFormsubclass for a user while another user also has records, and asserts that noModelChoiceFieldorModelMultipleChoiceFieldoffers the other user's rows.THREAT-MODEL.md:34to name the tests that actually cover addresses.3. The API download of a sent document is never asked for, and two entries get their 404 from a missing file
tests/security/test_isolation_sweep.py:404(postulo-api:document_download, withsource="upload"only);:271(accounts:avatar);:349(jobs:company_logowithpk_of(company);company()at:63-66stores no logo);api/routers/documents.py:158-162(therenderedbranch ofdownload_document);accounts/views.py:220-221(if not picture: raise Http404);jobs/views.py:425-426(if not company.logo: raise Http404).download_documentis tried only for an upload. No test anywhere requests/api/v1/documents/rendered/<pk>/download, for one's own sent document or for somebody else's (tests/test_api.py:175-181and:194download uploads). A change to therenderedbranch that droppedowned_or_404would serve another account's sent CVs and letters, with their home address and telephone number, to any token holdingdocuments:read, and nothing would fail. Separately, the sweep's ownkept_pagefactory explains why a file must be behind a record ("so that a 404 is the lookup refusing and not a file that was never there"), and two entries do not follow it: the avatar entry names a user with no picture and the logo entry a company with no logo, so both views answer 404 whatever their ownership check does. Ownership of both is tested with real files intests/security/test_feeds_and_pictures.py:135-170, so for these two the sweep proves less than it appears to; it is not a gap in coverage.APIentries carry several argument sets, or add a second key such aspostulo-api:document_download#renderedwithpk_of(rendered, source="rendered")and have the three bookkeeping tests strip the suffix. Intests/test_api.py, add a test that a token's owner can download their own rendered document. Give the sweep's avatar and company factories a stored file, askept_pagedoes (profile.avatar.save(...),company.logo.save(...)).Done when
tests/security/test_staff_only.pypasses today and fails whenStaffRequiredMixinis removed from any one ofPersonAdminView,PersonActiveView,PersonDeleteView,PersonUsernameVieworInviteRevokeView.tests/security/test_isolation_sweep.pyfails whenowned(...)is replaced withApplication.objectsinadd_interview,add_reminderorchange_reminder, or withContact.objectsin_contacts.scope_querysets.tests/security/test_isolation_sweep.pyfails whenowned_or_404in therenderedbranch ofdownload_documentis replaced with an unscoped lookup, and its avatar and logo entries still fail when the ownership check is removed fromAvatarVieworCompanyLogoView.tests/test_api.pydownloads one's own rendered document through/api/v1/documents/rendered/<pk>/downloadand gets the file.docs/THREAT-MODEL.mdno longer says the sweep covers postal addresses.Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#422).