Files
familysync/.planning/phases/05-web-push-notifications/05-SECURITY.md
T

9.0 KiB
Raw Blame History

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-112usePushSubscription.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

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

Approval: verified 2026-06-10