diff --git a/.planning/phases/03-event-write-back-pwa-install/03-SECURITY.md b/.planning/phases/03-event-write-back-pwa-install/03-SECURITY.md new file mode 100644 index 0000000..7cc050c --- /dev/null +++ b/.planning/phases/03-event-write-back-pwa-install/03-SECURITY.md @@ -0,0 +1,99 @@ +--- +phase: 03 +slug: event-write-back-pwa-install +status: verified +threats_open: 0 +asvs_level: 1 +created: 2026-06-09 +--- + +# Phase 03 — Security + +> Per-phase security contract: threat register, accepted risks, and audit trail. +> Verified against the CURRENT implementation, i.e. after the code-review fix cycle +> (CR-01/CR-02 member-scoped lookups, CR-01 move-path RRULE forwarding, IN-03 worker +> payload re-validation, WR-04 worker-startup gate) — not the as-executed SUMMARY claims. + +--- + +## Trust Boundaries + +| Boundary | Description | Data Crossing | +|----------|-------------|---------------| +| Browser ↔ API | PWA calls Hono API over HTTPS (Pangolin/Newt tunnel) | Event field JSON, session cookie; no etag/credentials from client | +| OIDC (Authelia) ↔ API | Authorization-code + PKCE; storage-less JWT session cookie | iss/sub identity claims | +| Dev-bypass ↔ API | `DEV_AUTH_BYPASS=true` AND `NODE_ENV!=production` injects a fixed dev user | Local dev only; hard-OFF in production | +| API ↔ MariaDB | Drizzle/mysql2 parameterized queries | Event cache, outbox rows, encrypted app passwords | +| Outbox worker ↔ Fastmail CalDAV | Background worker PUT/DELETE with server-sourced etag (If-Match) | VEVENT payloads; decrypted app password (never logged) | +| Service Worker ↔ network | Workbox SW; `/callback`, `/api`, `/health` on navigateFallbackDenylist; `runtimeCaching: []` | No authenticated API responses cached; OIDC callback never SW-served | + +--- + +## Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation | Status | +|-----------|----------|-----------|-------------|------------|--------| +| T-03-01 | Tampering | drizzle-kit push | mitigate | Human checkpoint + hand-applied additive DDL; runtime CMD is `node dist/index.js` (Dockerfile:46); `db:push` manual-only npm script | closed | +| T-03-02 | Info Disclosure | calendar_outbox payload/etag | accept | Outbox rows are server-side only; never returned to the frontend | closed | +| T-03-03 | Tampering | VEVENT field serialization | mitigate | ical.js `ICAL.Component/Property/Recur` for all serialization; no hand-rolled ICS (vevent.ts:89-148) | closed | +| T-03-04 | Spoofing | etag forgery to bypass conflict | mitigate | etag sourced server-side from `calendarEvents.etag`; never read from request body (write.ts:62-86, outboxWorker.ts:264-279) | closed | +| T-03-05 | EoP | write.ts called w/ another member's calendar | accept | Low-level primitive; ownership enforced at the route layer (T-03-06) | closed | +| T-03-06 | EoP | write to another member's personal calendar | mitigate | Route lookup scoped `and(eq(uid), or(eq(userId,current), eq(isShared,true)))` + 403 on miss; CR-01 deterministic `orderBy(...desc).limit(1)` closes shared-account IDOR (events.ts:251-260,342-368,474-497) | closed | +| T-03-07 | Info Disclosure | sync-status leaks another member's row | mitigate | `WHERE and(eq(userId,current), eq(uid))` (events.ts:547) | closed | +| T-03-08 | Tampering | XSS/oversized payload via title/location/description | mitigate | zod bounds (title 255, loc/desc 2000); IN-03 worker re-validates outbox payload + hard-fails invalid rows before VEVENT build (events.ts:100-109, outboxWorker.ts:70-82,231-234,323-326) | closed | +| T-03-09 | Tampering | SQLi via uid/calendarUrl | mitigate | Drizzle parameterized queries incl. bound `sql\`\`` params; no string interpolation (events.ts:181-198) | closed | +| T-03-10 | Spoofing | client-supplied etag bypass | mitigate | etag read server-side at enqueue; client never supplies it (events.ts:407,432,507) | closed | +| T-03-11a | EoP | writable-calendars surfaces another member's personal calendar | mitigate | `WHERE or(eq(userId,current), eq(isShared,true))` (events.ts:600) | closed | +| T-03-11b | Repudiation | silent last-write-wins on concurrent edit | mitigate | 412→`conflict:true`→mark failed, no overwrite + targeted resync; CR-02 fresh-etag re-read joins calendars on (userId,url)+limit(1) (outboxWorker.ts:265-279,362-370,543-548) | closed | +| T-03-12 | DoS | poison row retrying forever | mitigate | `MAX_ATTEMPTS=5` + bounded backoff + dead-letter (outboxWorker.ts:40,46,578-587) | closed | +| T-03-13 | Info Disclosure | logging decrypted app password | mitigate | Decrypt local-only; per-item catches log `err.message` only (outboxWorker.ts:127,174-177,608-611; poller.ts:70-74) | closed | +| T-03-14 | Tampering | partial-failure data loss on edit-as-move | mitigate | create-before-delete + durable sibling-status gate + create-fail skips delete; CR-01 `_preservedRrule` re-applied via validated passthrough (outboxWorker.ts:336-353,462-524) | closed | +| T-03-15 | Tampering | XSS via form title/location/description | mitigate | All fields plain-text JSX children; no `dangerouslySetInnerHTML` in `apps/pwa/src` (EventForm.tsx:557,591,729,752,798) | closed | +| T-03-16 | EoP | client offers non-writable calendar in picker | mitigate | Picker only from authoritative `fetchWritableCalendars`; server re-enforces (client.ts:273-284, EventForm.tsx:182-187) | closed | +| T-03-17 | Tampering | accidental/irreversible delete | mitigate | Mandatory two-tap dialog; no single-tap; no "don't ask again" (DeleteConfirmationDialog.tsx:78-81) | closed | +| T-03-18 | Repudiation | silent data loss on failed delete sync | mitigate | failed/dead toast persists until dismiss; invalidates `['events']` so server refetch restores (SyncStateToast.tsx:59,201-222) | closed | +| T-03-19 | Info Disclosure | another member's sync-status in toast | mitigate | Toast queries own `lastSyncedUid`; server scopes by member (SyncStateToast.tsx:41, events.ts:547) | closed | +| T-03-20 | Spoofing | SW caches shell for /callback, breaks OIDC | mitigate | `navigateFallbackDenylist: [/^\/callback/, /^\/api\//, /^\/health/]` (vite.config.ts:16-20) | closed | +| T-03-21 | Tampering | SW caches authenticated API responses | mitigate | `runtimeCaching: []` (vite.config.ts:22) | closed | +| T-03-22 | Info Disclosure | manifest/icons leak secrets | accept | Static public assets only; no secrets in manifest | closed | +| T-03-23 | Spoofing | dev-auth bypass active in live deploy | mitigate | First guard `NODE_ENV==='production'`→no-op; prod mounts OIDC unconditionally; WR-04 moved worker startup into `isMainModule()` gate without altering middleware mount order (devBypass.ts:61, index.ts:38,46-48,104-114) | closed | +| T-03-24 | Info Disclosure | OIDC redirect_uri mismatch leaks codes | mitigate | `OIDC_AUTH_EXTERNAL_URL` MANDATORY = public URL (middleware.ts:12, index.ts:44-45); deployment-config responsibility, no code gap | closed | +| T-03-25 | Tampering | SW intercepts /callback in live build | mitigate | Same denylist verified vs production build (vite.config.ts:16-20); Gate 2 row 4 confirmed standalone login | 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-03-01 | T-03-02 | Outbox payload/etag are server-side-only rows, never exposed to the frontend; payload is the member's own VEVENT | Lucas Berger | 2026-06-09 | +| AR-03-02 | T-03-05 | `write.ts` is a low-level CalDAV primitive with no auth context; ownership is enforced one layer up at the route (T-03-06) | Lucas Berger | 2026-06-09 | +| AR-03-03 | T-03-22 | PWA manifest and icons are static public assets; contain no secrets | Lucas Berger | 2026-06-09 | + +--- + +## Security Audit Trail + +| Audit Date | Threats Total | Closed | Open | Run By | +|------------|---------------|--------|------|--------| +| 2026-06-09 | 25 | 25 | 0 | gsd-security-auditor (opus) | + +Notes: Verified against the post code-review-fix implementation. The five fix areas +(CR-01 member-scoped lookups, CR-01 move-path RRULE forwarding, CR-02 fresh-etag re-read, +IN-03 worker payload re-validation, WR-04 worker-startup gate) were each re-verified as +present and non-regressing. T-03-24 is a deployment-config control (no code gap). No +unregistered threat flags surfaced across the Phase 03 summaries. + +--- + +## 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-09