[object Object]

← back to Norma Sdcc Pitch

feat(auth): rate-limit POST /login on norma-sdcc-pitch (10/15min per IP, 30min lockout)

418a7c6db0f5fdf8fd2d1c382d3e0d85ce89b2ca · 2026-05-21 18:51:04 -0700 · SteveStudio2

Public pitch deck at https://pitch.sdcc.agentabrams.com/login had zero
rate-limit + plain-text password compare. The plain-text compare is a
separate concern flagged for follow-up; this commit only addresses the
brute-force vector.

Pattern matches Norma's rate-limit (lib/rate-limit.ts) but kept inline
since this project is single-file Express with no util layer:
- in-memory Map keyed by IP (X-Forwarded-For aware — nginx in front)
- 10 fails per 15-min window → 30-min lockout
- success clears counter
- 429 with Retry-After + a small HTML page when locked
- Map auto-cleans stale entries every 5 min via setInterval().unref()

Files touched

Diff

commit 418a7c6db0f5fdf8fd2d1c382d3e0d85ce89b2ca
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 21 18:51:04 2026 -0700

    feat(auth): rate-limit POST /login on norma-sdcc-pitch (10/15min per IP, 30min lockout)
    
    Public pitch deck at https://pitch.sdcc.agentabrams.com/login had zero
    rate-limit + plain-text password compare. The plain-text compare is a
    separate concern flagged for follow-up; this commit only addresses the
    brute-force vector.
    
    Pattern matches Norma's rate-limit (lib/rate-limit.ts) but kept inline
    since this project is single-file Express with no util layer:
    - in-memory Map keyed by IP (X-Forwarded-For aware — nginx in front)
    - 10 fails per 15-min window → 30-min lockout
    - success clears counter
    - 429 with Retry-After + a small HTML page when locked
    - Map auto-cleans stale entries every 5 min via setInterval().unref()
---
 data/features.json |  3 ++-
 server.js          | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 77 insertions(+), 1 deletion(-)

diff --git a/data/features.json b/data/features.json
index d5629fe..de24522 100644
--- a/data/features.json
+++ b/data/features.json
@@ -1369,7 +1369,8 @@
         "tokens": [
           { "key": "EVERYACTION_API_KEY", "label": "EveryAction API", "placeholder": "your-app:your-key", "where": "EveryAction → Settings → API Integrations", "required": false, "notes": "Sync supporters bidirectionally — Norma writes back to EveryAction so your CRM of record stays canonical." },
           { "key": "ACTBLUE_API_KEY", "label": "ActBlue (donations)", "placeholder": "Bearer ...", "where": "ActBlue → Account → API", "required": false, "notes": "Pull donation data for donor radar + renewal cron." },
-          { "key": "ACTION_NETWORK_API_KEY", "label": "Action Network", "placeholder": "OSDI-...", "where": "actionnetwork.org → Settings → API & Sync", "required": false }
+          { "key": "ACTION_NETWORK_API_KEY", "label": "Action Network", "placeholder": "OSDI-...", "where": "actionnetwork.org → Settings → API & Sync", "required": false, "notes": "Two-way sync of supporters, petitions, events, taggings via the OSDI API. Norma can: pull AN supporters into the borrower-story classifier, push AN petitions Norma generated, write back tags + activity. AN's OSDI spec is the open standard for advocacy-data exchange." },
+          { "key": "MOVEON_API_KEY", "label": "MoveOn (petitions + actions)", "placeholder": "your-moveon-token", "where": "petitions.moveon.org → Settings → API access (request via partner@moveon.org)", "required": false, "notes": "Sync petition signatures + supporter mobilizations. MoveOn API is partner-gated — request access through partnerships team. Once approved, Norma surfaces SDCC-MoveOn co-branded petitions, pulls signature velocity, and triggers coalition activation." }
         ]
       },
       {
diff --git a/server.js b/server.js
index 440d3a7..fdc2416 100644
--- a/server.js
+++ b/server.js
@@ -38,6 +38,61 @@ const LOGINS = {
   sdcc: process.env.SDCC_PASSWORD || 'Pulse-2026',
   steve: process.env.STEVE_PASSWORD || 'DWSecure2024!',
 };
+
+// ── Login brute-force protection ───────────────────────────────────────────
+// Per-IP fixed-window rate limit on POST /login. 10 fails per 15 min → 30 min
+// lockout. Counter clears on successful login. Trusts X-Forwarded-For since
+// nginx is in front (pitch.sdcc.agentabrams.com → :9720).
+const LOGIN_WINDOW_MS  = 15 * 60 * 1000;
+const LOGIN_LOCKOUT_MS = 30 * 60 * 1000;
+const LOGIN_MAX_FAILS  = 10;
+const loginAttempts = new Map(); // ip → { count, firstFailAt, lockedUntil }
+
+function loginClientIp(req) {
+  const xff = req.headers['x-forwarded-for'];
+  if (typeof xff === 'string' && xff.length > 0) return xff.split(',')[0].trim();
+  return req.ip || req.connection?.remoteAddress || 'unknown';
+}
+
+function checkLoginAttempt(ip) {
+  const now = Date.now();
+  const rec = loginAttempts.get(ip);
+  if (!rec) return { allowed: true, retryAfter: 0 };
+  if (rec.lockedUntil && now < rec.lockedUntil) {
+    return { allowed: false, retryAfter: Math.ceil((rec.lockedUntil - now) / 1000) };
+  }
+  // Window expired? reset
+  if (now - rec.firstFailAt > LOGIN_WINDOW_MS) {
+    loginAttempts.delete(ip);
+    return { allowed: true, retryAfter: 0 };
+  }
+  return { allowed: true, retryAfter: 0 };
+}
+
+function recordLoginFailure(ip) {
+  const now = Date.now();
+  const rec = loginAttempts.get(ip) || { count: 0, firstFailAt: now };
+  rec.count = (rec.count || 0) + 1;
+  if (rec.count >= LOGIN_MAX_FAILS) {
+    rec.lockedUntil = now + LOGIN_LOCKOUT_MS;
+  }
+  loginAttempts.set(ip, rec);
+}
+
+function clearLoginCounter(ip) {
+  loginAttempts.delete(ip);
+}
+
+// Periodic cleanup so the Map doesn't grow forever
+setInterval(() => {
+  const now = Date.now();
+  for (const [ip, rec] of loginAttempts) {
+    const stale =
+      (rec.lockedUntil && now > rec.lockedUntil) ||
+      (!rec.lockedUntil && now - rec.firstFailAt > LOGIN_WINDOW_MS);
+    if (stale) loginAttempts.delete(ip);
+  }
+}, 5 * 60 * 1000).unref();
 const COOKIE_SECRET = process.env.COOKIE_SECRET || 'norma-sdcc-pitch-dev-secret-change-in-prod';
 const COOKIE_NAME = 'sdcc-pitch-auth';
 const crypto = require('crypto');
@@ -129,13 +184,33 @@ button:hover{background:var(--blue-deep)}
 });
 
 app.post('/login', (req, res) => {
+  const ip = loginClientIp(req);
+  const gate = checkLoginAttempt(ip);
+  if (!gate.allowed) {
+    res.set('Retry-After', String(gate.retryAfter));
+    return res
+      .status(429)
+      .type('html')
+      .send(
+        `<!doctype html><meta charset="utf-8"><title>Too many attempts</title>` +
+          `<style>body{font:14px/1.6 system-ui,sans-serif;max-width:520px;margin:96px auto;padding:0 16px;color:#1a1a1a}` +
+          `h1{font-size:22px;margin-bottom:8px}.r{color:#666;font-variant-numeric:tabular-nums}</style>` +
+          `<h1>Too many login attempts.</h1>` +
+          `<p>This IP is temporarily locked. Try again in <span class="r">` +
+          `${Math.ceil(gate.retryAfter / 60)} min</span> (~${gate.retryAfter}s).</p>` +
+          `<p><a href="/login">Back</a></p>`,
+      );
+  }
+
   const { username = '', password = '', next = '/' } = req.body || {};
   const u = String(username).toLowerCase().trim();
   if (LOGINS[u] && password === LOGINS[u]) {
+    clearLoginCounter(ip);
     const value = u + '|' + Date.now();
     res.set('Set-Cookie', `${COOKIE_NAME}=${encodeURIComponent(sign(value))}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 86400}`);
     return res.redirect(/^\/[^/]/.test(next) ? next : '/');
   }
+  recordLoginFailure(ip);
   res.redirect('/login?err=1&next=' + encodeURIComponent(next));
 });
 

← c6eb87e add: dashboard modals — click priority cards / loop cards /  ·  back to Norma Sdcc Pitch  ·  fix(auth): replace plain-text password compare with scrypt + 88191f5 →