wacrm/.env.local.example
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

86 lines
4.0 KiB
Plaintext

# ============================================================
# REQUIRED — the app won't start without these.
# ============================================================
# Supabase (Project Settings → API)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# Supabase service-role key. Bypasses RLS; used only by server-side
# routes (the webhook and automation engine). Keep this secret —
# never paste it into client code.
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# WhatsApp token encryption (64 hex chars = 32 bytes, AES-256-GCM).
# Generate with:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Rotating this value orphans every token encrypted under the previous
# key — users have to re-save their WhatsApp settings to reconnect.
ENCRYPTION_KEY=your-64-char-hex-key-here
# Meta App Secret (Meta for Developers → App Settings → Basic).
# Verifies the HMAC-SHA256 signature on every inbound webhook POST.
# Required — without it the webhook rejects every request.
META_APP_SECRET=your-meta-app-secret
# ============================================================
# RECOMMENDED — safe defaults exist but you'll want to set these.
# ============================================================
# Canonical public URL of this deployment (scheme + host, no trailing
# slash). Used for the sitemap and OG images. Routes that need a
# self-referential URL (invite links from /api/account/invitations,
# any future email-bound link) derive the origin from the request
# itself, so this variable is *only* needed when the request-derived
# origin would be wrong — e.g. when generating links from a cron job
# or a background worker that has no incoming request.
NEXT_PUBLIC_SITE_URL=https://crm.example.com
# ============================================================
# OPTIONAL — only needed if you use the feature.
# ============================================================
# Defense-in-depth allow-list for the hostnames that
# /api/account/invitations is willing to publish in invite URLs.
# Comma-separated, no scheme, no port (just hostnames).
#
# Why this exists: when NEXT_PUBLIC_SITE_URL is unset, invite URLs
# are derived from the incoming request's `Host` / `X-Forwarded-
# Host` header. On a typical proxied deploy the proxy sets these
# to your canonical hostname and they're trustworthy. On a *bare*
# deployment exposed to the public internet, an attacker could
# POST to the API directly with a spoofed `Host: phishing.example`
# and receive an invite URL pointing at their site.
#
# When this var is set, hostnames not on the list are rejected
# (the request falls through to the wacrm.tech fallback with a
# console.warn). When unset, behavior is unchanged from earlier
# versions — the request-derived host is trusted.
#
# Most operators don't need this: setting NEXT_PUBLIC_SITE_URL to
# your canonical URL already pins invite links there. This is for
# operators who want belt-and-braces or run multi-tenant setups
# where one app instance serves several hostnames.
#
# Example:
# ALLOWED_INVITE_HOSTS=crm.example.com,crm-staging.example.com
# Shared secret protecting GET /api/automations/cron. Required if you
# use Wait steps in automations (a scheduled pinger drains pending
# executions). Generate any long random string:
# openssl rand -hex 32
# See docs/automations-and-cron.md.
# AUTOMATION_CRON_SECRET=generate-a-long-random-string
# Meta App ID (Meta for Developers → App Settings → Basic). Required
# only for the (planned) Resumable Upload flow that lets users attach
# local media files as template header samples. Submitting templates
# with a public sample URL works without it. Pair with META_APP_SECRET.
# META_APP_ID=your-meta-app-id
# When "true", POST /api/whatsapp/templates/submit skips the Meta call
# and stores the row with a synthetic `dry-run-<uuid>` meta_template_id.
# Set this in CI and local development so you can exercise the full
# template UI without a real WABA. Leave unset (or "false") in prod.
# WHATSAPP_TEMPLATES_DRY_RUN=true