9.0 KiB
9.0 KiB
phase, slug, status, threats_open, asvs_level, created
| phase | slug | status | threats_open | asvs_level | created |
|---|---|---|---|---|---|
| 05 | web-push-notifications | verified | 0 | 1 | 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)
db:pushscript still present.apps/api/package.json:13defines"db:push": "drizzle-kit push". T-05-02 concerns the migration that was performed (generate+migrate via0003_same_xavin.sql, verified); the script's mere existence is not the threat. Repo memorydrizzle-mariadb-push-unsafedocuments the prohibition. Consider guarding/removing the script in a future hardening pass.- VAPID public key served under the
/apiOIDC 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. - 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
- All threats have a disposition (mitigate / accept / transfer)
- Accepted risks documented in Accepted Risks Log
threats_open: 0confirmedstatus: verifiedset in frontmatter
Approval: verified 2026-06-10