From d209a192c3c27f0e0aa5b339bef11a941ac36768 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:58:02 -0400 Subject: [PATCH] feat(mam-api): login rate limit + X-Requested-With CSRF header check --- services/mam-api/src/auth/rate-limit.js | 18 +++++++++++++ services/mam-api/src/index.js | 4 ++- services/mam-api/src/middleware/auth.js | 15 +++++++++++ services/mam-api/src/routes/auth.js | 16 +++++++++--- services/mam-api/test/auth/rate-limit.test.js | 24 +++++++++++++++++ services/mam-api/test/middleware/auth.test.js | 26 +++++++++++++++++++ services/mam-api/test/routes/auth.test.js | 22 ++++++++-------- services/mam-api/test/routes/tokens.test.js | 6 ++--- services/mam-api/test/routes/users.test.js | 6 ++--- 9 files changed, 116 insertions(+), 21 deletions(-) create mode 100644 services/mam-api/src/auth/rate-limit.js create mode 100644 services/mam-api/test/auth/rate-limit.test.js diff --git a/services/mam-api/src/auth/rate-limit.js b/services/mam-api/src/auth/rate-limit.js new file mode 100644 index 0000000..5b81f2e --- /dev/null +++ b/services/mam-api/src/auth/rate-limit.js @@ -0,0 +1,18 @@ +// Per-IP exponential backoff for /auth/login. Single-instance — fine for +// Dragonflight's deployment shape (one mam-api per node). Documented limitation. +const failures = new Map(); // ip -> count + +const STEPS = [1000, 2000, 4000, 8000, 16000, 30000]; + +export const ipBackoff = { + delayMs(ip) { + const n = failures.get(ip) || 0; + if (n === 0) return 0; + return STEPS[Math.min(n - 1, STEPS.length - 1)]; + }, + recordFailure(ip) { + failures.set(ip, (failures.get(ip) || 0) + 1); + }, + recordSuccess(ip) { failures.delete(ip); }, + reset(ip) { failures.delete(ip); }, +}; diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 3ee3ea7..6e2ac99 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -8,7 +8,7 @@ import os from 'node:os'; import { exec } from 'node:child_process'; import pool from './db/pool.js'; import { errorHandler } from './middleware/errors.js'; -import { requireAuth } from './middleware/auth.js'; +import { requireAuth, requireUiHeader } from './middleware/auth.js'; import { loadS3ConfigFromDb } from './s3/client.js'; import authRouter from './routes/auth.js'; @@ -100,6 +100,8 @@ const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-require // to require auth. If node-agent grows another endpoint, add it here. // TODO: long-term, issue node-agent a real bound api_token and drop this carve-out. const SERVICE_PATHS = new Set(['/cluster/heartbeat']); +app.use('/api/v1', requireUiHeader); +// then the existing gate: app.use('/api/v1', (req, res, next) => { if (UNAUTH_PATHS.has(req.path)) return next(); if (SERVICE_PATHS.has(req.path)) return next(); diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js index d0bdec2..1d8cb3c 100644 --- a/services/mam-api/src/middleware/auth.js +++ b/services/mam-api/src/middleware/auth.js @@ -62,3 +62,18 @@ export async function requireAuth(req, res, next) { // 3. Nothing matched return res.status(401).json({ error: 'unauthorized' }); } + +// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site +// cookie sends, but a custom header that no
can produce hardens +// against the edge cases. Applied to mutating verbs only. +const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); +const REQUIRED_HEADER = 'dragonflight-ui'; + +export function requireUiHeader(req, res, next) { + if (!MUTATING.has(req.method)) return next(); + // Bearer-authed requests (Premiere panel, scripts) are exempt — they're not + // browsers and can't be drive-by'd from another origin. + if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next(); + if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next(); + return res.status(403).json({ error: 'missing X-Requested-With header' }); +} diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index 529e5ce..4e52a02 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -2,6 +2,7 @@ import express from 'express'; import pool from '../db/pool.js'; import { DEV_USER_ID, requireAuth } from '../middleware/auth.js'; import { hashPassword, comparePassword } from '../auth/passwords.js'; +import { ipBackoff } from '../auth/rate-limit.js'; const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K'; @@ -64,8 +65,15 @@ router.post('/setup', async (req, res, next) => { // POST /api/v1/auth/login — authenticate an existing user by username + password. router.post('/login', async (req, res, next) => { try { + const ip = req.ip || req.socket?.remoteAddress || 'unknown'; + const delay = ipBackoff.delayMs(ip); + if (delay > 0) await new Promise(r => setTimeout(r, delay)); + const { username, password } = req.body || {}; - if (!username || !password) return res.status(401).json({ error: 'invalid credentials' }); + if (!username || !password) { + ipBackoff.recordFailure(ip); + return res.status(401).json({ error: 'invalid credentials' }); + } const { rows } = await pool.query( `SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`, @@ -76,10 +84,12 @@ router.post('/login', async (req, res, next) => { // Used to keep the user-not-found response time uniform with the wrong-password // path (~180ms at cost 12) so user enumeration via timing isn't possible. await comparePassword(password, DUMMY_PASSWORD_HASH); + ipBackoff.recordFailure(ip); return res.status(401).json({ error: 'invalid credentials' }); } const user = rows[0]; if (!(await comparePassword(password, user.password_hash))) { + ipBackoff.recordFailure(ip); return res.status(401).json({ error: 'invalid credentials' }); } @@ -94,8 +104,8 @@ router.post('/login', async (req, res, next) => { pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]) .catch(err => console.error('[auth] last_login_at update failed:', err.message)); - res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); - } catch (err) { next(err); } + ipBackoff.recordSuccess(ip); + res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); } }); // POST /api/v1/auth/logout — destroys the session and clears the cookie. diff --git a/services/mam-api/test/auth/rate-limit.test.js b/services/mam-api/test/auth/rate-limit.test.js new file mode 100644 index 0000000..69adedd --- /dev/null +++ b/services/mam-api/test/auth/rate-limit.test.js @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ipBackoff } from '../../src/auth/rate-limit.js'; + +test('first failure → small delay; repeated failures → exponential up to 30s', () => { + ipBackoff.reset('1.2.3.4'); + assert.equal(ipBackoff.delayMs('1.2.3.4'), 0); + ipBackoff.recordFailure('1.2.3.4'); + assert.equal(ipBackoff.delayMs('1.2.3.4'), 1000); + ipBackoff.recordFailure('1.2.3.4'); + assert.equal(ipBackoff.delayMs('1.2.3.4'), 2000); + ipBackoff.recordFailure('1.2.3.4'); + assert.equal(ipBackoff.delayMs('1.2.3.4'), 4000); + for (let i = 0; i < 10; i++) ipBackoff.recordFailure('1.2.3.4'); + assert.equal(ipBackoff.delayMs('1.2.3.4'), 30000); +}); + +test('successful login resets the counter', () => { + ipBackoff.reset('5.6.7.8'); + ipBackoff.recordFailure('5.6.7.8'); + ipBackoff.recordFailure('5.6.7.8'); + ipBackoff.recordSuccess('5.6.7.8'); + assert.equal(ipBackoff.delayMs('5.6.7.8'), 0); +}); diff --git a/services/mam-api/test/middleware/auth.test.js b/services/mam-api/test/middleware/auth.test.js index a23a1fb..79ce269 100644 --- a/services/mam-api/test/middleware/auth.test.js +++ b/services/mam-api/test/middleware/auth.test.js @@ -147,3 +147,29 @@ test('bearer token whose user was deleted → 401', { skip: !isTestDbConfigured( assert.equal(res.statusCode, 401); } finally { await pool.end(); } }); + +import { requireUiHeader } from '../../src/middleware/auth.js'; + +test('requireUiHeader: GET → next (any header)', () => { + let called = false; + requireUiHeader({ method: 'GET', headers: {} }, { status: () => ({ json: () => {} }) }, () => { called = true; }); + assert.equal(called, true); +}); + +test('requireUiHeader: POST without header → 403', () => { + const res = { status(n) { this.statusCode = n; return this; }, json(o) { this.body = o; } }; + requireUiHeader({ method: 'POST', headers: {} }, res, () => {}); + assert.equal(res.statusCode, 403); +}); + +test('requireUiHeader: POST with correct header → next', () => { + let called = false; + requireUiHeader({ method: 'POST', headers: { 'x-requested-with': 'dragonflight-ui' } }, {}, () => { called = true; }); + assert.equal(called, true); +}); + +test('requireUiHeader: POST with bearer auth → next (exempt)', () => { + let called = false; + requireUiHeader({ method: 'POST', headers: { authorization: 'Bearer dfl_xxx' } }, {}, () => { called = true; }); + assert.equal(called, true); +}); diff --git a/services/mam-api/test/routes/auth.test.js b/services/mam-api/test/routes/auth.test.js index 3d9824c..41af3f6 100644 --- a/services/mam-api/test/routes/auth.test.js +++ b/services/mam-api/test/routes/auth.test.js @@ -66,7 +66,7 @@ test('POST /auth/setup creates the first admin and returns a session cookie', { try { const res = await fetch(baseUrl + '/api/v1/auth/setup', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }), }); assert.equal(res.status, 200); @@ -85,7 +85,7 @@ test('POST /auth/setup is 409 once a real user exists', { skip: !isTestDbConfigu try { const res = await fetch(baseUrl + '/api/v1/auth/setup', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }), }); assert.equal(res.status, 409); @@ -98,7 +98,7 @@ test('POST /auth/setup rejects passwords shorter than 12 chars', { skip: !isTest const { baseUrl, close } = await appWithSession(pool); try { const res = await fetch(baseUrl + '/api/v1/auth/setup', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'admin', password: 'short' }), }); assert.equal(res.status, 400); @@ -137,7 +137,7 @@ test('POST /auth/login with valid creds → 200 + cookie, and the cookie unlocks // 1. Login. const loginRes = await fetch(baseUrl + '/api/v1/auth/login', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }), }); assert.equal(loginRes.status, 200); @@ -162,11 +162,11 @@ test('POST /auth/login with wrong password → 401 + generic message (no enumera const { baseUrl, close } = await appWithSessionAndMe(pool); try { const r1 = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'alice', password: 'wrong' }), }); const r2 = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'nobody', password: 'whatever-long-enough' }), }); assert.equal(r1.status, 401); @@ -184,7 +184,7 @@ test('POST /auth/logout destroys the session row and the cookie no longer unlock const { baseUrl, close } = await appWithSessionAndMe(pool); try { const loginRes = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }), }); const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0]; @@ -206,7 +206,7 @@ test('GET /auth/me returns the authed user', { skip: !isTestDbConfigured() && 'T const { baseUrl, close } = await appWithSessionAndMe(pool); try { const loginRes = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }), }); const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0]; @@ -225,18 +225,18 @@ test('POST /auth/password rotates the password when current is correct', { skip: const { baseUrl, close } = await appWithSessionAndMe(pool); try { const loginRes = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }), }); const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0]; const change = await fetch(baseUrl + '/api/v1/auth/password', { - method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie }, body: JSON.stringify({ current_password: 'correct-horse-battery', new_password: 'brand-new-passphrase' }), }); assert.equal(change.status, 204); // Wrong current → 400 const wrong = await fetch(baseUrl + '/api/v1/auth/password', { - method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie }, body: JSON.stringify({ current_password: 'no', new_password: 'another-good-passphrase' }), }); assert.equal(wrong.status, 400); diff --git a/services/mam-api/test/routes/tokens.test.js b/services/mam-api/test/routes/tokens.test.js index 50c7baa..a49b25c 100644 --- a/services/mam-api/test/routes/tokens.test.js +++ b/services/mam-api/test/routes/tokens.test.js @@ -32,7 +32,7 @@ async function app(pool) { async function loginCookie(baseUrl, u, p) { const r = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: u, password: p }), }); return (r.headers.get('set-cookie') || '').split(';')[0]; @@ -47,7 +47,7 @@ test('tokens: create returns the raw token exactly once; bearer of that token wo // Create const create = await fetch(baseUrl + '/api/v1/auth/tokens', { - method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie }, body: JSON.stringify({ name: 'Premiere panel' }), }); assert.equal(create.status, 201); @@ -91,7 +91,7 @@ test('tokens: cannot revoke another users token', { skip: !isTestDbConfigured() const aliceCookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery'); const bobCookie = await loginCookie(baseUrl, 'bob', 'bob-passphrase-12'); const aliceTok = await (await fetch(baseUrl + '/api/v1/auth/tokens', { - method: 'POST', headers: { 'Content-Type': 'application/json', cookie: aliceCookie }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie: aliceCookie }, body: JSON.stringify({ name: 'alice token' }), })).json(); const r = await fetch(baseUrl + '/api/v1/auth/tokens/' + aliceTok.id, { diff --git a/services/mam-api/test/routes/users.test.js b/services/mam-api/test/routes/users.test.js index 0c258d6..dd9294d 100644 --- a/services/mam-api/test/routes/users.test.js +++ b/services/mam-api/test/routes/users.test.js @@ -31,7 +31,7 @@ async function app(pool) { async function login(baseUrl, username, password) { const r = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username, password }), }); assert.equal(r.status, 200, 'login failed: ' + JSON.stringify(await r.json())); @@ -53,7 +53,7 @@ test('users: list + create + delete + admin reset password', { skip: !isTestDbCo // Create const created = await fetch(baseUrl + '/api/v1/auth/users', { - method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie }, body: JSON.stringify({ username: 'bob', password: 'bob-passphrase!', display_name: 'Bob' }), }); assert.equal(created.status, 201); @@ -62,7 +62,7 @@ test('users: list + create + delete + admin reset password', { skip: !isTestDbCo // Admin reset password const reset = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id + '/password', { - method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie }, body: JSON.stringify({ new_password: 'a-fresh-passphrase' }), }); assert.equal(reset.status, 204);