fix(mam-api): TRUST_PROXY boot warning + CSRF integration tests + bounded rate-limit map

Fixes three issues in the authentication system:

C1: Add boot-time warning when AUTH_ENABLED=true but TRUST_PROXY!=true.
    Without TRUST_PROXY=true behind nginx, req.ip becomes the proxy IP for all
    clients, collapsing per-IP rate limiting into a shared pool. Operators must
    explicitly set TRUST_PROXY=true to make per-IP rate limiting effective.

C2: Mount requireUiHeader middleware in test helpers (auth.test.js,
    users.test.js, tokens.test.js). The CSRF header validation was not being
    exercised in the test suite. Tests now send X-Requested-With: dragonflight-ui
    headers that are actually validated by the middleware.

I1: Implement bounded rate-limit Map with MAX_ENTRIES=10000 and LRU eviction.
    Unbounded Maps are vulnerable to spray attacks: attackers can force memory
    exhaustion by requesting with distinct IPs. Now we evict the oldest entry
    (by insertion order) when the map reaches capacity.
This commit is contained in:
Zac Gaetano 2026-05-27 15:03:35 -04:00
parent d209a192c3
commit 96effaaa3c
6 changed files with 3008 additions and 3 deletions

2992
services/mam-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@
const failures = new Map(); // ip -> count
const STEPS = [1000, 2000, 4000, 8000, 16000, 30000];
const MAX_ENTRIES = 10000; // bounded to prevent unbounded growth under spray attacks
export const ipBackoff = {
delayMs(ip) {
@ -11,6 +12,11 @@ export const ipBackoff = {
return STEPS[Math.min(n - 1, STEPS.length - 1)];
},
recordFailure(ip) {
// Evict the oldest entry if we're at the cap. Map preserves insertion order,
// so .keys().next().value is the oldest.
if (failures.size >= MAX_ENTRIES && !failures.has(ip)) {
failures.delete(failures.keys().next().value);
}
failures.set(ip, (failures.get(ip) || 0) + 1);
},
recordSuccess(ip) { failures.delete(ip); },

View file

@ -276,6 +276,9 @@ const server = app.listen(PORT, () => {
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
console.log(`MAM API listening on port ${PORT}`);
console.log(`Authentication: ${authMode}`);
if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') {
console.warn('[auth] WARNING: AUTH_ENABLED=true but TRUST_PROXY=false — req.ip will be the proxy IP, login rate-limit will throttle all clients together. Set TRUST_PROXY=true when behind nginx/HTTPS.');
}
// Boot the recorder scheduler tick loop after the HTTP server is live so
// the loop's self-calls to /recorders/:id/start|stop reach a ready socket.
startSchedulerLoop();

View file

@ -5,7 +5,7 @@ import express from 'express';
import session from 'express-session';
import authRouter from '../../src/routes/auth.js';
import { hashPassword } from '../../src/auth/passwords.js';
import { requireAuth } from '../../src/middleware/auth.js';
import { requireAuth, requireUiHeader } from '../../src/middleware/auth.js';
async function appWithAuth(pool) {
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
@ -52,6 +52,7 @@ async function appWithSession(pool) {
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
rolling: false, resave: false, saveUninitialized: false,
}));
app.use('/api/v1', requireUiHeader);
app.use('/api/v1/auth', authRouter);
return new Promise(r => {
const srv = app.listen(0, '127.0.0.1', () => {
@ -119,6 +120,7 @@ async function appWithSessionAndMe(pool) {
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
rolling: false, resave: false, saveUninitialized: false,
}));
app.use('/api/v1', requireUiHeader);
app.use('/api/v1/auth', authRouter);
app.get('/api/v1/protected/me', requireAuth, (req, res) => res.json({ user: req.user }));
return new Promise(r => {

View file

@ -5,7 +5,7 @@ import session from 'express-session';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import tokensRouter from '../../src/routes/tokens.js';
import authRouter from '../../src/routes/auth.js';
import { requireAuth } from '../../src/middleware/auth.js';
import { requireAuth, requireUiHeader } from '../../src/middleware/auth.js';
import { hashPassword } from '../../src/auth/passwords.js';
async function app(pool) {
@ -20,6 +20,7 @@ async function app(pool) {
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
rolling: false, resave: false, saveUninitialized: false,
}));
a.use('/api/v1', requireUiHeader);
a.use('/api/v1/auth', authRouter);
a.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
a.get('/api/v1/protected/ping', requireAuth, (req, res) => res.json({ user: req.user.username }));

View file

@ -5,7 +5,7 @@ import session from 'express-session';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import usersRouter from '../../src/routes/users.js';
import authRouter from '../../src/routes/auth.js';
import { requireAuth } from '../../src/middleware/auth.js';
import { requireAuth, requireUiHeader } from '../../src/middleware/auth.js';
import { hashPassword } from '../../src/auth/passwords.js';
async function app(pool) {
@ -20,6 +20,7 @@ async function app(pool) {
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
rolling: false, resave: false, saveUninitialized: false,
}));
a.use('/api/v1', requireUiHeader);
a.use('/api/v1/auth', authRouter);
a.use('/api/v1/auth/users', requireAuth, usersRouter);
return new Promise(r => {