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