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.
This commit is contained in:
Joel Brock
2026-10-30 11:20:30 -07:00
parent df471e0e88
commit 526422abed
5 changed files with 43 additions and 19 deletions
+8 -4
View File
@@ -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 Generates a working URL for any contact, returning the URL and the
contact/org context for verification. contact/org context for verification.
**Auth:** requires the `HEALTH_TOKEN` env var (same secret as **Auth:** send `PREVIEW_ADMIN_TOKEN` as a Bearer header. This endpoint
`/api/health`). In development without `HEALTH_TOKEN` set, no auth required. 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:** **Request:**
```bash ```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:** **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" ### "Contact reports the link doesn't work"
1. Hit `/api/preview-link?cid=<their-cid>&token=<HEALTH_TOKEN>`. 1. Hit `/api/preview-link?cid=<their-cid>` with `Authorization: Bearer <PREVIEW_ADMIN_TOKEN>`.
2. Verify the response shows the right org name + stage. 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). 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. 4. If `/api/preview-link` 404s, the contact lacks a Primary Contact relationship — fix in CiviCRM first.
+1 -1
View File
@@ -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_USER` | optional | Username, if CiviCRM sits behind HTTP Basic Auth at the webserver layer |
| `CIVI_HTTP_AUTH_PASS` | optional | Password for the above | | `CIVI_HTTP_AUTH_PASS` | optional | Password for the above |
| `HEALTH_TOKEN` | optional | If set, `/api/health` requires `?token=…` in production | | `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 | | `NODE_ENV` | — | Set to `production` for the production build |
Copy `.env.example` to `.env.local` for local development. Copy `.env.example` to `.env.local` for local development.
+2 -1
View File
@@ -22,6 +22,7 @@ import { NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm"; import { civi, verifyChecksum } from "@/lib/civicrm";
import { ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form"; import { ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
import { appEnv } from "@/lib/env"; import { appEnv } from "@/lib/env";
import { safeEqual } from "@/lib/staff-token.mjs";
interface CheckResult { interface CheckResult {
check: string; check: string;
@@ -362,7 +363,7 @@ export async function GET(req: Request) {
if (!e.healthToken) { if (!e.healthToken) {
return new NextResponse("Not Found", { status: 404 }); 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 }); return new NextResponse("Not Found", { status: 404 });
} }
} }
+18 -12
View File
@@ -1,5 +1,6 @@
/** /**
* GET /api/preview-link?cid=<contactId>&token=<HEALTH_TOKEN> * GET /api/preview-link?cid=<contactId>
* Authorization: Bearer <PREVIEW_ADMIN_TOKEN>
* *
* Returns a ready-to-send survey URL for a given contact, plus enough * 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: * 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 * - Testing a contact's setup before sending the real CiviCRM email
* - QA before rollout * - QA before rollout
* *
* AUTH: Always requires the HEALTH_TOKEN (same secret as /api/health). In * AUTH: this mints working form links for ANY contact, so it gets its own
* development, if HEALTH_TOKEN is unset, anyone can hit the endpoint — * secret, PREVIEW_ADMIN_TOKEN, sent as a Bearer header to keep it out of
* matches /api/health behavior so dev workflow stays frictionless. In * URLs and access logs. Until PREVIEW_ADMIN_TOKEN is set, HEALTH_TOKEN is
* production, the endpoint returns 404 if HEALTH_TOKEN is unset. * 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: * Returns:
* { * {
@@ -26,6 +29,7 @@ import { NextResponse } from "next/server";
import { civi } from "@/lib/civicrm"; import { civi } from "@/lib/civicrm";
import { FORM_CONTACT_RELATIONSHIP } from "@/config/form"; import { FORM_CONTACT_RELATIONSHIP } from "@/config/form";
import { appEnv } from "@/lib/env"; import { appEnv } from "@/lib/env";
import { safeEqual } from "@/lib/staff-token.mjs";
interface PreviewLinkResponse { interface PreviewLinkResponse {
contactId: number; contactId: number;
@@ -42,13 +46,15 @@ export async function GET(req: Request) {
const env = appEnv(); const env = appEnv();
const url = new URL(req.url); const url = new URL(req.url);
const cid = url.searchParams.get("cid"); const cid = url.searchParams.get("cid");
const token = url.searchParams.get("token"); // Auth gate — see header comment.
const secret = env.previewAdminToken || env.healthToken;
// Auth gate: production requires HEALTH_TOKEN. Dev allows passthrough. if (secret) {
if (env.isProduction) { const bearer = req.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
if (!env.healthToken) return new NextResponse("Not Found", { status: 404 }); const presented = bearer ?? (env.previewAdminToken ? null : url.searchParams.get("token"));
if (token !== env.healthToken) return new NextResponse("Not Found", { status: 404 }); if (!presented || !safeEqual(presented, secret)) {
} else if (env.healthToken && token !== env.healthToken) { return new NextResponse("Not Found", { status: 404 });
}
} else if (env.isProduction) {
return new NextResponse("Not Found", { status: 404 }); return new NextResponse("Not Found", { status: 404 });
} }
+14 -1
View File
@@ -22,6 +22,11 @@ export interface AppEnv {
* string and pass via `?token=<>` to the health endpoint to read diagnostics. * string and pass via `?token=<>` to the health endpoint to read diagnostics.
*/ */
healthToken?: string; 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'` * Same-origin host this app runs at. Used to build `frame-ancestors 'self'`
* and any absolute self-links. Defaults to inferring from the request. * 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, basicAuthUser: process.env.CIVI_HTTP_AUTH_USER,
basicAuthPass: process.env.CIVI_HTTP_AUTH_PASS, basicAuthPass: process.env.CIVI_HTTP_AUTH_PASS,
healthToken: process.env.HEALTH_TOKEN, healthToken: process.env.HEALTH_TOKEN,
previewAdminToken: process.env.PREVIEW_ADMIN_TOKEN,
publicOrigin: process.env.PUBLIC_ORIGIN, publicOrigin: process.env.PUBLIC_ORIGIN,
}; };
// In production, refuse to start in stub mode — that's almost certainly a // 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 { export function redact(s: string): string {
let out = s; let out = s;
const e = appEnv(); 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»"); if (v && v.length >= 6) out = out.split(v).join("«redacted»");
} }
return out; return out;