feat(mam-api): login rate limit + X-Requested-With CSRF header check
This commit is contained in:
parent
56b661ef65
commit
d209a192c3
9 changed files with 116 additions and 21 deletions
18
services/mam-api/src/auth/rate-limit.js
Normal file
18
services/mam-api/src/auth/rate-limit.js
Normal file
|
|
@ -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); },
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 <form> 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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
24
services/mam-api/test/auth/rate-limit.test.js
Normal file
24
services/mam-api/test/auth/rate-limit.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue