Security: signed staff tokens, fail-closed checksum, upload & submit hardening #2

Merged
joelbrock merged 6 commits from security/staff-auth-hardening into main 2026-10-30 11:24:28 -07:00
5 changed files with 43 additions and 19 deletions
Showing only changes of commit 526422abed - Show all commits
+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;