[object Object]

← 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

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 →