PST-024: A Web Push subscription outlives sign-out, so reminders keep arriving on a shared browser #753

Closed
opened 2026-10-10 10:56:01 +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-024. 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 / session-boundary
  • Location: src/postulo/static/js/app.js (~6730-6775)
  • Found by: client-side code

Who can do it, and what they need. Whoever uses the same browser profile after the person has signed out (shared computer, borrowed laptop). Precondition: the person set up the Browser notifier with delivery Pushed to this browser while using that browser, then signed out without removing the connection.

How it works.

  1. On the connection page the person presses Allow notifications in this browser; app.js registers /sw.js with scope / and subscribes; the subscription JSON is saved on the connection.
  2. The person signs out. Nothing in the sign-out flow unsubscribes the push subscription, unregisters the worker or revokes the connection; Notification.permission stays granted for the origin.
  3. When a reminder or an application event fires, BrowserNotifier.send() pushes _payload(title, body, url, event) to the browser; sw.js shows it (showNotification(data.title, {body: data.body, data: {url}})) regardless of who, if anyone, is signed in.
  4. The next person sees the OS notification with the reminder title and body (an application's title and the company name), and clicking it opens Postulo at that URL (landing on the sign-in page with ?next=). The in-tab path is correctly limited to signed-in pages (data-browser-notices is only rendered for an authenticated person), so only the push path leaks.

Impact. Names of companies applied to, reminder subjects and application URLs shown to a third party on a shared machine, indefinitely, until the person revokes the connection from another device. Narrow: needs a shared browser and the push delivery mode, and reveals notification text rather than records.

Evidence.

app.js: `return navigator.serviceWorker.register(card.dataset.webPushWorker, { scope: "/" })` ... `registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: base64UrlToBytes(card.dataset.webPushKey || "") })`. sw.js: `self.registration.showNotification(data.title || "Postulo", { body: data.body || "", ... data: { url: data.url || "/" } })`. plugins/browser/__init__.py: `webpush.push(subscription, _payload(notification.title, notification.body, notification.url, notification.event))`. `grep -rn -i 'shared|own browser|public computer' src/postulo/plugins/browser src/postulo/templates/connections` → no warning. `grep -rn 'account_logout|LogoutView' src/postulo --include=
…

Verification (verdict: confirmed; severity after verification: Low). Keep it as a low-severity privacy and UX hardening note, not an exposure of records. Correct lines: app.js ~6782-6792 (register and subscribe), plugins/browser/init.py 155-188 (send), sw.js 21-28 (push handler). Corrections to the narrative: (a) sign-out leaving the push in place is intended, since the 'push' option is labelled 'even with Postulo closed' and sessions lapse after Django's default two weeks; (b) the person can stop it from the same browser by removing the connection or by revoking the site's notification permission, so 'only from another device' is wrong; (c) the shared-profile user gains notification text only, and role titles are already visible in browser history through the page <title>; (d) a Web Push subscription is per origin and per browser profile, so a second account that signs in on the same browser and presses Allow gets the same subscription back, and the two accounts' reminders then land in one browser. This is the same shared-profile decision and gives no cross-account read. …

Suggested remediation. On the sign-out form submit (app.js already delegates submit), call navigator.serviceWorker.getRegistration('/') → pushManager.getSubscription() → unsubscribe() and registration.unregister() before the navigation, and have the server mark that connection unusable on the next 410; or offer a per-browser forget this browser on sign-out choice. At minimum, add to the connection page's help the sentence that a push subscription belongs to the browser, not the session, and should only be made in a browser that is the person's own, and show the subscribed browsers under Settings with a revoke button (the connection list already allows deleting the connection).

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-024**. 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 / session-boundary - **Location:** `src/postulo/static/js/app.js` (~6730-6775) - **Found by:** client-side code **Who can do it, and what they need.** Whoever uses the same browser profile after the person has signed out (shared computer, borrowed laptop). Precondition: the person set up the *Browser* notifier with delivery *Pushed to this browser* while using that browser, then signed out without removing the connection. **How it works.** 1. On the connection page the person presses *Allow notifications in this browser*; app.js registers `/sw.js` with scope `/` and subscribes; the subscription JSON is saved on the connection. 2. The person signs out. Nothing in the sign-out flow unsubscribes the push subscription, unregisters the worker or revokes the connection; `Notification.permission` stays granted for the origin. 3. When a reminder or an application event fires, `BrowserNotifier.send()` pushes `_payload(title, body, url, event)` to the browser; sw.js shows it (`showNotification(data.title, {body: data.body, data: {url}})`) regardless of who, if anyone, is signed in. 4. The next person sees the OS notification with the reminder title and body (an application's title and the company name), and clicking it opens Postulo at that URL (landing on the sign-in page with `?next=`). The in-tab path is correctly limited to signed-in pages (`data-browser-notices` is only rendered for an authenticated person), so only the push path leaks. **Impact.** Names of companies applied to, reminder subjects and application URLs shown to a third party on a shared machine, indefinitely, until the person revokes the connection from another device. Narrow: needs a shared browser and the push delivery mode, and reveals notification text rather than records. **Evidence.** ```text app.js: `return navigator.serviceWorker.register(card.dataset.webPushWorker, { scope: "/" })` ... `registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: base64UrlToBytes(card.dataset.webPushKey || "") })`. sw.js: `self.registration.showNotification(data.title || "Postulo", { body: data.body || "", ... data: { url: data.url || "/" } })`. plugins/browser/__init__.py: `webpush.push(subscription, _payload(notification.title, notification.body, notification.url, notification.event))`. `grep -rn -i 'shared|own browser|public computer' src/postulo/plugins/browser src/postulo/templates/connections` → no warning. `grep -rn 'account_logout|LogoutView' src/postulo --include= … ``` **Verification** (verdict: confirmed; severity after verification: Low). Keep it as a low-severity privacy and UX hardening note, not an exposure of records. Correct lines: app.js ~6782-6792 (register and subscribe), plugins/browser/__init__.py 155-188 (send), sw.js 21-28 (push handler). Corrections to the narrative: (a) sign-out leaving the push in place is intended, since the 'push' option is labelled 'even with Postulo closed' and sessions lapse after Django's default two weeks; (b) the person can stop it from the same browser by removing the connection or by revoking the site's notification permission, so 'only from another device' is wrong; (c) the shared-profile user gains notification text only, and role titles are already visible in browser history through the page &lt;title>; (d) a Web Push subscription is per origin and per browser profile, so a second account that signs in on the same browser and presses Allow gets the same subscription back, and the two accounts' reminders then land in one browser. This is the same shared-profile decision and gives no cross-account read. … **Suggested remediation.** On the sign-out form submit (app.js already delegates `submit`), call `navigator.serviceWorker.getRegistration('/')` → `pushManager.getSubscription()` → `unsubscribe()` and `registration.unregister()` before the navigation, and have the server mark that connection unusable on the next `410`; or offer a per-browser *forget this browser on sign-out* choice. At minimum, add to the connection page's help the sentence that a push subscription belongs to the browser, not the session, and should only be made in a browser that is the person's own, and show the subscribed browsers under Settings with a revoke button (the connection list already allows deleting the connection).
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:32 +00:00
Author
Owner

Fixed in a47c0b837 on main.

Fixed in a47c0b837 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#753
No description provided.