Files
anwar-pi-website/docs/adr/0005-member-trust-model.md
T
AHMED 165efc3269 Docs: new README (Arabic flows, screenshots, architecture) + AGENT.md for AI agents
- README rewritten for the real Next.js + Payload + Postgres stack
  (old one described the dead static prototype)
- Logo + 4 live screenshots (home, articles, post, subscription)
- Arabic flow explanations with mermaid diagrams: visitor journey,
  content pipeline, request path
- Architecture table, quick start, API reference, security summary,
  deploy commands
- AGENT.md: orientation for future AI agents (data model, trust rules,
  rate limits, routes, env, deploy, house rules)
- GLOSSARY.md and docs/adr committed (linked from README)
2026-10-09 17:23:14 +03:00

2.5 KiB
Raw Permalink Blame History

ADR 0005 — Member trust model (server-only privilege/verification/billing fields)

Date: 2026-10-09

Status

Accepted

Context

The 2026-10-08 pentest (findings #1, #3, #4, #6) confirmed that anonymous or self-authenticated client requests could write privileged fields on users:

  1. POST /api/users accepted client-supplied emailVerified, role, onboarded, stripeCustomerId, verificationToken — e.g. instant self-verified admin.
  2. Self-PATCH /api/users/{id} accepted the same fields on update.
  3. Per-account login lockout (no time-box) let an attacker lock the admin out of the site indefinitely with wrong-password floods.

Decision

  • role, emailVerified, verificationToken, stripeCustomerId, onboarded are stripped from every untrusted write in Users.beforeChange (web/src/collections/Users.ts). Untrusted = no authenticated admin and no context.systemActivation === true. On create, the hook forces role: 'free', emailVerified: false, onboarded: false.
  • Server-side writers opt back in with context: { systemActivation: true } on their local-API payload.update/create calls. Enumerated writers: verify-email, resend-verification, subscribe, stripe/webhook (×2), payment-success, seed. Omitting the context silently no-ops the write (the field is deleted) — that is the intended failure mode, so a missing context is caught in QA rather than leaking privilege.
  • Login lockout is per-account, time-boxed: 5 failed attempts → 15-minute lock (auth: { maxLoginAttempts: 5, lockTime: 15 * 60 * 1000 }).
  • role === 'admin' accounts are exempt from lockout (owner decision 2026-10-09): a beforeOperation hook on login pre-clears lockUntil/loginAttempts via db.updateOne (which, like Payload's own incrementLoginAttempts, bypasses hooks). Free/VIP accounts keep the normal 5/15-min lockout.
  • Commenting requires a verified email; admins are exempt (see the 403 gate in api/posts/[slug]/comments).

Consequences

  • Every future server-side writer of these fields MUST pass context.systemActivation; a writer that forgets it silently loses its write.
  • The admin password has no brute-force throttle — compensate with a strong password. If that posture changes, delete the beforeOperation hook and the account falls back to 5/15-min lockout.
  • Client-sent profile fields (fullName, termsVersion, interest, level, goal, prefs) stay writable; only privilege/verification/billing fields are protected.