From 20ba076a51cbdbfe8dba6c8d133ac6e9fe00695d Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Wed, 8 Apr 2026 23:21:04 +0100 Subject: [PATCH] Add rate limiting to authentication endpoints (#7432) * [AI] Add rate limiting to authentication endpoints Add strict rate limiting (5 attempts per 15 minutes) to /account/login, /account/bootstrap, and /account/change-password endpoints to prevent brute-force password attacks. Uses express-rate-limit as route-level middleware on auth-sensitive routes only. https://claude.ai/code/session_017SHnNCn93RzxpvEEPJAZUZ * [AI] Add release notes and remove rate limit from /change-password Add upcoming release notes file for the auth rate limiting feature. Remove rate limiting from /change-password since it already requires a valid admin session token. https://claude.ai/code/session_017SHnNCn93RzxpvEEPJAZUZ --------- Co-authored-by: Claude --- packages/sync-server/src/app-account.js | 16 +++++-- packages/sync-server/src/app-account.test.js | 47 +++++++++++++++++++- upcoming-release-notes/7432.md | 6 +++ 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 upcoming-release-notes/7432.md diff --git a/packages/sync-server/src/app-account.js b/packages/sync-server/src/app-account.js index 0ab17614ca..ae065559ab 100644 --- a/packages/sync-server/src/app-account.js +++ b/packages/sync-server/src/app-account.js @@ -1,4 +1,5 @@ import express from 'express'; +import rateLimit from 'express-rate-limit'; import { bootstrap, @@ -21,7 +22,16 @@ app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(errorMiddleware); app.use(requestLoggerMiddleware); -export { app as handlers }; + +const authRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // 5 attempts per window + legacyHeaders: false, + standardHeaders: true, + message: { status: 'error', reason: 'too-many-requests' }, +}); + +export { app as handlers, authRateLimiter }; // Non-authenticated endpoints: // @@ -45,7 +55,7 @@ app.get('/needs-bootstrap', (req, res) => { }); }); -app.post('/bootstrap', async (req, res) => { +app.post('/bootstrap', authRateLimiter, async (req, res) => { const boot = await bootstrap(req.body); if (boot?.error) { @@ -60,7 +70,7 @@ app.get('/login-methods', (req, res) => { res.send({ status: 'ok', methods }); }); -app.post('/login', async (req, res) => { +app.post('/login', authRateLimiter, async (req, res) => { const loginMethod = getLoginMethod(req); console.log('Logging in via ' + loginMethod); let tokenRes = null; diff --git a/packages/sync-server/src/app-account.test.js b/packages/sync-server/src/app-account.test.js index 5608d49bf9..a1fe96439b 100644 --- a/packages/sync-server/src/app-account.test.js +++ b/packages/sync-server/src/app-account.test.js @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { getAccountDb, getLoginMethod, getServerPrefs } from './account-db'; import { bootstrapPassword } from './accounts/password'; -import { handlers as app } from './app-account'; +import { handlers as app, authRateLimiter } from './app-account'; const ADMIN_ROLE = 'ADMIN'; const BASIC_ROLE = 'BASIC'; @@ -45,6 +45,51 @@ const clearAuth = () => { getAccountDb().mutate('DELETE FROM auth'); }; +beforeEach(() => { + authRateLimiter.resetKey('127.0.0.1'); +}); + +describe('auth rate limiting', () => { + it('should return 429 after exceeding the rate limit on /login', async () => { + for (let i = 0; i < 5; i++) { + await request(app).post('/login').send({ password: 'wrong' }); + } + + const res = await request(app).post('/login').send({ password: 'wrong' }); + + expect(res.statusCode).toEqual(429); + expect(res.body).toEqual({ + status: 'error', + reason: 'too-many-requests', + }); + }); + + it('should apply the same rate limit across /login and /bootstrap', async () => { + for (let i = 0; i < 5; i++) { + await request(app).post('/login').send({ password: 'wrong' }); + } + + const res = await request(app) + .post('/bootstrap') + .send({ password: 'test' }); + + expect(res.statusCode).toEqual(429); + expect(res.body).toEqual({ + status: 'error', + reason: 'too-many-requests', + }); + }); + + it('should not rate limit non-auth endpoints', async () => { + for (let i = 0; i < 6; i++) { + await request(app).post('/login').send({ password: 'wrong' }); + } + + const res = await request(app).get('/needs-bootstrap'); + expect(res.statusCode).toEqual(200); + }); +}); + describe('/change-password', () => { let adminUserId, basicUserId, diff --git a/upcoming-release-notes/7432.md b/upcoming-release-notes/7432.md new file mode 100644 index 0000000000..d13d666d72 --- /dev/null +++ b/upcoming-release-notes/7432.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [actualbudget] +--- + +Add rate limiting to authentication endpoints to prevent brute-force attacks.