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
+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
* 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 });
}