---
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
