PST-058: Authenticated HTML pages carry no Cache-Control: no-store, so Back after sign-out can restore personal data on a shared browser #787

Closed
opened 2026-10-10 10:56:30 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 8 to 10 October 2026, as PST-058. Severity: Low.

The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.


  • Severity: Low
  • Category: information-disclosure / cache-control
  • Location: src/postulo/config/settings/base.py (MIDDLEWARE list)
  • Found by: client-side code

Who can do it, and what they need. The next person at a shared or borrowed computer (library, family machine, co-working desk). Precondition: the previous person used Postulo in that browser and signed out but did not close the browser window/tab history; no credentials are needed.

How it works.

  1. Person A opens Postulo, visits Your details (home address, phone, date of birth) or the applications table, then presses Sign out (a POST navigation to the sign-in page).
  2. Person B presses Back / Alt+Left.
  3. The previous page had no Cache-Control: no-store, so it is eligible for the browser's back/forward cache (Firefox and Safari restore it verbatim; Chrome restores it unless the page was no-store); the page is shown from memory without any request reaching the server, including the address, applications, companies and reminders it held.
  4. app.js's own pageshow handlers exist precisely because pages come back this way ("Coming back to a page from the cache with yesterday's dirty mark on it"). Files already carry private, max-age=0, no-store; HTML pages carry nothing.

Impact. Disclosure of the previous person's personal data (the threat model's sharpest item, the home address on Your details, plus the whole job-search record) to whoever next uses the browser, without a session. Limited scope: physical access to the same browser profile, and only the pages visited before sign-out.

Evidence.

Scratch test (tests run against test settings, which send the same headers as production): `CACHE HEADERS: {'core:home': (200, None, 'Cookie, Accept-Language'), 'accounts:profile': (200, None, 'Cookie, Accept-Language'), 'applications:list': (200, None, 'HX-Request, HX-History-Restore-Request, HX-Trigger, Cookie, Accept-Language')}` — the second tuple member is `response.get("Cache-Control")`. `grep -rn 'no-store|never_cache|cache_control' src/postulo` finds cache headers only on file, JSON, iCal, vCard, metrics, logs and export responses, never on HTML. app.js:2159-2165: `window.addEventListener("pageshow", function () { // Coming back to a page from the cache with yesterday's dirty mark on
…

Verification (verdict: confirmed; severity after verification: Low; first-pass claim: Medium). Lower from medium to low. Correct the mechanism: only the browser's back/forward cache restores the stale page, the HTTP cache does not (Vary: Cookie plus the deleted sessionid forces a refetch and a redirect to sign-in). Precondition: physical access to the same open tab or window shortly after sign-out (bfcache entries expire, in Chrome after about ten minutes). The attacker gets a read-only DOM snapshot of pages the previous user viewed in that tab (Your details with address, phone and date of birth, the applications table) and cannot act on them. Location: src/postulo/config/settings/base.py MIDDLEWARE at lines 93-122 (no cache-header middleware) and src/postulo/templates/base.html; app.js pageshow handlers (lines 1668, 1934, 2131, 3887) do not reload on event.persisted. Remediation: add a small middleware or after-login decorator setting 'Cache-Control: no-store' (optionally 'private, max-age=0') on every authenticated HTML response, and/or send 'Clear-Site-Data: "cache"' on logout, and in app.js reload or redirect on pageshow with event.persisted when the session is gone. …

Suggested remediation. Add a small middleware (or wrap the base view mixin) that calls Django's add_never_cache_headers() / sets Cache-Control: private, no-store on every response to an authenticated request whose content type is HTML (and on the sign-in/sign-out responses). no-store makes the page ineligible for the back/forward cache in Firefox and Safari and, in Chrome, evicts it when the session cookie changes at sign-out. Optionally send Clear-Site-Data: "cache" on the sign-out response. …

Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 8 to 10 October 2026, as **PST-058**. Severity: **Low**. The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it. --- - **Severity:** Low - **Category:** information-disclosure / cache-control - **Location:** `src/postulo/config/settings/base.py` (MIDDLEWARE list) - **Found by:** client-side code **Who can do it, and what they need.** The next person at a shared or borrowed computer (library, family machine, co-working desk). Precondition: the previous person used Postulo in that browser and signed out but did not close the browser window/tab history; no credentials are needed. **How it works.** 1. Person A opens Postulo, visits *Your details* (home address, phone, date of birth) or the applications table, then presses *Sign out* (a POST navigation to the sign-in page). 2. Person B presses Back / Alt+Left. 3. The previous page had no `Cache-Control: no-store`, so it is eligible for the browser's back/forward cache (Firefox and Safari restore it verbatim; Chrome restores it unless the page was no-store); the page is shown from memory without any request reaching the server, including the address, applications, companies and reminders it held. 4. app.js's own `pageshow` handlers exist precisely because pages come back this way ("Coming back to a page from the cache with yesterday's dirty mark on it"). Files already carry `private, max-age=0, no-store`; HTML pages carry nothing. **Impact.** Disclosure of the previous person's personal data (the threat model's sharpest item, the home address on *Your details*, plus the whole job-search record) to whoever next uses the browser, without a session. Limited scope: physical access to the same browser profile, and only the pages visited before sign-out. **Evidence.** ```text Scratch test (tests run against test settings, which send the same headers as production): `CACHE HEADERS: {'core:home': (200, None, 'Cookie, Accept-Language'), 'accounts:profile': (200, None, 'Cookie, Accept-Language'), 'applications:list': (200, None, 'HX-Request, HX-History-Restore-Request, HX-Trigger, Cookie, Accept-Language')}` — the second tuple member is `response.get("Cache-Control")`. `grep -rn 'no-store|never_cache|cache_control' src/postulo` finds cache headers only on file, JSON, iCal, vCard, metrics, logs and export responses, never on HTML. app.js:2159-2165: `window.addEventListener("pageshow", function () { // Coming back to a page from the cache with yesterday's dirty mark on … ``` **Verification** (verdict: confirmed; severity after verification: Low; first-pass claim: Medium). Lower from medium to low. Correct the mechanism: only the browser's back/forward cache restores the stale page, the HTTP cache does not (Vary: Cookie plus the deleted sessionid forces a refetch and a redirect to sign-in). Precondition: physical access to the same open tab or window shortly after sign-out (bfcache entries expire, in Chrome after about ten minutes). The attacker gets a read-only DOM snapshot of pages the previous user viewed in that tab (Your details with address, phone and date of birth, the applications table) and cannot act on them. Location: src/postulo/config/settings/base.py MIDDLEWARE at lines 93-122 (no cache-header middleware) and src/postulo/templates/base.html; app.js pageshow handlers (lines 1668, 1934, 2131, 3887) do not reload on event.persisted. Remediation: add a small middleware or after-login decorator setting 'Cache-Control: no-store' (optionally 'private, max-age=0') on every authenticated HTML response, and/or send 'Clear-Site-Data: "cache"' on logout, and in app.js reload or redirect on pageshow with event.persisted when the session is gone. … **Suggested remediation.** Add a small middleware (or wrap the base view mixin) that calls Django's `add_never_cache_headers()` / sets `Cache-Control: private, no-store` on every response to an authenticated request whose content type is HTML (and on the sign-in/sign-out responses). `no-store` makes the page ineligible for the back/forward cache in Firefox and Safari and, in Chrome, evicts it when the session cookie changes at sign-out. Optionally send `Clear-Site-Data: "cache"` on the sign-out response. …
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:58 +00:00
Author
Owner

Fixed in 90c75b28f on main.

Fixed in 90c75b28f on main.
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#787
No description provided.