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 <noreply@anthropic.com>
This commit is contained in:
Matiss Janis Aboltins
2026-04-08 23:21:04 +01:00
committed by GitHub
parent 4efa8bba04
commit 20ba076a51
3 changed files with 65 additions and 4 deletions

View File

@@ -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;

View File

@@ -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,

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [actualbudget]
---
Add rate limiting to authentication endpoints to prevent brute-force attacks.