From 526422abed6e725d0c7732344ea6f453ebe4ee33 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Fri, 30 Oct 2026 11:20:30 -0700 Subject: [PATCH] Give /api/preview-link its own Bearer secret; constant-time token checks preview-link mints working form links for any contact, but was gated by HEALTH_TOKEN (the diagnostics secret) passed in the query string, where it lands in access logs. README already documented PREVIEW_ADMIN_TOKEN as a Bearer header; make the code match. - PREVIEW_ADMIN_TOKEN, when set, is required as Authorization: Bearer and is never accepted from the URL. - Until it is set, HEALTH_TOKEN still works (Bearer or legacy ?token=) so the support runbook in EMAIL_DELIVERY.md keeps working. - preview-link and /api/health compare tokens in constant time. - redact() also scrubs PREVIEW_ADMIN_TOKEN and STAFF_REPORT_KEY. --- EMAIL_DELIVERY.md | 12 ++++++++---- README.md | 2 +- app/api/health/route.ts | 3 ++- app/api/preview-link/route.ts | 30 ++++++++++++++++++------------ lib/env.ts | 15 ++++++++++++++- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/EMAIL_DELIVERY.md b/EMAIL_DELIVERY.md index 432cd34..8a8b032 100644 --- a/EMAIL_DELIVERY.md +++ b/EMAIL_DELIVERY.md @@ -147,13 +147,17 @@ You can use any combination. Common pattern: Option C for stage transitions Generates a working URL for any contact, returning the URL and the contact/org context for verification. -**Auth:** requires the `HEALTH_TOKEN` env var (same secret as -`/api/health`). In development without `HEALTH_TOKEN` set, no auth required. +**Auth:** send `PREVIEW_ADMIN_TOKEN` as a Bearer header. This endpoint +mints working links for any contact, so it has its own secret, kept out of +URLs. Until `PREVIEW_ADMIN_TOKEN` is set in Amplify, `HEALTH_TOKEN` is +accepted instead (Bearer header or legacy `?token=`). In development with +neither set, no auth is required. **Request:** ```bash -curl "https://survey.fci.coop/api/preview-link?cid=513&token=$HEALTH_TOKEN" +curl -H "Authorization: Bearer $PREVIEW_ADMIN_TOKEN" \ + "https://survey.fci.coop/api/preview-link?cid=513" ``` **Response:** @@ -196,7 +200,7 @@ curl "https://survey.fci.coop/api/preview-link?cid=513&token=$HEALTH_TOKEN" ### "Contact reports the link doesn't work" -1. Hit `/api/preview-link?cid=&token=`. +1. Hit `/api/preview-link?cid=` with `Authorization: Bearer `. 2. Verify the response shows the right org name + stage. 3. Send the URL from the response directly to the contact (or have them try it). 4. If `/api/preview-link` 404s, the contact lacks a Primary Contact relationship — fix in CiviCRM first. diff --git a/README.md b/README.md index b88a957..a0d3d6d 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ no live CRM calls. Production startup will refuse to boot in stub mode. | `CIVI_HTTP_AUTH_USER` | optional | Username, if CiviCRM sits behind HTTP Basic Auth at the webserver layer | | `CIVI_HTTP_AUTH_PASS` | optional | Password for the above | | `HEALTH_TOKEN` | optional | If set, `/api/health` requires `?token=…` in production | -| `PREVIEW_ADMIN_TOKEN` | optional | If set, `/api/preview-link` requires a matching `Authorization: Bearer …` header (admin convenience endpoint for minting form links) | +| `PREVIEW_ADMIN_TOKEN` | recommended | `/api/preview-link` requires a matching `Authorization: Bearer …` header (admin endpoint for minting form links). Until set, `HEALTH_TOKEN` is accepted instead | | `NODE_ENV` | — | Set to `production` for the production build | Copy `.env.example` to `.env.local` for local development. diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 810ad40..28cf629 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -22,6 +22,7 @@ import { NextResponse } from "next/server"; import { civi, verifyChecksum } from "@/lib/civicrm"; import { ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form"; import { appEnv } from "@/lib/env"; +import { safeEqual } from "@/lib/staff-token.mjs"; interface CheckResult { check: string; @@ -362,7 +363,7 @@ export async function GET(req: Request) { if (!e.healthToken) { return new NextResponse("Not Found", { status: 404 }); } - if (url.searchParams.get("token") !== e.healthToken) { + if (!safeEqual(url.searchParams.get("token") ?? "", e.healthToken)) { return new NextResponse("Not Found", { status: 404 }); } } diff --git a/app/api/preview-link/route.ts b/app/api/preview-link/route.ts index 94aa23a..7cc809b 100644 --- a/app/api/preview-link/route.ts +++ b/app/api/preview-link/route.ts @@ -1,5 +1,6 @@ /** - * GET /api/preview-link?cid=&token= + * GET /api/preview-link?cid= + * Authorization: Bearer * * Returns a ready-to-send survey URL for a given contact, plus enough * context for staff to verify it's the right org. Used for: @@ -7,10 +8,12 @@ * - Testing a contact's setup before sending the real CiviCRM email * - QA before rollout * - * AUTH: Always requires the HEALTH_TOKEN (same secret as /api/health). In - * development, if HEALTH_TOKEN is unset, anyone can hit the endpoint — - * matches /api/health behavior so dev workflow stays frictionless. In - * production, the endpoint returns 404 if HEALTH_TOKEN is unset. + * AUTH: this mints working form links for ANY contact, so it gets its own + * secret, PREVIEW_ADMIN_TOKEN, sent as a Bearer header to keep it out of + * URLs and access logs. Until PREVIEW_ADMIN_TOKEN is set, HEALTH_TOKEN is + * accepted instead (as a Bearer header or the legacy `?token=`) so the + * existing support workflow keeps working. With neither set, production + * returns 404 and development allows passthrough. * * Returns: * { @@ -26,6 +29,7 @@ import { NextResponse } from "next/server"; import { civi } from "@/lib/civicrm"; import { FORM_CONTACT_RELATIONSHIP } from "@/config/form"; import { appEnv } from "@/lib/env"; +import { safeEqual } from "@/lib/staff-token.mjs"; interface PreviewLinkResponse { contactId: number; @@ -42,13 +46,15 @@ export async function GET(req: Request) { const env = appEnv(); const url = new URL(req.url); const cid = url.searchParams.get("cid"); - const token = url.searchParams.get("token"); - - // Auth gate: production requires HEALTH_TOKEN. Dev allows passthrough. - if (env.isProduction) { - if (!env.healthToken) return new NextResponse("Not Found", { status: 404 }); - if (token !== env.healthToken) return new NextResponse("Not Found", { status: 404 }); - } else if (env.healthToken && token !== env.healthToken) { + // Auth gate — see header comment. + const secret = env.previewAdminToken || env.healthToken; + if (secret) { + const bearer = req.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1]; + const presented = bearer ?? (env.previewAdminToken ? null : url.searchParams.get("token")); + if (!presented || !safeEqual(presented, secret)) { + return new NextResponse("Not Found", { status: 404 }); + } + } else if (env.isProduction) { return new NextResponse("Not Found", { status: 404 }); } diff --git a/lib/env.ts b/lib/env.ts index 0e9e27d..e6d6731 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -22,6 +22,11 @@ export interface AppEnv { * string and pass via `?token=<>` to the health endpoint to read diagnostics. */ healthToken?: string; + /** + * Dedicated secret for /api/preview-link, sent as `Authorization: Bearer`. + * When unset, that endpoint falls back to HEALTH_TOKEN. + */ + previewAdminToken?: string; /** * Same-origin host this app runs at. Used to build `frame-ancestors 'self'` * and any absolute self-links. Defaults to inferring from the request. @@ -46,6 +51,7 @@ export function appEnv(): AppEnv { basicAuthUser: process.env.CIVI_HTTP_AUTH_USER, basicAuthPass: process.env.CIVI_HTTP_AUTH_PASS, healthToken: process.env.HEALTH_TOKEN, + previewAdminToken: process.env.PREVIEW_ADMIN_TOKEN, publicOrigin: process.env.PUBLIC_ORIGIN, }; // In production, refuse to start in stub mode — that's almost certainly a @@ -64,7 +70,14 @@ export function appEnv(): AppEnv { export function redact(s: string): string { let out = s; const e = appEnv(); - for (const v of [e.civiApiKey, e.civiSiteKey, e.basicAuthPass, e.healthToken]) { + for (const v of [ + e.civiApiKey, + e.civiSiteKey, + e.basicAuthPass, + e.healthToken, + e.previewAdminToken, + process.env.STAFF_REPORT_KEY, + ]) { if (v && v.length >= 6) out = out.split(v).join("«redacted»"); } return out;