mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🚧 fix: Add Per-User Throttle to 2FA Continuation Attempts (#13583)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Publish `@librechat/client` to NPM / pack (push) Waiting to run
Publish `@librechat/client` to NPM / publish-npm (push) Blocked by required conditions
Publish `librechat-data-provider` to NPM / pack (push) Waiting to run
Publish `librechat-data-provider` to NPM / publish-npm (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `@librechat/data-schemas` to NPM / pack (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Publish `@librechat/client` to NPM / pack (push) Waiting to run
Publish `@librechat/client` to NPM / publish-npm (push) Blocked by required conditions
Publish `librechat-data-provider` to NPM / pack (push) Waiting to run
Publish `librechat-data-provider` to NPM / publish-npm (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `@librechat/data-schemas` to NPM / pack (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
* fix: refine auth continuation throttling * chore: import order
This commit is contained in:
parent
3f4001fca1
commit
209e8d1eb6
2 changed files with 154 additions and 5 deletions
|
|
@ -1,5 +1,6 @@
|
|||
const rateLimit = require('express-rate-limit');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { createHash } = require('crypto');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { ViolationTypes } = require('librechat-data-provider');
|
||||
const { limiterCache, removePorts } = require('@librechat/api');
|
||||
const { logViolation } = require('~/cache');
|
||||
|
|
@ -18,6 +19,23 @@ const score = TWO_FACTOR_TEMP_VIOLATION_SCORE ?? LOGIN_VIOLATION_SCORE;
|
|||
const windowInMinutes = windowMs / 60000;
|
||||
const message = `Too many verification attempts, please try again after ${windowInMinutes} minutes.`;
|
||||
|
||||
const hashLimiterKey = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
const getUserLimiterKey = (req) => {
|
||||
const userId = req.user?.id ?? req.user?._id;
|
||||
if (userId) {
|
||||
return `user:${userId.toString()}`;
|
||||
}
|
||||
|
||||
const tempToken = req.body?.tempToken;
|
||||
if (typeof tempToken === 'string' && tempToken) {
|
||||
return `temp:${hashLimiterKey(tempToken)}`;
|
||||
}
|
||||
|
||||
const ip = removePorts(req);
|
||||
return ip ? `ip:${ip}` : 'ip:unknown';
|
||||
};
|
||||
|
||||
const getTempTokenUserId = (tempToken) => {
|
||||
if (!tempToken) {
|
||||
return null;
|
||||
|
|
@ -31,11 +49,12 @@ const getTempTokenUserId = (tempToken) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handler = async (req, res) => {
|
||||
const createHandler = (limiter) => async (req, res) => {
|
||||
const type = ViolationTypes.LOGINS;
|
||||
const errorMessage = {
|
||||
type,
|
||||
max,
|
||||
limiter,
|
||||
windowInMinutes,
|
||||
};
|
||||
|
||||
|
|
@ -50,14 +69,33 @@ const handler = async (req, res) => {
|
|||
return res.status(429).json({ message });
|
||||
};
|
||||
|
||||
const limiterOptions = {
|
||||
const ipLimiterOptions = {
|
||||
windowMs,
|
||||
max,
|
||||
handler,
|
||||
handler: createHandler('ip'),
|
||||
keyGenerator: removePorts,
|
||||
store: limiterCache('two_factor_temp_limiter'),
|
||||
};
|
||||
|
||||
const twoFactorTempLimiter = rateLimit(limiterOptions);
|
||||
const userLimiterOptions = {
|
||||
windowMs,
|
||||
max,
|
||||
handler: createHandler('user'),
|
||||
keyGenerator: getUserLimiterKey,
|
||||
store: limiterCache('two_factor_temp_user_limiter'),
|
||||
};
|
||||
|
||||
const twoFactorTempIpLimiter = rateLimit(ipLimiterOptions);
|
||||
const twoFactorTempUserLimiter = rateLimit(userLimiterOptions);
|
||||
|
||||
const twoFactorTempLimiter = (req, res, next) => {
|
||||
twoFactorTempIpLimiter(req, res, (err) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
return twoFactorTempUserLimiter(req, res, next);
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = twoFactorTempLimiter;
|
||||
|
|
|
|||
111
api/server/middleware/limiters/twoFactorTempLimiter.test.js
Normal file
111
api/server/middleware/limiters/twoFactorTempLimiter.test.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
const jwt = require('jsonwebtoken');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const originalEnv = process.env;
|
||||
const jwtSecret = 'test-two-factor-secret';
|
||||
|
||||
const createToken = (userId) =>
|
||||
jwt.sign({ userId, twoFAPending: true }, jwtSecret, { expiresIn: '5m' });
|
||||
|
||||
const createApp = () => {
|
||||
jest.resetModules();
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
JWT_SECRET: jwtSecret,
|
||||
LOGIN_MAX: '2',
|
||||
LOGIN_WINDOW: '5',
|
||||
TWO_FACTOR_TEMP_MAX: '2',
|
||||
TWO_FACTOR_TEMP_WINDOW: '5',
|
||||
};
|
||||
|
||||
jest.doMock('@librechat/api', () => ({
|
||||
limiterCache: jest.fn(() => undefined),
|
||||
removePorts: (req) => req?.['ip'],
|
||||
}));
|
||||
jest.doMock('~/cache', () => ({
|
||||
logViolation: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const setTwoFactorTempUser = require('../setTwoFactorTempUser');
|
||||
const twoFactorTempLimiter = require('./twoFactorTempLimiter');
|
||||
const { logViolation } = require('~/cache');
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
app.use(express.json());
|
||||
app.post('/verify', setTwoFactorTempUser, twoFactorTempLimiter, (req, res) =>
|
||||
res.status(204).end(),
|
||||
);
|
||||
|
||||
return { app, logViolation };
|
||||
};
|
||||
|
||||
describe('twoFactorTempLimiter', () => {
|
||||
afterEach(() => {
|
||||
jest.dontMock('@librechat/api');
|
||||
jest.dontMock('~/cache');
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('limits a valid temp-token user across rotating source IPs', async () => {
|
||||
const { app, logViolation } = createApp();
|
||||
const tempToken = createToken('user-1');
|
||||
|
||||
await request(app)
|
||||
.post('/verify')
|
||||
.set('X-Forwarded-For', '203.0.113.1')
|
||||
.send({ tempToken, token: '000000' })
|
||||
.expect(204);
|
||||
await request(app)
|
||||
.post('/verify')
|
||||
.set('X-Forwarded-For', '203.0.113.2')
|
||||
.send({ tempToken, token: '000001' })
|
||||
.expect(204);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/verify')
|
||||
.set('X-Forwarded-For', '203.0.113.3')
|
||||
.send({ tempToken, token: '000002' })
|
||||
.expect(429);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
message: 'Too many verification attempts, please try again after 5 minutes.',
|
||||
});
|
||||
expect(logViolation).toHaveBeenCalledTimes(1);
|
||||
expect(logViolation.mock.calls[0][0].user).toEqual({ id: 'user-1' });
|
||||
expect(logViolation.mock.calls[0][3]).toMatchObject({
|
||||
limiter: 'user',
|
||||
max: '2',
|
||||
windowInMinutes: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the existing source IP limit before the user limit', async () => {
|
||||
const { app, logViolation } = createApp();
|
||||
|
||||
await request(app)
|
||||
.post('/verify')
|
||||
.set('X-Forwarded-For', '198.51.100.1')
|
||||
.send({ tempToken: createToken('user-a'), token: '000000' })
|
||||
.expect(204);
|
||||
await request(app)
|
||||
.post('/verify')
|
||||
.set('X-Forwarded-For', '198.51.100.1')
|
||||
.send({ tempToken: createToken('user-b'), token: '000001' })
|
||||
.expect(204);
|
||||
|
||||
await request(app)
|
||||
.post('/verify')
|
||||
.set('X-Forwarded-For', '198.51.100.1')
|
||||
.send({ tempToken: createToken('user-c'), token: '000002' })
|
||||
.expect(429);
|
||||
|
||||
expect(logViolation).toHaveBeenCalledTimes(1);
|
||||
expect(logViolation.mock.calls[0][3]).toMatchObject({
|
||||
limiter: 'ip',
|
||||
max: '2',
|
||||
windowInMinutes: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue