[object Object]

← back to Norma Platform

security(yoloforever c1): rate-limit public petition CREATE (anon spam vector Cody reproduced); drop admin /api/petitions from pulse-public middleware bypass (defense-in-depth); +2 regression assertions (20 green)

b98a5b6db699922c1123677d870b0b1cec082e9d · 2026-08-05 14:23:12 -0700 · Steve

Files touched

Diff

commit b98a5b6db699922c1123677d870b0b1cec082e9d
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 5 14:23:12 2026 -0700

    security(yoloforever c1): rate-limit public petition CREATE (anon spam vector Cody reproduced); drop admin /api/petitions from pulse-public middleware bypass (defense-in-depth); +2 regression assertions (20 green)
---
 app/api/pulse/petitions/route.ts | 10 ++++++++++
 middleware.ts                    |  6 ++++--
 tests/security-regression.mjs    | 14 ++++++++++++++
 3 files changed, 28 insertions(+), 2 deletions(-)

diff --git a/app/api/pulse/petitions/route.ts b/app/api/pulse/petitions/route.ts
index b6298d9..72326d1 100644
--- a/app/api/pulse/petitions/route.ts
+++ b/app/api/pulse/petitions/route.ts
@@ -1,5 +1,6 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { query } from '@/lib/db';
+import { checkRateLimit } from '@/lib/rate-limit';
 
 /**
  * GET /api/pulse/petitions
@@ -110,6 +111,15 @@ export async function GET(request: NextRequest) {
  */
 export async function POST(request: NextRequest) {
   try {
+    // Public, unauthenticated create (intentional — anyone can start a petition).
+    // Throttle per client IP to curb petition-spam floods (10 creates / hour).
+    const rl = checkRateLimit(request, 10, 60 * 60 * 1000, 'petition-create');
+    if (rl.limited) {
+      return NextResponse.json(
+        { error: 'Too many petitions created. Please try again later.' },
+        { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.resetIn / 1000)) } },
+      );
+    }
     const body = await request.json();
 
     // Validate required fields
diff --git a/middleware.ts b/middleware.ts
index 1c02852..a836917 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -39,8 +39,10 @@ export function middleware(request: NextRequest) {
     pathname.startsWith('/api/pulse/stats') ||
     pathname.startsWith('/api/pulse/topics') ||
     pathname.startsWith('/api/pulse/articles') ||
-    pathname.startsWith('/api/pulse/fetch') ||
-    pathname.startsWith('/api/petitions');
+    pathname.startsWith('/api/pulse/fetch');
+  // NOTE: /api/petitions (admin/staff CRUD) is deliberately NOT bypassed here —
+  // it self-guards with requireRole. Whitelisting it as "public" was a
+  // defense-in-depth gap: one removed route-level guard would have opened it.
 
   if (isPulsePublic || isPulsePublicAPI) {
     return NextResponse.next();
diff --git a/tests/security-regression.mjs b/tests/security-regression.mjs
index abf37b5..a641ecc 100644
--- a/tests/security-regression.mjs
+++ b/tests/security-regression.mjs
@@ -83,6 +83,20 @@ async function main() {
   }
   ok(got429, 'petition-sign flood → 429 (rate limited)');
 
+  // --- FIX: public petition CREATE is rate-limited (anti-spam) ---
+  let create429 = false;
+  for (let i = 0; i < 14; i++) {
+    const r = await fetch(`${BASE}/api/pulse/petitions`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ title: `regr-create-${i}-longenough`, body: 'x' }),
+    });
+    if (r.status === 429) { create429 = true; break; }
+  }
+  ok(create429, 'petition CREATE flood → 429 (rate limited)');
+
+  // --- FIX: admin /api/petitions no longer middleware-bypassed as "public" ---
+  ok((await get('/api/petitions')).status !== 200, '/api/petitions unauth → not 200 (auth required)');
+
   // --- FIX: rate-limiter scoped per (ip,username) — one account's fails don't lock others ---
   // (run LAST — it trips a lock on a throwaway username)
   for (let i = 0; i < 12; i++) await login('lockbait-user', 'wrong');

← 89bc532 test: add green security-regression suite (18 assertions) +  ·  back to Norma Platform  ·  auto-save: 2026-08-05T14:42:56 (14 files) — app/api/cron/gma 24209b8 →