← back to Norma
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
M app/api/pulse/petitions/route.tsM middleware.tsM tests/security-regression.mjs
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
·
auto-save: 2026-08-05T14:42:56 (14 files) — app/api/cron/gma 24209b8 →