← back to Norma Platform
Apply consent red-team fixes: fail-closed token secret + per-IP rate limit
6d6130750c91340a5ecb4ee86e03de38cf0c70b6 · 2026-09-09 15:19:14 -0700 · Steve Abrams
Cody (contrarian) red-team of the verified-consent slice surfaced two real
holes; both fixed and re-verified:
1. Token signing now FAILS CLOSED on a missing SESSION_SECRET (secret()
helper) instead of falling back to the literal 'invalid' — that fallback
would have minted a publicly-computable, forgeable consent token in a
misconfigured deploy. SESSION_SECRET already underpins the auth cookies,
so a real deployment always has it.
2. Public /api/consent (confirm/unsubscribe) now carries the same per-IP
rate limiter as every other public write (consentRateLimit, 60/5min),
reusing intake.ts's proven organizing_intake_limits pattern.
3. RUNBOOK documents that NORMA_PUBLIC_ORIGIN MUST be pinned before a live
email provider is wired (Host-header phishing vector; moot while dispatch
is sandbox-only).
Re-verified on a fresh build with the fixes on disk: tsc clean, eslint 0
errors, 15/15 API journeys, 8/8 Chromium+WebKit browser tests, and the
production-default gate (dispatcher refused outside sandbox). Verification
evidence refreshed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTfwQCiPbW99SJV5RYRe4L
Files touched
M app/api/consent/route.tsM docs/platform/RUNBOOK.mdM lib/campaigns/consent.tsM verification/platform/api-results.jsonM verification/platform/browser-results.jsonM verification/platform/publication-results.jsonM verification/platform/screenshots/chromium-advocacy.pngM verification/platform/screenshots/chromium-campaign.pngM verification/platform/screenshots/chromium-consent.pngM verification/platform/screenshots/chromium-event.pngM verification/platform/screenshots/chromium-fundraiser.pngM verification/platform/screenshots/chromium-mobile.pngM verification/platform/screenshots/chromium-receipt.pngM verification/platform/screenshots/chromium-volunteer.pngM verification/platform/screenshots/webkit-advocacy.pngM verification/platform/screenshots/webkit-campaign.pngM verification/platform/screenshots/webkit-consent.pngM verification/platform/screenshots/webkit-event.pngM verification/platform/screenshots/webkit-fundraiser.pngM verification/platform/screenshots/webkit-mobile.pngM verification/platform/screenshots/webkit-receipt.pngM verification/platform/screenshots/webkit-volunteer.png
Diff
commit 6d6130750c91340a5ecb4ee86e03de38cf0c70b6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 9 15:19:14 2026 -0700
Apply consent red-team fixes: fail-closed token secret + per-IP rate limit
Cody (contrarian) red-team of the verified-consent slice surfaced two real
holes; both fixed and re-verified:
1. Token signing now FAILS CLOSED on a missing SESSION_SECRET (secret()
helper) instead of falling back to the literal 'invalid' — that fallback
would have minted a publicly-computable, forgeable consent token in a
misconfigured deploy. SESSION_SECRET already underpins the auth cookies,
so a real deployment always has it.
2. Public /api/consent (confirm/unsubscribe) now carries the same per-IP
rate limiter as every other public write (consentRateLimit, 60/5min),
reusing intake.ts's proven organizing_intake_limits pattern.
3. RUNBOOK documents that NORMA_PUBLIC_ORIGIN MUST be pinned before a live
email provider is wired (Host-header phishing vector; moot while dispatch
is sandbox-only).
Re-verified on a fresh build with the fixes on disk: tsc clean, eslint 0
errors, 15/15 API journeys, 8/8 Chromium+WebKit browser tests, and the
production-default gate (dispatcher refused outside sandbox). Verification
evidence refreshed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTfwQCiPbW99SJV5RYRe4L
---
app/api/consent/route.ts | 3 +-
docs/platform/RUNBOOK.md | 2 +-
lib/campaigns/consent.ts | 31 +++++++++++++++++-
verification/platform/api-results.json | 2 +-
verification/platform/browser-results.json | 36 ++++++++++-----------
verification/platform/publication-results.json | 2 +-
.../platform/screenshots/chromium-advocacy.png | Bin 100627 -> 102670 bytes
.../platform/screenshots/chromium-campaign.png | Bin 146806 -> 145602 bytes
.../platform/screenshots/chromium-consent.png | Bin 141402 -> 142866 bytes
.../platform/screenshots/chromium-event.png | Bin 107534 -> 108818 bytes
.../platform/screenshots/chromium-fundraiser.png | Bin 83281 -> 84637 bytes
.../platform/screenshots/chromium-mobile.png | Bin 817026 -> 940034 bytes
.../platform/screenshots/chromium-receipt.png | Bin 92916 -> 91477 bytes
.../platform/screenshots/chromium-volunteer.png | Bin 94989 -> 96715 bytes
.../platform/screenshots/webkit-advocacy.png | Bin 335106 -> 334913 bytes
.../platform/screenshots/webkit-campaign.png | Bin 463757 -> 464877 bytes
.../platform/screenshots/webkit-consent.png | Bin 435577 -> 432134 bytes
verification/platform/screenshots/webkit-event.png | Bin 359148 -> 356821 bytes
.../platform/screenshots/webkit-fundraiser.png | Bin 300507 -> 298397 bytes
.../platform/screenshots/webkit-mobile.png | Bin 2559064 -> 2918553 bytes
.../platform/screenshots/webkit-receipt.png | Bin 304922 -> 305728 bytes
.../platform/screenshots/webkit-volunteer.png | Bin 317361 -> 314359 bytes
22 files changed, 53 insertions(+), 23 deletions(-)
diff --git a/app/api/consent/route.ts b/app/api/consent/route.ts
index 13b64a6..6e00592 100644
--- a/app/api/consent/route.ts
+++ b/app/api/consent/route.ts
@@ -1,12 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { body, CampaignError, failure } from '@/lib/campaigns/validation';
-import { confirmConsent, unsubscribeConsent } from '@/lib/campaigns/consent';
+import { confirmConsent, consentRateLimit, unsubscribeConsent } from '@/lib/campaigns/consent';
export const dynamic = 'force-dynamic';
// Public, token-gated. No session — possession of the emailed link is the authorization.
// A landing page POSTs here same-origin so a link prefetch can never flip consent state.
export async function POST(request: NextRequest) {
try {
const data = await body(request);
+ await consentRateLimit(request);
if (data.choice === 'confirm') return NextResponse.json(await confirmConsent(data.supporter_id as string, data.token), { headers: { 'Cache-Control': 'no-store' } });
if (data.choice === 'unsubscribe') return NextResponse.json(await unsubscribeConsent(data.supporter_id as string, data.token), { headers: { 'Cache-Control': 'no-store' } });
throw new CampaignError(400, 'Choose confirm or unsubscribe.');
diff --git a/docs/platform/RUNBOOK.md b/docs/platform/RUNBOOK.md
index fd26db8..30cf0ef 100644
--- a/docs/platform/RUNBOOK.md
+++ b/docs/platform/RUNBOOK.md
@@ -36,7 +36,7 @@ The gate runs its own isolated process with public campaign intake disabled. Whi
- `NORMA_CAMPAIGN_SANDBOX=true` works only when `DATABASE_URL` names `sdcc_test`. The preview uses this mode.
- Public activation/intake defaults off elsewhere. `NORMA_CAMPAIGN_PUBLIC_ENABLED=true` must be deliberately configured for a separately approved public deployment.
-- Set `NORMA_PUBLIC_ORIGIN` to the exact external origin behind an HTTPS reverse proxy. The ingress must overwrite client-IP headers, strip caller-supplied forwarded headers, and impose global abuse limits. The per-action limiter is not a substitute for ingress protection.
+- Set `NORMA_PUBLIC_ORIGIN` to the exact external origin behind an HTTPS reverse proxy. The ingress must overwrite client-IP headers, strip caller-supplied forwarded headers, and impose global abuse limits. The per-action limiter is not a substitute for ingress protection. **`NORMA_PUBLIC_ORIGIN` MUST be pinned before a live email provider is wired** — confirm/unsubscribe links are built from it, and an unpinned (Host-derived) origin would become a phishing link mailed to real supporters. Today this is moot: dispatch is sandbox-only and the link never leaves an authenticated JSON response.
- A pledge is not a payment or a recurring subscription. The optional HTTPS ActBlue link opens an external contribution form; the user selects amount/frequency on that provider. There is no payment webhook reconciliation or accounting inference.
- A participation email-update checkbox now starts a verified-consent state machine on the supporter (migration 028): `none → pending → confirmed`, with sticky `unsubscribed` and bounce-driven `suppressed` terminal states, every transition appended to an immutable `organizing_consent_events` log (a DB trigger blocks any UPDATE). The organizer "Send confirmation" dispatcher is a sandbox-only MOCK provider — it computes a deterministic delivery result (delivered / bounced → suppressed / transient-failed → retryable) and logs it but NEVER opens a network connection, and it throws outside the sandbox. Real email/SMS transmission through a verified provider is still deferred (see milestone 2). Do not treat a `pending` request as a send-ready subscriber; only `confirmed` reflects a completed double-opt-in.
- One email has one response per action. Identical retries return the original receipt; a changed duplicate returns a clear conflict instead of silently changing a pledge or consent. Supporter correction/self-service is future work.
diff --git a/lib/campaigns/consent.ts b/lib/campaigns/consent.ts
index 434f1c8..5d16013 100644
--- a/lib/campaigns/consent.ts
+++ b/lib/campaigns/consent.ts
@@ -6,11 +6,34 @@ import { CampaignError, id, publicOrigin, sandbox } from './validation';
type ConsentEvent = 'requested' | 'dispatched' | 'delivered' | 'bounced' | 'failed' | 'confirmed' | 'unsubscribed';
+// A consent token is a security boundary, not obfuscation, so it must FAIL CLOSED on a
+// missing signing key — never fall back to a public literal the way a rate-limiter bucket
+// key can. SESSION_SECRET already underpins the auth cookies, so it is present in any real
+// deployment; if it is somehow absent we refuse to mint/verify rather than issue a
+// publicly-computable token that anyone with a supporter UUID could forge.
+function secret() {
+ const s = process.env.SESSION_SECRET;
+ if (!s) throw new Error('SESSION_SECRET is required to sign consent tokens');
+ return s;
+}
// Possession of the link is the proof of identity for a public confirm/unsubscribe.
// The token is only ever minted inside a dispatched confirmation (sandbox-only today),
// so no valid token can exist in a deployment without a wired provider.
function token(supporterId: string) {
- return createHmac('sha256', process.env.SESSION_SECRET || 'invalid').update(`consent:${supporterId}`).digest('hex');
+ return createHmac('sha256', secret()).update(`consent:${supporterId}`).digest('hex');
+}
+// Per-IP rate limit for the unauthenticated /api/consent endpoint, matching the public
+// intake limiter in intake.ts (every public write in this codebase gets one). A valid
+// token only ever controls its own supporter's record, but this bounds abuse of the
+// endpoint the same way the rest of the platform does.
+export async function consentRateLimit(request: Request) {
+ const ip = request.headers.get('x-real-ip') || request.headers.get('x-forwarded-for')?.split(',')[0].trim() || 'local';
+ const key = createHmac('sha256', secret()).update(`consent-endpoint:${ip}`).digest('hex');
+ await query(`DELETE FROM organizing_intake_limits WHERE key IN (SELECT key FROM organizing_intake_limits WHERE expires_at<now() ORDER BY expires_at LIMIT 100)`);
+ const hits = (await query(`INSERT INTO organizing_intake_limits(key,hits,expires_at) VALUES($1,1,now()+interval '5 minutes')
+ ON CONFLICT(key) DO UPDATE SET hits=CASE WHEN organizing_intake_limits.expires_at<now() THEN 1 ELSE organizing_intake_limits.hits+1 END,
+ expires_at=CASE WHEN organizing_intake_limits.expires_at<now() THEN now()+interval '5 minutes' ELSE organizing_intake_limits.expires_at END RETURNING hits`, [key])).rows[0].hits;
+ if (hits > 60) throw new CampaignError(429, 'Too many requests. Please try again in a few minutes.');
}
function verify(supporterId: string, presented: unknown) {
if (typeof presented !== 'string' || !/^[0-9a-f]{64}$/.test(presented)) return false;
@@ -64,6 +87,12 @@ export async function dispatchConfirmation(request: Request, s: Scope, supporter
await client.query("UPDATE organizing_supporters SET consent_state='suppressed',consent_updated_at=now() WHERE id=$1 AND org_id=$2", [supporterId, s.org]);
}
await audit(client, s, `consent.dispatched.${result}`, supporterId);
+ // origin comes from publicOrigin(), which trusts the Host header when
+ // NORMA_PUBLIC_ORIGIN is unset. Harmless while dispatch is sandbox-only (the link is
+ // returned in an authenticated JSON response and never mailed), but BEFORE a real
+ // provider is wired and this sandbox guard is removed, NORMA_PUBLIC_ORIGIN MUST be
+ // pinned — otherwise a Host-header-injected origin becomes a phishing link to real
+ // supporters (see docs/platform/RUNBOOK.md integration boundaries).
const link = (choice: string) => `${origin}/act/consent?s=${supporterId}&t=${token(supporterId)}&c=${choice}`;
// The raw token stands in for the emailed link; expose it only in the sandbox so a
// real organizer can never confirm on a supporter's behalf.
diff --git a/verification/platform/api-results.json b/verification/platform/api-results.json
index aaf00d5..b9b3691 100644
--- a/verification/platform/api-results.json
+++ b/verification/platform/api-results.json
@@ -1,5 +1,5 @@
{
- "timestamp": "2026-09-09T21:47:24.597Z",
+ "timestamp": "2026-09-09T22:16:57.572Z",
"base": "http://127.0.0.1:7416",
"checks": [
{
diff --git a/verification/platform/browser-results.json b/verification/platform/browser-results.json
index ca3ef02..34374c8 100644
--- a/verification/platform/browser-results.json
+++ b/verification/platform/browser-results.json
@@ -104,12 +104,12 @@
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
- "duration": 4314,
+ "duration": 4113,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T21:53:48.650Z",
+ "startTime": "2026-09-09T22:17:18.212Z",
"annotations": [],
"attachments": [
{
@@ -144,12 +144,12 @@
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
- "duration": 1801,
+ "duration": 1298,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T21:53:53.082Z",
+ "startTime": "2026-09-09T22:17:22.558Z",
"annotations": [],
"attachments": [
{
@@ -184,12 +184,12 @@
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
- "duration": 13736,
+ "duration": 4158,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T21:53:54.900Z",
+ "startTime": "2026-09-09T22:17:23.860Z",
"annotations": [],
"attachments": [
{
@@ -224,12 +224,12 @@
"workerIndex": 0,
"parallelIndex": 0,
"status": "passed",
- "duration": 4546,
+ "duration": 1933,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T21:54:08.655Z",
+ "startTime": "2026-09-09T22:17:28.022Z",
"annotations": [],
"attachments": [
{
@@ -264,12 +264,12 @@
"workerIndex": 1,
"parallelIndex": 0,
"status": "passed",
- "duration": 2795,
+ "duration": 3237,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T21:54:13.490Z",
+ "startTime": "2026-09-09T22:17:30.195Z",
"annotations": [],
"attachments": [
{
@@ -304,12 +304,12 @@
"workerIndex": 1,
"parallelIndex": 0,
"status": "passed",
- "duration": 1393,
+ "duration": 3879,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T21:54:16.509Z",
+ "startTime": "2026-09-09T22:17:33.922Z",
"annotations": [],
"attachments": [
{
@@ -344,12 +344,12 @@
"workerIndex": 1,
"parallelIndex": 0,
"status": "passed",
- "duration": 5032,
+ "duration": 4529,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T21:54:17.907Z",
+ "startTime": "2026-09-09T22:17:37.806Z",
"annotations": [],
"attachments": [
{
@@ -384,12 +384,12 @@
"workerIndex": 1,
"parallelIndex": 0,
"status": "passed",
- "duration": 2191,
+ "duration": 2248,
"errors": [],
"stdout": [],
"stderr": [],
"retry": 0,
- "startTime": "2026-09-09T21:54:22.943Z",
+ "startTime": "2026-09-09T22:17:42.339Z",
"annotations": [],
"attachments": [
{
@@ -413,8 +413,8 @@
],
"errors": [],
"stats": {
- "startTime": "2026-09-09T21:53:48.423Z",
- "duration": 36760.004,
+ "startTime": "2026-09-09T22:17:18.004Z",
+ "duration": 26634.139000000003,
"expected": 8,
"skipped": 0,
"unexpected": 0,
diff --git a/verification/platform/publication-results.json b/verification/platform/publication-results.json
index a9b969f..051e696 100644
--- a/verification/platform/publication-results.json
+++ b/verification/platform/publication-results.json
@@ -1,5 +1,5 @@
{
- "timestamp": "2026-09-09T21:56:24.740Z",
+ "timestamp": "2026-09-09T22:18:33.007Z",
"base": "http://127.0.0.1:7417",
"checks": [
{
diff --git a/verification/platform/screenshots/chromium-advocacy.png b/verification/platform/screenshots/chromium-advocacy.png
index b2ff018..8832c80 100644
Binary files a/verification/platform/screenshots/chromium-advocacy.png and b/verification/platform/screenshots/chromium-advocacy.png differ
diff --git a/verification/platform/screenshots/chromium-campaign.png b/verification/platform/screenshots/chromium-campaign.png
index 7e5cdc8..4224c4b 100644
Binary files a/verification/platform/screenshots/chromium-campaign.png and b/verification/platform/screenshots/chromium-campaign.png differ
diff --git a/verification/platform/screenshots/chromium-consent.png b/verification/platform/screenshots/chromium-consent.png
index b490d3a..ef7fb31 100644
Binary files a/verification/platform/screenshots/chromium-consent.png and b/verification/platform/screenshots/chromium-consent.png differ
diff --git a/verification/platform/screenshots/chromium-event.png b/verification/platform/screenshots/chromium-event.png
index 025d59f..a3e70eb 100644
Binary files a/verification/platform/screenshots/chromium-event.png and b/verification/platform/screenshots/chromium-event.png differ
diff --git a/verification/platform/screenshots/chromium-fundraiser.png b/verification/platform/screenshots/chromium-fundraiser.png
index 80a8769..5b278ee 100644
Binary files a/verification/platform/screenshots/chromium-fundraiser.png and b/verification/platform/screenshots/chromium-fundraiser.png differ
diff --git a/verification/platform/screenshots/chromium-mobile.png b/verification/platform/screenshots/chromium-mobile.png
index 0f5371d..f5743f5 100644
Binary files a/verification/platform/screenshots/chromium-mobile.png and b/verification/platform/screenshots/chromium-mobile.png differ
diff --git a/verification/platform/screenshots/chromium-receipt.png b/verification/platform/screenshots/chromium-receipt.png
index aaa4422..fd7e67b 100644
Binary files a/verification/platform/screenshots/chromium-receipt.png and b/verification/platform/screenshots/chromium-receipt.png differ
diff --git a/verification/platform/screenshots/chromium-volunteer.png b/verification/platform/screenshots/chromium-volunteer.png
index 86c9aef..ad43204 100644
Binary files a/verification/platform/screenshots/chromium-volunteer.png and b/verification/platform/screenshots/chromium-volunteer.png differ
diff --git a/verification/platform/screenshots/webkit-advocacy.png b/verification/platform/screenshots/webkit-advocacy.png
index c635ce9..bbc393b 100644
Binary files a/verification/platform/screenshots/webkit-advocacy.png and b/verification/platform/screenshots/webkit-advocacy.png differ
diff --git a/verification/platform/screenshots/webkit-campaign.png b/verification/platform/screenshots/webkit-campaign.png
index d624647..cedc6a8 100644
Binary files a/verification/platform/screenshots/webkit-campaign.png and b/verification/platform/screenshots/webkit-campaign.png differ
diff --git a/verification/platform/screenshots/webkit-consent.png b/verification/platform/screenshots/webkit-consent.png
index db6a595..e12f0b8 100644
Binary files a/verification/platform/screenshots/webkit-consent.png and b/verification/platform/screenshots/webkit-consent.png differ
diff --git a/verification/platform/screenshots/webkit-event.png b/verification/platform/screenshots/webkit-event.png
index aa0d9b0..9c676df 100644
Binary files a/verification/platform/screenshots/webkit-event.png and b/verification/platform/screenshots/webkit-event.png differ
diff --git a/verification/platform/screenshots/webkit-fundraiser.png b/verification/platform/screenshots/webkit-fundraiser.png
index 0afe87c..d3680d9 100644
Binary files a/verification/platform/screenshots/webkit-fundraiser.png and b/verification/platform/screenshots/webkit-fundraiser.png differ
diff --git a/verification/platform/screenshots/webkit-mobile.png b/verification/platform/screenshots/webkit-mobile.png
index 33c091c..ac3838b 100644
Binary files a/verification/platform/screenshots/webkit-mobile.png and b/verification/platform/screenshots/webkit-mobile.png differ
diff --git a/verification/platform/screenshots/webkit-receipt.png b/verification/platform/screenshots/webkit-receipt.png
index ce69c68..9f3c4e7 100644
Binary files a/verification/platform/screenshots/webkit-receipt.png and b/verification/platform/screenshots/webkit-receipt.png differ
diff --git a/verification/platform/screenshots/webkit-volunteer.png b/verification/platform/screenshots/webkit-volunteer.png
index 95db07d..c8b9e03 100644
Binary files a/verification/platform/screenshots/webkit-volunteer.png and b/verification/platform/screenshots/webkit-volunteer.png differ
← f6bd2e4 Add verified-consent state machine, immutable log, and sandb
·
back to Norma Platform
·
Add donation reconciliation slice (TK-11335 Section A, local b18aac0 →