[object Object]

← back to AbramsOS

Rate-limit auth endpoints + fix audit-log IP integrity

254e60da8b48d57d5563725a2aa2b0ed9c3998f9 · 2026-07-31 16:21:27 -0700 · Steve Abrams

- middleware/rate-limit.js: hand-rolled in-memory limiter (no dep), 12/15min/IP,
  429+Retry-After. Applied to POST /signin, /signup, /step-up. Verified: 12 ok then 429.
- clientMeta now logs req.ip (trust-proxy-resolved real client) not the raw leftmost
  X-Forwarded-For (attacker-controllable) — audit-log IP integrity.
/yoloforever cycle 3. Cody's 'XFF-spoof bypasses the limiter' CRITICAL was DISPROVEN
empirically (spoofed XFF still 429 — nginx appends real peer right; trust proxy:1 picks
it); only the audit-log (leftmost-XFF) read was the real issue. HSTS already sent by helmet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 254e60da8b48d57d5563725a2aa2b0ed9c3998f9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 31 16:21:27 2026 -0700

    Rate-limit auth endpoints + fix audit-log IP integrity
    
    - middleware/rate-limit.js: hand-rolled in-memory limiter (no dep), 12/15min/IP,
      429+Retry-After. Applied to POST /signin, /signup, /step-up. Verified: 12 ok then 429.
    - clientMeta now logs req.ip (trust-proxy-resolved real client) not the raw leftmost
      X-Forwarded-For (attacker-controllable) — audit-log IP integrity.
    /yoloforever cycle 3. Cody's 'XFF-spoof bypasses the limiter' CRITICAL was DISPROVEN
    empirically (spoofed XFF still 429 — nginx appends real peer right; trust proxy:1 picks
    it); only the audit-log (leftmost-XFF) read was the real issue. HSTS already sent by helmet.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 middleware/rate-limit.js | 39 +++++++++++++++++++++++++++++++++++++++
 routes/auth-app.js       | 16 ++++++++++++----
 2 files changed, 51 insertions(+), 4 deletions(-)

diff --git a/middleware/rate-limit.js b/middleware/rate-limit.js
new file mode 100644
index 0000000..d64b023
--- /dev/null
+++ b/middleware/rate-limit.js
@@ -0,0 +1,39 @@
+// Tiny in-memory fixed-window rate limiter — no dependencies.
+// Purpose: brute-force brake on the auth endpoints now that AbramsOS is on a
+// public host. Keyed by client IP (accurate because server.js sets
+// `trust proxy: 1`, so req.ip reflects nginx's X-Forwarded-For, not the
+// tailnet peer). Single fork process → a Map is sufficient; state resets on
+// restart, which is fine for a brute-force brake.
+
+const BUCKETS = new Map(); // key -> { count, resetAt }
+
+function rateLimit({ windowMs = 15 * 60 * 1000, max = 12, key = (req) => req.ip } = {}) {
+  return function rateLimitMw(req, res, next) {
+    const now = Date.now();
+    const k = key(req) || 'unknown';
+    let b = BUCKETS.get(k);
+    if (!b || b.resetAt <= now) {
+      b = { count: 0, resetAt: now + windowMs };
+      BUCKETS.set(k, b);
+    }
+    b.count++;
+    if (b.count > max) {
+      const retry = Math.max(1, Math.ceil((b.resetAt - now) / 1000));
+      res.set('Retry-After', String(retry));
+      return res.status(429).render('error', {
+        error: 'Too many attempts. Please wait a few minutes and try again.',
+      });
+    }
+    next();
+  };
+}
+
+// Bound memory: drop expired buckets periodically. unref so it never holds the
+// process open on shutdown.
+const sweep = setInterval(() => {
+  const now = Date.now();
+  for (const [k, b] of BUCKETS) if (b.resetAt <= now) BUCKETS.delete(k);
+}, 10 * 60 * 1000);
+if (sweep.unref) sweep.unref();
+
+module.exports = { rateLimit };
diff --git a/routes/auth-app.js b/routes/auth-app.js
index cbfa943..eabfa86 100644
--- a/routes/auth-app.js
+++ b/routes/auth-app.js
@@ -1,13 +1,21 @@
 const express = require('express');
 const auth = require('../lib/auth');
 const db = require('../lib/db');
+const { rateLimit } = require('../middleware/rate-limit');
 
 const router = express.Router();
 const DEV_USER_ID = 'user_steve';
 
+// Brute-force brake on auth POSTs (public host). 12 attempts / 15 min / IP —
+// ample for the 2-request password+TOTP signin flow, tight against guessing.
+const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 12 });
+
 function clientMeta(req) {
+  // req.ip is trust-proxy-resolved to the real client (nginx appends the true
+  // peer to the RIGHT of XFF; trust proxy:1 picks it). Do NOT read the raw
+  // leftmost XFF entry — that's attacker-controllable and would poison the log.
   return {
-    ip: (req.headers['x-forwarded-for']?.split(',')[0].trim() || req.ip || null),
+    ip: req.ip || req.headers['x-real-ip'] || null,
     userAgent: req.headers['user-agent'] || null,
   };
 }
@@ -19,7 +27,7 @@ router.get('/signup', async (_req, res) => {
   res.render('signup', { error: null });
 });
 
-router.post('/signup', async (req, res) => {
+router.post('/signup', authLimiter, async (req, res) => {
   const n = await auth.userCount();
   if (n > 0) return res.redirect('/signin');
 
@@ -95,7 +103,7 @@ router.get('/signin', async (req, res) => {
   res.render('signin', { stage: 'password', error: null, next: req.query.next || '/' });
 });
 
-router.post('/signin', async (req, res) => {
+router.post('/signin', authLimiter, async (req, res) => {
   const meta = clientMeta(req);
   const next = req.body?.next || '/';
 
@@ -154,7 +162,7 @@ router.get('/step-up', (req, res) => {
   res.render('step-up', { error: null, next: req.query.next || '/' });
 });
 
-router.post('/step-up', async (req, res) => {
+router.post('/step-up', authLimiter, async (req, res) => {
   if (!req.userId) return res.redirect('/signin');
   const totp = await auth.getTotpSecret(req.userId);
   const next = req.body?.next || '/';

← 8c7f239 Stop unauth info-disclosure on public surface (/healthz + er  ·  back to AbramsOS  ·  chore: v0.4.0 (session close — claims tracker + auth hardeni 4f346d9 →