SEC2-010: The API's iCalendar feeds carry no Cache-Control, which #788's middleware gives only to attachments #952

Closed
opened 2026-10-17 10:38:02 +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-525 (Use of Web Browser Cache Containing Sensitive Information); OWASP A05:2021 (Security Misconfiguration)

Location:

  • src/postulo/api/routers/interviews.py:73-83
  • src/postulo/api/routers/reminders.py:42-47
  • src/postulo/core/middleware.py:301-325 (keys on Content-Disposition: attachment)
  • src/postulo/core/middleware.py:351-375 (keys on a session user and text/html)
  • src/postulo/api/api.py:886-912 (urls(), which already rewrites every pattern's callback)

Issue

SECURITY.md (line 30) promises files are delivered with Cache-Control: no-store, and #788
made that hold for every download by giving the header to any response that is an attachment.
The two API feeds are plain bodies, not attachments, and set nothing themselves:

@router.get("/calendar.ics", summary="Everything still ahead, as an iCalendar file")
def calendar_feed(request):
    text = ical.calendar(_queryset(request).upcoming(), ..., appointments=...)
    return HttpResponse(text, content_type="text/calendar; charset=utf-8")

PrivatePagesAreNotKeptMiddleware does not reach them either: it asks for a session user
and text/html, and a token call has neither. The same is true of every JSON answer of the
API (applications with their timelines, a company's contacts, GET /profile with birth data
and nationalities): no Cache-Control at all. A shared cache must not store a response to a
request carrying Authorization without an explicit directive (RFC 9111 §3.5), which is
what keeps this Low; a browser's own cache, a browser extension's fetch and some proxies are
under no such rule. The one-interview file at interviews.py:96-104 is an attachment and is
covered.

Impact

Interview dates, places, notes and reminders, and whatever an agent read through the API,
can stay in the HTTP cache of the machine that asked for them, after the token is revoked or
the person signs out, where the web page of the same data cannot.

Fix

Wrap every pattern in urls() (api.py:886-912, which already rewrites each callback for
405s and transactions) so that any API response without a Cache-Control of its own gets
private, max-age=0, no-store; openapi.json is a ninja pattern and is covered with the rest.
Setting the header on the two feeds alone would keep the SECURITY.md sentence true but leave
the JSON answers uncovered. Done when tests/security/test_downloads_not_cached.py asserts the
header on /api/v1/interviews/calendar.ics, /api/v1/reminders/calendar.ics and one JSON list.

#788 (closed) - the same promise for the generated downloads; its fix keyed on
Content-Disposition: attachment, which the API feeds and JSON answers do not carry, so this
is the part it did not reach. #787 (closed) - the HTML back/forward-cache half.

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-525 (Use of Web Browser Cache Containing Sensitive Information); OWASP A05:2021 (Security Misconfiguration) **Location:** - `src/postulo/api/routers/interviews.py:73-83` - `src/postulo/api/routers/reminders.py:42-47` - `src/postulo/core/middleware.py:301-325` (keys on `Content-Disposition: attachment`) - `src/postulo/core/middleware.py:351-375` (keys on a session user and `text/html`) - `src/postulo/api/api.py:886-912` (`urls()`, which already rewrites every pattern's callback) ## Issue `SECURITY.md` (line 30) promises files are delivered with `Cache-Control: no-store`, and #788 made that hold for every download by giving the header to any response that is an attachment. The two API feeds are plain bodies, not attachments, and set nothing themselves: ```python @router.get("/calendar.ics", summary="Everything still ahead, as an iCalendar file") def calendar_feed(request): text = ical.calendar(_queryset(request).upcoming(), ..., appointments=...) return HttpResponse(text, content_type="text/calendar; charset=utf-8") ``` `PrivatePagesAreNotKeptMiddleware` does not reach them either: it asks for a session user and `text/html`, and a token call has neither. The same is true of every JSON answer of the API (applications with their timelines, a company's contacts, `GET /profile` with birth data and nationalities): no `Cache-Control` at all. A shared cache must not store a response to a request carrying `Authorization` without an explicit directive (RFC 9111 §3.5), which is what keeps this Low; a browser's own cache, a browser extension's fetch and some proxies are under no such rule. The one-interview file at `interviews.py:96-104` is an attachment and is covered. ## Impact Interview dates, places, notes and reminders, and whatever an agent read through the API, can stay in the HTTP cache of the machine that asked for them, after the token is revoked or the person signs out, where the web page of the same data cannot. ## Fix Wrap every pattern in `urls()` (`api.py:886-912`, which already rewrites each callback for 405s and transactions) so that any API response without a `Cache-Control` of its own gets `private, max-age=0, no-store`; `openapi.json` is a ninja pattern and is covered with the rest. Setting the header on the two feeds alone would keep the `SECURITY.md` sentence true but leave the JSON answers uncovered. Done when `tests/security/test_downloads_not_cached.py` asserts the header on `/api/v1/interviews/calendar.ics`, `/api/v1/reminders/calendar.ics` and one JSON list. ## Related #788 (closed) - the same promise for the generated downloads; its fix keyed on `Content-Disposition: attachment`, which the API feeds and JSON answers do not carry, so this is the part it did not reach. #787 (closed) - the HTML back/forward-cache half.
tiagoagueda added this to the 0.6.0 milestone 2026-10-17 10:38:03 +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#952
No description provided.