← 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
A middleware/rate-limit.jsM routes/auth-app.js
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 →