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:
parent
d209a192c3
commit
96effaaa3c
6 changed files with 3008 additions and 3 deletions
2992
services/mam-api/package-lock.json
generated
Normal file
2992
services/mam-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,7 @@
|
||||||
const failures = new Map(); // ip -> count
|
const failures = new Map(); // ip -> count
|
||||||
|
|
||||||
const STEPS = [1000, 2000, 4000, 8000, 16000, 30000];
|
const STEPS = [1000, 2000, 4000, 8000, 16000, 30000];
|
||||||
|
const MAX_ENTRIES = 10000; // bounded to prevent unbounded growth under spray attacks
|
||||||
|
|
||||||
export const ipBackoff = {
|
export const ipBackoff = {
|
||||||
delayMs(ip) {
|
delayMs(ip) {
|
||||||
|
|
@ -11,6 +12,11 @@ export const ipBackoff = {
|
||||||
return STEPS[Math.min(n - 1, STEPS.length - 1)];
|
return STEPS[Math.min(n - 1, STEPS.length - 1)];
|
||||||
},
|
},
|
||||||
recordFailure(ip) {
|
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);
|
failures.set(ip, (failures.get(ip) || 0) + 1);
|
||||||
},
|
},
|
||||||
recordSuccess(ip) { failures.delete(ip); },
|
recordSuccess(ip) { failures.delete(ip); },
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,9 @@ const server = app.listen(PORT, () => {
|
||||||
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
|
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(`MAM API listening on port ${PORT}`);
|
||||||
console.log(`Authentication: ${authMode}`);
|
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
|
// 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.
|
// the loop's self-calls to /recorders/:id/start|stop reach a ready socket.
|
||||||
startSchedulerLoop();
|
startSchedulerLoop();
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import express from 'express';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import authRouter from '../../src/routes/auth.js';
|
import authRouter from '../../src/routes/auth.js';
|
||||||
import { hashPassword } from '../../src/auth/passwords.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) {
|
async function appWithAuth(pool) {
|
||||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
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 },
|
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
||||||
rolling: false, resave: false, saveUninitialized: false,
|
rolling: false, resave: false, saveUninitialized: false,
|
||||||
}));
|
}));
|
||||||
|
app.use('/api/v1', requireUiHeader);
|
||||||
app.use('/api/v1/auth', authRouter);
|
app.use('/api/v1/auth', authRouter);
|
||||||
return new Promise(r => {
|
return new Promise(r => {
|
||||||
const srv = app.listen(0, '127.0.0.1', () => {
|
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 },
|
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
||||||
rolling: false, resave: false, saveUninitialized: false,
|
rolling: false, resave: false, saveUninitialized: false,
|
||||||
}));
|
}));
|
||||||
|
app.use('/api/v1', requireUiHeader);
|
||||||
app.use('/api/v1/auth', authRouter);
|
app.use('/api/v1/auth', authRouter);
|
||||||
app.get('/api/v1/protected/me', requireAuth, (req, res) => res.json({ user: req.user }));
|
app.get('/api/v1/protected/me', requireAuth, (req, res) => res.json({ user: req.user }));
|
||||||
return new Promise(r => {
|
return new Promise(r => {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import session from 'express-session';
|
||||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||||||
import tokensRouter from '../../src/routes/tokens.js';
|
import tokensRouter from '../../src/routes/tokens.js';
|
||||||
import authRouter from '../../src/routes/auth.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';
|
import { hashPassword } from '../../src/auth/passwords.js';
|
||||||
|
|
||||||
async function app(pool) {
|
async function app(pool) {
|
||||||
|
|
@ -20,6 +20,7 @@ async function app(pool) {
|
||||||
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
||||||
rolling: false, resave: false, saveUninitialized: false,
|
rolling: false, resave: false, saveUninitialized: false,
|
||||||
}));
|
}));
|
||||||
|
a.use('/api/v1', requireUiHeader);
|
||||||
a.use('/api/v1/auth', authRouter);
|
a.use('/api/v1/auth', authRouter);
|
||||||
a.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
a.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
||||||
a.get('/api/v1/protected/ping', requireAuth, (req, res) => res.json({ user: req.user.username }));
|
a.get('/api/v1/protected/ping', requireAuth, (req, res) => res.json({ user: req.user.username }));
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import session from 'express-session';
|
||||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||||||
import usersRouter from '../../src/routes/users.js';
|
import usersRouter from '../../src/routes/users.js';
|
||||||
import authRouter from '../../src/routes/auth.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';
|
import { hashPassword } from '../../src/auth/passwords.js';
|
||||||
|
|
||||||
async function app(pool) {
|
async function app(pool) {
|
||||||
|
|
@ -20,6 +20,7 @@ async function app(pool) {
|
||||||
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
||||||
rolling: false, resave: false, saveUninitialized: false,
|
rolling: false, resave: false, saveUninitialized: false,
|
||||||
}));
|
}));
|
||||||
|
a.use('/api/v1', requireUiHeader);
|
||||||
a.use('/api/v1/auth', authRouter);
|
a.use('/api/v1/auth', authRouter);
|
||||||
a.use('/api/v1/auth/users', requireAuth, usersRouter);
|
a.use('/api/v1/auth/users', requireAuth, usersRouter);
|
||||||
return new Promise(r => {
|
return new Promise(r => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue