security: enforce https on dues_payment_methods.url at the data layer

The url renders to members as a clickable href. https was enforced only
in the admin Zod action, but RLS lets an admin/board write the row
directly via PostgREST, bypassing Zod, with no DB constraint -- a
malicious admin could store a javascript: URL and XSS members who click.

- Migration 050: CHECK (url ~* '^https://') so the DB is the source of
  truth; integration-tested that the service path cannot store
  javascript:/http:.
- Member-read action filters with isSafeDuesUrl (defense-in-depth).
This commit is contained in:
Moheeb Zara
2026-05-19 21:35:49 -07:00
parent 5ce6b29d4a
commit f2534323da
7 changed files with 61 additions and 2 deletions
+3
View File
@@ -31,6 +31,9 @@ Branch `main`. Same session as pass-57; addressed three follow-up requests the u
- Made `isSafeDuesUrl` load-bearing (the Zod url field now refines on it, removing the duplicate inline https check). Verified ChipInput limits match the profile Zod schema (skills/interests 40×60, willing_to 20×60, affiliations 50×200). Confirmed `getMyBilling`'s only real caller is `/me` (shape change from `null` to always-object is safe). External links render `target=_blank rel=noopener noreferrer`; url validated absolute-https so no javascript:/data: scheme.
### Post-deploy audit follow-up (LOCAL, NOT yet deployed)
**Third ultrathink-audit finding (migration 050): stored-XSS defense gap on the dues url.** `dues_payment_methods.url` is rendered to members as a clickable `<a href>`. The https requirement was enforced ONLY in the admin Zod action, but RLS lets an admin/board write the row directly via PostgREST (public anon key + their JWT), bypassing Zod, and the table had no url constraint. A malicious admin could store `javascript:...` and XSS any member who clicks. Fixed two ways: (a) migration 050 adds a CHECK `url ~* '^https://'` (the DB is now the source of truth; integration-tested that the service path can't store `javascript:`/`http:`), and (b) the member-read action filters with `isSafeDuesUrl` (defense-in-depth, immediate protection for current prod). Admin-gated and prod has no rows yet, so no live exploitation window, but it escalated "space admin" to "XSS any member" and is closed now.
Second ultrathink audit after the `ab8c22a` deploy found NO deploy-blocking bug. Re-read the dispatcher, dues actions, and dues card with fresh eyes: URL is Zod-validated absolute-https; the `delete` platform flows through a parameterized `.eq` filter (double-gated by the space pin + RLS); writes/reads are scoped to the authenticated member; billing is never muted (logic + test). Then hardened + polished (all local, post-`ab8c22a`):
- **Integration coverage added (now 37, was 31).** `integration/dues-payment-methods.test.ts` (4: admin insert + member read + non-admin write denied + cross-space read denied + cross-space write denied + 046 unverified-admin gate) and `integration/notification-prefs-dispatch.test.ts` (2: muted category -> `skipped` while billing always sends; no-pref-row -> sends). The dispatcher skip was unexercised in prod (cron gated off), so this is its first real exercise. All 37 pass vs local Postgres.
- **Harness bug fixed.** `integration/db.ts` `rowsAsUser` ran `begin; set; select; commit;` in one `psql -c`, which prints only the LAST statement's result, so the SELECT output was swallowed (returned `[]`). It was never used before; now the SELECT is the last statement (session `set`, no trailing `commit`).