SEC2-021: Calendar files carry private interview notes and company contacts as ATTENDEE, against the form's promise #963

Closed
opened 2026-10-17 10:38:28 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, 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 in DESCRIPTION, contacts as ATTENDEE;CN=...:mailto:)
  • src/postulo/applications/ical.py:211-257 (appointment_component: adviser as ATTENDEE, notes in DESCRIPTION)
  • src/postulo/applications/forms.py:635 and :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:

    description: list[str] = []
    if interview.notes:
        description.append(interview.notes)
    ...
    for person in people:
        if person.email:
            address = without_controls(person.email)
            event.add(
                "attendee",
                icalendar.vCalAddress(f"mailto:{address}"),
                parameters={"CN": ..., "ROLE": "REQ-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 .ics is ("a meeting somebody forwards or imports once", per the InterviewCalendarView docstring), and what the DAV plugin pushes into the person's own CalDAV calendar through event_lines(alarm=True) (plugins/api.py:396), which may be a shared or employer-provided calendar.

ATTENDEE with a mailto: 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:PUBLISH on 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_notes are deliberately left out "because the file goes to a calendar that may be shared" (ical.py:211-220 docstring), yet notes and 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

  1. Drop the ATTENDEE properties from event_component and appointment_component; the "With:" line in DESCRIPTION already names the people. The tests that assert attendee lines change with it: tests/test_interviews.py:448-450 and :570, tests/security/test_exports.py:133.
  2. Leave notes out of calendar()'s feed and the downloads by default, as outcome_notes already is, or gate it on a parameter the download page exposes.
  3. Whatever is decided about 1 and 2, correct both help texts at forms.py:635 and :734 to 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.py asserts no ATTENDEE line and no notes text in interviews.ics and 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.

#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:PUBLISH on what it pushes, its own choice about DESCRIPTION, and its test at tests/test_dav.py:586 asserting the attendee line) belongs in postulo/postulo-dav.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, 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 in `DESCRIPTION`, contacts as `ATTENDEE;CN=...:mailto:`) - `src/postulo/applications/ical.py:211-257` (`appointment_component`: adviser as `ATTENDEE`, notes in `DESCRIPTION`) - `src/postulo/applications/forms.py:635` and `: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: ```python description: list[str] = [] if interview.notes: description.append(interview.notes) ... for person in people: if person.email: address = without_controls(person.email) event.add( "attendee", icalendar.vCalAddress(f"mailto:{address}"), parameters={"CN": ..., "ROLE": "REQ-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 `.ics` is ("a meeting somebody forwards or imports once", per the `InterviewCalendarView` docstring), and what the DAV plugin pushes into the person's own CalDAV calendar through `event_lines(alarm=True)` (`plugins/api.py:396`), which may be a shared or employer-provided calendar. `ATTENDEE` with a `mailto:` 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:PUBLISH` on 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_notes` are deliberately left out "because the file goes to a calendar that may be shared" (`ical.py:211-220` docstring), yet `notes` and 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 1. Drop the `ATTENDEE` properties from `event_component` and `appointment_component`; the "With:" line in `DESCRIPTION` already names the people. The tests that assert attendee lines change with it: `tests/test_interviews.py:448-450` and `:570`, `tests/security/test_exports.py:133`. 2. Leave `notes` out of `calendar()`'s feed and the downloads by default, as `outcome_notes` already is, or gate it on a parameter the download page exposes. 3. Whatever is decided about 1 and 2, correct both help texts at `forms.py:635` and `:734` to 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.py` asserts no `ATTENDEE` line and no notes text in `interviews.ics` and 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:PUBLISH` on what it pushes, its own choice about `DESCRIPTION`, and its test at `tests/test_dav.py:586` asserting the attendee line) belongs in postulo/postulo-dav.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:38:30 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#963
No description provided.