---
phase: 05
slug: web-push-notifications
status: verified
threats_open: 0
asvs_level: 1
created: 2026-06-10
---

# Phase 05 — Security

> Per-phase security contract: threat register, accepted risks, and audit trail.
>
> **Audit type:** Threat-mitigation verification (declared dispositions only — not a blind vulnerability scan). The register was authored at plan time across the eight `05-0N-PLAN.md` `<threat_model>` blocks. Each threat below was verified by locating its declared mitigation in the implemented code (file:line); documentation/intent was NOT accepted as evidence. Implementation files were READ-ONLY during this audit.

---

## Trust Boundaries

| Boundary                              | Description                                                                         | Data Crossing                        |
| ------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------ |
| developer machine → git               | VAPID private key must never cross into a committed file                            | VAPID private key (secret)           |
| pnpm registry → repo                  | package installs are untrusted supply-chain input                                   | web-push, workbox-\* packages        |
| API → push service (APNs/FCM)         | server signs with VAPID private key; response status is untrusted                   | push payload, response status        |
| browser → POST /api/push/subscription | untrusted subscription body crosses into the API                                    | endpoint URL, p256dh/auth keys       |
| SW → push payload                     | push payload from the service is untrusted input parsed in the SW                   | notification copy                    |
| SW → /callback navigation             | OIDC callback must reach the server, never the SW cache                             | OIDC auth code                       |
| list/event mutation → push audience   | audience must be derived from list/calendar access, not the request                 | member identity, list/event metadata |
| reminder query → push audience        | reminder eligibility is decided by the SQL WHERE, not by any request                | shared-calendar event metadata       |
| client permission state → UI          | `Notification.permission` + localStorage drive which surface shows; no server trust | none (client-only)                   |

---

## Threat Register

| Threat ID | Category                      | Component                                   | Disposition | Mitigation                                                                                                                                                 | Status |
| --------- | ----------------------------- | ------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| T-05-01   | Information Disclosure        | VAPID_PRIVATE_KEY                           | mitigate    | `.env` + `apps/api/.env` gitignored (`.gitignore:10-11`); only `.env.example` tracked; `.env.example:36` is a placeholder, no real key in any tracked file | closed |
| T-05-SC   | Tampering (supply chain)      | npm installs (web-push, workbox-\*)         | mitigate    | Blocking human checkpoint executed pre-install (`05-01-SUMMARY.md:50`); deps at audited versions (web-push@^3.6.7, workbox-\*@^7.4.1)                      | closed |
| T-05-02   | Tampering (migration)         | drizzle migration on populated MariaDB      | mitigate    | generate+migrate only: `db/migrations/0003_same_xavin.sql` (CREATE push_subscriptions + ADD title); no `db:push` used                                      | closed |
| T-05-03   | Cryptography misuse           | VAPID signing                               | mitigate    | `pushDispatcher.ts:19,104` web-push only; `index.ts:120` sole `setVapidDetails`; never hand-rolled                                                         | closed |
| T-05-04   | Denial of Service             | malformed push response / per-sub crash     | mitigate    | `pushDispatcher.ts:108-120` per-send try/catch; never throws — one failed send never aborts the fan-out                                                    | closed |
| T-05-05   | Information Disclosure (logs) | error logs                                  | mitigate    | `pushDispatcher.ts:118-119` logs only statusCode + err.message; never subscription keys or payload body                                                    | closed |
| T-05-06   | Information Disclosure        | list-change copy                            | mitigate    | `listChangeDispatcher.ts:107-115` generic copy "{Actor} made {N} change(s) to {ListName}"; no item text (D-02)                                             | closed |
| T-05-07   | Spoofing                      | actor self-notification                     | mitigate    | `pushCoalescer.ts:39-49` keys on `${listId}:${actorId}`; `listChangeDispatcher.ts:89-91` filters `uid !== actorId` (D-03)                                  | closed |
| T-05-08   | Denial of Service             | unbounded pending map                       | accept      | Accepted risk (see log); per-(list,actor) keys, entries self-delete on fire (`pushCoalescer.ts:60-63`)                                                     | closed |
| T-05-09   | Spoofing                      | POST /subscription (user A as user B)       | mitigate    | `push.ts:92-106` userId from `resolveUserId(c)` (OIDC session), never the body                                                                             | closed |
| T-05-10   | Input Validation              | subscription body                           | mitigate    | `push.ts:60-66,92` zod subscribeSchema: endpoint url().max(2048), p256dh ≤512, auth ≤256 before insert                                                     | closed |
| T-05-11   | Tampering                     | SW serving /callback from cache             | mitigate    | `sw.ts:59-67` NavigationRoute denylist `/^\/callback/, /^\/api\//, /^\/health/`                                                                            | closed |
| T-05-12   | Denial of Service             | malformed push payload in SW                | mitigate    | `sw.ts:86-130` try/catch around `event.data.json()`; `showNotification` runs unconditionally (generic fallback)                                            | closed |
| T-05-13   | Access Control                | DELETE /subscription                        | mitigate    | `push.ts:131-136` scoped `WHERE userId = caller`; cannot delete another member's subscription                                                              | closed |
| T-05-14   | Information Disclosure        | list-change push to a non-member            | mitigate    | `listChangeDispatcher.ts:74-101` audience = owner ∪ list_shares only; never all users                                                                      | closed |
| T-05-15   | Information Disclosure        | item text in payload                        | mitigate    | `listChangeDispatcher.ts:107-115` generic copy, no item text (same as T-05-06)                                                                             | closed |
| T-05-16   | Spoofing                      | actor notified of own change                | mitigate    | `listChangeDispatcher.ts:89-91` actor excluded from audience (same as T-05-07)                                                                             | closed |
| T-05-17   | Information Disclosure        | reminder leaking a personal-calendar event  | mitigate    | `reminderScheduler.ts:88-95` `WHERE calendars.isShared = true` in the SQL query; personal events never selected (D-05)                                     | closed |
| T-05-18   | Denial of Service             | duplicate reminder storm at window boundary | mitigate    | `reminderScheduler.ts:38,131-132,162` in-memory dedup Set `${uid}:${minuteBucket}`; per-event try/catch                                                    | closed |
| T-05-19   | Denial of Service             | one bad subscription aborting the cycle     | mitigate    | `reminderScheduler.ts:145-157` per-subscription try/catch; dispatchPush swallows + prunes 410/404                                                          | closed |
| T-05-20   | Spoofing                      | actor notified of own event change          | mitigate    | `eventChangeDispatcher.ts:142-151` `ne(userId, actorUserId)` + `.filter`; `poller.ts:70`/`outboxWorker.ts:174` supply actor                                | closed |
| T-05-21   | Denial of Service             | description-edit spam                       | mitigate    | `eventChangeDispatcher.ts:31-37,64-71` `isMeaningfulChange` excludes description-only edits (D-04)                                                         | closed |
| T-05-22   | Information Disclosure        | event-change code calling Fastmail          | mitigate    | `eventChangeDispatcher.ts:18-21` no tsdav import; reads MariaDB cache only (D-13)                                                                          | closed |
| T-05-23   | Tampering                     | silent re-subscribe without permission      | mitigate    | `usePushSubscription.ts:153-155` health-check re-subscribes only when `Notification.permission === 'granted'`                                              | closed |
| T-05-24   | Information Disclosure        | XSS via copy                                | mitigate    | Zero `dangerouslySetInnerHTML={...}` usage in `apps/pwa/src`; all copy is plain-text JSX children                                                          | closed |
| T-05-25   | Repudiation                   | toggle off leaves stale server subscription | mitigate    | `SettingsSheet.tsx:110-112` → `usePushSubscription.ts:245-257` `unsubscribe()` issues `DELETE /api/push/subscription`                                      | closed |

_Status: open · closed_
_Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)_

---

## Accepted Risks Log

| Risk ID  | Threat Ref | Rationale                                                                                                                                                                                                                                                                                                      | Accepted By                                | Date       |
| -------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ---------- |
| AR-05-01 | T-05-08    | In-memory `pending` Map in `pushCoalescer.ts` is keyed per-(list, actor). For a two-person household (expanding to a small N-member family) the key space is small and bounded; entries self-delete when the debounce timer fires (`pushCoalescer.ts:60-63`). No unbounded growth path under normal operation. | Plan author (`05-03-PLAN.md` threat model) | 2026-06-10 |

_Accepted risks do not resurface in future audit runs._

---

## Security Audit Trail

| Audit Date | Threats Total | Closed | Open | Run By                      |
| ---------- | ------------- | ------ | ---- | --------------------------- |
| 2026-06-10 | 26            | 26     | 0    | gsd-security-auditor (opus) |

---

## Notes (informational — not blockers)

1. **`db:push` script still present.** `apps/api/package.json:13` defines `"db:push": "drizzle-kit push"`. T-05-02 concerns the migration that was _performed_ (generate+migrate via `0003_same_xavin.sql`, verified); the script's mere existence is not the threat. Repo memory `drizzle-mariadb-push-unsafe` documents the prohibition. Consider guarding/removing the script in a future hardening pass.
2. **VAPID public key served under the `/api` OIDC guard** (`push.ts:78-80`). The public key is non-secret by design; serving it only to authenticated members is acceptable for v1 (documented in the route comment). Not a registered threat.
3. **Reminder fan-out cross-joins ALL push_subscriptions** (`reminderScheduler.ts:87`). Intentional and member-count-agnostic: a shared-calendar reminder notifies every member. T-05-17 confirms event _selection_ is shared-only via the WHERE clause, so no personal-calendar event reaches the fan-out. Correct by design.

---

## Sign-Off

- [x] All threats have a disposition (mitigate / accept / transfer)
- [x] Accepted risks documented in Accepted Risks Log
- [x] `threats_open: 0` confirmed
- [x] `status: verified` set in frontmatter

**Approval:** verified 2026-06-10
