← 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
M data/features.jsonM server.js
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 →