Commit Graph

3 Commits

Author SHA1 Message Date
Arnas Donauskas
44f5c550e1
harden(invite): optional ALLOWED_INVITE_HOSTS allow-list
Defense-in-depth for the request-derived branch of `getBaseUrl()`
in /api/account/invitations.

The threat model
- On a typical proxied deploy (Vercel / Hostinger / Cloudflare /
  nginx with a fixed server_name), the proxy overwrites the
  `Host` and `X-Forwarded-Host` headers with the deployment's
  canonical hostname, so the request-derived path produces the
  right URL. NEXT_PUBLIC_SITE_URL pinning is also unnecessary in
  that case.
- On a *bare* deployment exposed to the public internet (no proxy
  in front, no SITE_URL set), an attacker can POST directly to
  /api/account/invitations with `Host: phishing.example` and
  receive an invite URL pointing at their site. They'd still need
  admin role on *some* account to call the endpoint, and they'd
  need to set up `phishing.example/join/<token>` to actually
  phish anyone — but the resolution chain shouldn't trust
  un-validated client input by default.

The fix
- New env var `ALLOWED_INVITE_HOSTS` (comma-separated hostnames).
- When set, `getBaseUrl` validates each candidate host against
  the list and falls through to wacrm.tech if neither matches.
- When unset, behavior is unchanged — the request-derived host
  is trusted (preserves the "just works" experience for the
  typical proxied case).
- Distinct console.warn for the rejection path vs the
  no-Host-at-all path so operators can tell their allow-list is
  actually catching something.

Documented in .env.local.example with a clear explanation of
when operators should set it (bare deploys + multi-tenant
hosting) and when they probably don't need to (the typical case
where NEXT_PUBLIC_SITE_URL already pins the host).

Severity: MEDIUM in the final review, downgraded in practice
because (a) the attack window is narrow, (b) requires admin
role + a phishing site, and (c) most deployments are behind a
proxy that strips the spoofable headers. Still worth the small
amount of code to give security-conscious operators the knob.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 14:26:01 +03:00
Arnas Donauskas
7d466ca865
fix(invite): derive invite-URL origin from the request, not wacrm.tech
Pre-fix: `getBaseUrl()` in /api/account/invitations defaulted to
`https://wacrm.tech` whenever `NEXT_PUBLIC_SITE_URL` was unset.
wacrm.tech is the docs/marketing site (separate ArnasDon/wacrm-site
repo); it has no `/join/[token]` route. Any fork that didn't set the
env var got invite links pointing into the marketing site, which
404'd on click. Reported by @ArnasDon while testing the flow on
production.

Post-fix: `getBaseUrl(request)` resolves in this order:

  1. `NEXT_PUBLIC_SITE_URL` — explicit operator config, still wins.
  2. `X-Forwarded-Host` (+ `X-Forwarded-Proto`) — set by every
     reverse proxy in front of the app (Hostinger Managed Node.js,
     Vercel, Cloudflare). This is what makes invite links Just
     Work in production without any env-var fiddling.
  3. `Host` header + the request's protocol — bare deployments
     without a proxy in front.
  4. `https://wacrm.tech` last-resort fallback with a console.warn
     so operators spot the misconfig in logs. Effectively
     unreachable from a real browser; only triggers if there's no
     Host header at all.

Also tightened `.env.local.example` so the variable's purpose
matches the new behavior: it's now only needed when the request-
derived origin would be wrong (cron jobs, background workers).
Day-to-day forks don't have to remember it.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (after `rm -rf .next` to clear
                        stale [locale]/* type stubs from the
                        i18n revert that landed on main)
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 09:16:14 +03:00
Arnas Donauskas
b013861160
feat(api): invitation create + peek + redeem endpoints
PR 4 of the multi-user accounts series. Lands the link-only
invitation flow — admin generates a shareable URL, copies it,
sends it via any channel. No email service needed.

API surface
- GET    /api/account/invitations              — list pending  (admin+)
- POST   /api/account/invitations              — create        (admin+)
- DELETE /api/account/invitations/[id]         — revoke        (admin+)
- GET    /api/invitations/[token]/peek         — public, rate-limited per-IP
- POST   /api/invitations/[token]/redeem       — authenticated, rate-limited per-IP

Token lifecycle
- POST returns the plaintext token + share URL exactly ONCE in
  the response. We store only the SHA-256 hash. There is no
  endpoint that resurfaces it; if the admin dismisses the dialog
  without copying, the only recourse is to revoke and re-issue.
- Peek and redeem take the plaintext token in the URL path, hash
  it in TS via `hashInviteToken`, and look up by `token_hash`.

Two new SECURITY DEFINER RPCs back the cross-RLS work
(migration 019):
- `peek_invitation` — anonymous read. Returns a uniform JSON
  envelope: `{ ok, account_name, role, expires_at }` or
  `{ ok: false, reason: not_found|used|expired }`. The route
  forwards verbatim so the join page can branch cleanly.
- `redeem_invitation` — authenticated. Locks the invite row
  `FOR UPDATE`, validates expiry/usage, refuses if the caller is
  already in a shared account OR their personal account has any
  domain rows (checked across all 11 user-creatable parent
  tables), then atomically moves profile.account_id +
  account_role, stamps the invitation accepted, and deletes the
  orphan personal account. Single transaction.

Error contract
- 22023 (invalid_parameter_value)  → 400 — not_found / used / expired
- 42501 (insufficient_privilege)   → 401 — caller not authenticated
- 23505 (unique_violation, reused) → 409 — caller's account has data,
                                           or they're already a
                                           member of the inviter's
                                           account / another shared
                                           account

Rate limiting
- Two new buckets in `RATE_LIMITS`: invitationPeek (30/min/IP)
  and invitationRedeem (10/min/IP). Belt-and-braces against
  token enumeration even though 256-bit entropy makes it
  theoretical. Uses the existing fixed-window in-memory limiter.

Why no peek-then-redeem two-step on the server
- Peek is read-only and idempotent; redeem locks `FOR UPDATE` so
  two concurrent redemptions can't both win. The peek result is
  cosmetic ("show me what I'm joining"); the actual write
  happens entirely inside the redeem RPC's transaction, so a
  TOCTOU between peek and redeem just means the user sees the
  same `{ ok: false, reason }` on the next render.

Verification
- `npm run lint`      → 0 errors (same 20 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds; the 4 new routes register as ƒ

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 17:32:46 +03:00