SEC2-021: Calendar files carry private interview notes and company contacts as ATTENDEE, against the form's promise #963
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#963
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: Low · Category: Data Exposure · Reference: CWE-359 (Exposure of Private Personal Information to an Unauthorized Actor); OWASP A01:2021 (Broken Access Control)
Location:
src/postulo/applications/ical.py:138-197(event_component: notes inDESCRIPTION, contacts asATTENDEE;CN=...:mailto:)src/postulo/applications/ical.py:211-257(appointment_component: adviser asATTENDEE, notes inDESCRIPTION)src/postulo/applications/forms.py:635and:734(help text: "Yours, to prepare with. Nothing here is sent to anybody.")src/postulo/applications/views.py:1313-1345(InterviewCalendarView),src/postulo/applications/views_ical.py:31-57(CalendarDownloadView),src/postulo/applications/agenda.py:974(download_for)Issue
Every iCalendar output of an interview writes the person's private preparation notes into the event and each company contact as a participant:
The interview form tells the person the opposite about the notes field (
forms.py:635,:734: "Nothing here is sent to anybody"). The same component is what the subscribed feed serves (interviews.ics), what Download on the calendar page writes, what the single-interview.icsis ("a meeting somebody forwards or imports once", per theInterviewCalendarViewdocstring), and what the DAV plugin pushes into the person's own CalDAV calendar throughevent_lines(alarm=True)(plugins/api.py:396), which may be a shared or employer-provided calendar.ATTENDEEwith amailto:is not inert metadata. Several calendar clients treat an imported or synced event's attendees as invitees and offer to send them updates when the event is edited or deleted, and some send them.METHOD:PUBLISHon the wrapper (ical.py:_wrapper, RFC 5546) tells a client the file is not an invitation, but that is advisory, and it is not written at all on the per-event lines the DAV plugin pushes. The appointment component already applies the right instinct to half of this:outcome_notesare deliberately left out "because the file goes to a calendar that may be shared" (ical.py:211-220docstring), yetnotesand the adviser's address go in.Impact
The account holder, not an attacker: a recruiter or hiring manager can receive a calendar update from the person's calendar application, exposing that the person keeps a tracker and whatever the event title says, and a shared calendar's readers see the private notes and the contact's address. No cross-account exposure.
Fix
ATTENDEEproperties fromevent_componentandappointment_component; the "With:" line inDESCRIPTIONalready names the people. The tests that assert attendee lines change with it:tests/test_interviews.py:448-450and:570,tests/security/test_exports.py:133.notesout ofcalendar()'s feed and the downloads by default, asoutcome_notesalready is, or gate it on a parameter the download page exposes.forms.py:635and:734to say the notes are written into the calendar files and, where a calendar plugin is connected, into that calendar.Done when
tests/security/test_feeds_and_pictures.pyasserts noATTENDEEline and no notes text ininterviews.icsand the calendar download for an interview with a contact who has an email address and a note, and the help texts say where the notes go.Related
#661 (closed) - designed the library-based writer and kept the attendee lines by design. #627 (closed) - wording of the "With" line. #218 (closed) - line injection in the feeds only. #330 (open) - the plugin surface for
event_lines; does not cover this. The plugin-side half (METHOD:PUBLISHon what it pushes, its own choice aboutDESCRIPTION, and its test attests/test_dav.py:586asserting the attendee line) belongs in postulo/postulo-dav.