Security: signed staff tokens, fail-closed checksum, upload & submit hardening #2
+8
-4
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user