diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..ae2ed46 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,16 @@ +# Supabase +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key + +# Supabase Service Role (for server-side operations like webhook handling) +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key + +# WhatsApp Token Encryption (64 hex chars = 32 bytes for AES-256-CBC) +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +ENCRYPTION_KEY=your-64-char-hex-key-here + +# Meta App Secret — REQUIRED. Used to verify the HMAC-SHA256 signature +# Meta attaches to inbound webhook POSTs. Without it, the webhook +# rejects every request. Find it at: +# Meta for Developers → your app → App Settings → Basic → App Secret +META_APP_SECRET=your-meta-app-secret diff --git a/.gitignore b/.gitignore index 5ef6a52..7f57002 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +# keep example files so forkers have a template to copy +!.env.local.example +!.env.example # vercel .vercel diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 4b88425..4e5be78 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -12,12 +12,12 @@ minimal template; the table below is the full reference. | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase → Project Settings → API → **anon / public** key | Public. Relies on RLS for safety. | | `SUPABASE_SERVICE_ROLE_KEY` | Supabase → Project Settings → API → **service_role** key | **Secret.** Bypasses RLS. Used by webhook + admin routes only. | | `ENCRYPTION_KEY` | Generate: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` | 64 hex chars (32 bytes, AES-256-CBC). **Rotating breaks existing tokens.** | +| `META_APP_SECRET` | Meta → App Settings → Basic → **App Secret** | Verifies the `X-Hub-Signature-256` HMAC on every inbound webhook. **Without it the webhook rejects every request** — a public deploy cannot receive messages until this is set. | ## Recommended | Variable | Purpose | | ---------------------- | ---------------------------------------------------------------------------------------- | -| `META_APP_SECRET` | Meta → App Settings → Basic → **App Secret**. Enables `X-Hub-Signature-256` verification on incoming webhooks. | | `NEXT_PUBLIC_SITE_URL` | Canonical public URL (e.g., `https://crm.example.com`). Used for absolute URLs, sitemap, OG images. | ## Optional @@ -34,12 +34,14 @@ NEXT_PUBLIC_SUPABASE_URL=https://abcd1234.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi... SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi... +# Meta App Secret — required for webhook signature verification +META_APP_SECRET=abcdef0123456789... + # Encryption — DO NOT change after first deploy ENCRYPTION_KEY=3f9c0a7e4d8b2f1a6c5e8d4b9f0a2c6e8d4b9f0a2c6e8d4b9f0a2c6e8d4b9f0a -# Public URL + Meta webhook signing +# Public URL NEXT_PUBLIC_SITE_URL=https://crm.example.com -META_APP_SECRET=abcdef0123456789... # Automation cron AUTOMATION_CRON_SECRET=generate-a-long-random-string diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 780e9c7..b5c6283 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -50,9 +50,11 @@ Set `NEXT_PUBLIC_SITE_URL` **and** add the same origin to Supabase → - The callback URL must be reachable without auth. Test it yourself: `curl 'https://crm.example.com/api/whatsapp/webhook?hub.mode=subscribe&hub.verify_token=&hub.challenge=test'` should return `test`. -- If you set `META_APP_SECRET`, check the app secret matches — a bad - secret causes 401s on inbound webhooks, but verification itself does - not use it. +- `META_APP_SECRET` must be set on the deployment (it's required — + without it, every inbound POST gets a 401). If it's set but wrong, + you'll also see 401s for every POST while the initial subscribe + handshake (which doesn't use it) works. Double-check the value + matches Meta → App Settings → Basic → App Secret exactly. ### Messages go out but nothing comes in diff --git a/docs/whatsapp-setup.md b/docs/whatsapp-setup.md index 7812356..94afeb1 100644 --- a/docs/whatsapp-setup.md +++ b/docs/whatsapp-setup.md @@ -83,11 +83,14 @@ Under the webhook config, **Subscribe to** at minimum: `message_template_status_update` keeps Meta-approved template statuses in sync on the broadcast UI. -## 6. Signature verification (recommended) +## 6. Signature verification (required) Set `META_APP_SECRET` to your app's **App Secret** (Meta for Developers → -App Settings → Basic). When present, WaCRM validates the `X-Hub-Signature-256` -header on every inbound webhook and drops unsigned or forged calls. +App Settings → Basic). WaCRM validates the `X-Hub-Signature-256` HMAC +on every inbound webhook and **rejects every request if the env var is +not set** — anyone who knew the webhook URL could otherwise spoof +messages and status updates. Configure it before pointing the webhook +at a deployed instance. ## 7. Send a test message diff --git a/src/lib/whatsapp/webhook-signature.ts b/src/lib/whatsapp/webhook-signature.ts index 55576ce..c448eee 100644 --- a/src/lib/whatsapp/webhook-signature.ts +++ b/src/lib/whatsapp/webhook-signature.ts @@ -11,11 +11,12 @@ import crypto from 'node:crypto' * Reference: * https://developers.facebook.com/docs/graph-api/webhooks/getting-started#verify-payloads * - * Rollout behavior: - * If `META_APP_SECRET` is unset, we **fail open** with a loud warning - * log. This avoids breaking existing deployments at the moment this - * code ships — operators can set the env var on their next deploy. - * Once the env var is set, verification is strict. + * Contract: + * `META_APP_SECRET` is **required**. If it's missing we fail closed — + * every request is rejected until the operator configures the + * secret. A previous version fell open with a warning log, which is + * unsafe for a public template: anyone who forgets the env var would + * be running a fully spoofable webhook. */ export function verifyMetaWebhookSignature( rawBody: string, @@ -23,11 +24,12 @@ export function verifyMetaWebhookSignature( ): boolean { const secret = process.env.META_APP_SECRET if (!secret) { - console.warn( - '[webhook] META_APP_SECRET not set — skipping signature verification. ' + - 'Set the env var to harden against spoofed webhook POSTs.', + console.error( + '[webhook] META_APP_SECRET is not set — rejecting request. ' + + 'Configure the env var (Meta → App Settings → Basic → App Secret) ' + + 'to enable signature verification.', ) - return true + return false } if (!signatureHeader) return false