LibreChat/api/server/routes/auth.reset-password-ratelimit.test.js
Cha 73c43ded25
💂 fix: Enforce ALLOW_EMAIL_LOGIN on the Backend Login Route (#14180)
* 🔒 fix: Enforce ALLOW_EMAIL_LOGIN on Backend Login Route

ALLOW_EMAIL_LOGIN=false previously only hid the login form; POST
/api/auth/login stayed mounted and accepted valid credentials. Add a
validateEmailLogin middleware (mirroring validateRegistration /
validatePasswordReset) that rejects login with 403 when the flag is
disabled, with an ALLOW_EMAIL_LOGIN_OVERRIDE escape hatch for
intentional direct API login (each use logged with request IP).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Gate admin local login by email login flag

* fix: Move email login gate into api package

* test: Avoid mutating readonly request ip

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-07-09 09:55:36 -04:00

122 lines
4.6 KiB
JavaScript

const express = require('express');
const request = require('supertest');
const mockCheckBan = jest.fn((req, res, next) => next());
const mockResetPasswordSubmissionLimiter = jest.fn((req, res, next) => next());
const mockValidatePasswordReset = jest.fn((req, res, next) => next());
const mockResetPasswordController = jest.fn((req, res) => res.status(204).end());
jest.mock('@librechat/api', () => ({
createSetBalanceConfig: jest.fn(() => (req, res, next) => next()),
forceRefreshCloudFrontAuthCookies: jest.fn(),
}));
jest.mock('~/server/controllers/AuthController', () => ({
refreshController: jest.fn((req, res) => res.status(204).end()),
registrationController: jest.fn((req, res) => res.status(204).end()),
resetPasswordController: (...args) => mockResetPasswordController(...args),
resetPasswordRequestController: jest.fn((req, res) => res.status(204).end()),
graphTokenController: jest.fn((req, res) => res.status(204).end()),
}));
jest.mock('~/server/controllers/TwoFactorController', () => ({
enable2FA: jest.fn((req, res) => res.status(204).end()),
verify2FA: jest.fn((req, res) => res.status(204).end()),
confirm2FA: jest.fn((req, res) => res.status(204).end()),
disable2FA: jest.fn((req, res) => res.status(204).end()),
regenerateBackupCodes: jest.fn((req, res) => res.status(204).end()),
}));
jest.mock('~/server/controllers/auth/TwoFactorAuthController', () => ({
verify2FAWithTempToken: jest.fn((req, res) => res.status(204).end()),
}));
jest.mock('~/server/controllers/auth/LogoutController', () => ({
logoutController: jest.fn((req, res) => res.status(204).end()),
}));
jest.mock('~/server/controllers/auth/LoginController', () => ({
loginController: jest.fn((req, res) => res.status(204).end()),
}));
jest.mock('~/models', () => ({
findBalanceByUser: jest.fn(),
upsertBalanceFields: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
}));
jest.mock('~/server/middleware', () => {
const pass = (req, res, next) => next();
return {
logHeaders: pass,
loginLimiter: pass,
setTwoFactorTempUser: pass,
twoFactorTempLimiter: pass,
checkBan: (...args) => mockCheckBan(...args),
validateEmailLogin: pass,
requireLocalAuth: pass,
requireLdapAuth: pass,
registerLimiter: pass,
checkInviteUser: pass,
validateRegistration: pass,
resetPasswordLimiter: pass,
resetPasswordSubmissionLimiter: (...args) => mockResetPasswordSubmissionLimiter(...args),
validatePasswordReset: (...args) => mockValidatePasswordReset(...args),
requireJwtAuth: pass,
};
});
const authRouter = require('./auth');
describe('POST /api/auth/resetPassword rate limiting', () => {
let app;
beforeEach(() => {
jest.clearAllMocks();
mockCheckBan.mockImplementation((req, res, next) => next());
mockResetPasswordSubmissionLimiter.mockImplementation((req, res, next) => next());
mockValidatePasswordReset.mockImplementation((req, res, next) => next());
mockResetPasswordController.mockImplementation((req, res) => res.status(204).end());
app = express();
app.use(express.json());
app.use('/api/auth', authRouter);
});
it('limits before ban checks, validation, and password reset work', async () => {
await request(app).post('/api/auth/resetPassword').send({ token: 'token' }).expect(204);
expect(mockResetPasswordSubmissionLimiter).toHaveBeenCalledTimes(1);
expect(mockCheckBan).toHaveBeenCalledTimes(1);
expect(mockValidatePasswordReset).toHaveBeenCalledTimes(1);
expect(mockResetPasswordController).toHaveBeenCalledTimes(1);
expect(mockResetPasswordSubmissionLimiter.mock.invocationCallOrder[0]).toBeLessThan(
mockCheckBan.mock.invocationCallOrder[0],
);
expect(mockCheckBan.mock.invocationCallOrder[0]).toBeLessThan(
mockValidatePasswordReset.mock.invocationCallOrder[0],
);
expect(mockValidatePasswordReset.mock.invocationCallOrder[0]).toBeLessThan(
mockResetPasswordController.mock.invocationCallOrder[0],
);
});
it('does not validate or reset passwords after the limiter rejects the request', async () => {
mockResetPasswordSubmissionLimiter.mockImplementation((req, res) =>
res.status(429).json({ message: 'Too many password reset attempts' }),
);
const response = await request(app)
.post('/api/auth/resetPassword')
.send({ token: 'token' })
.expect(429);
expect(response.body).toEqual({ message: 'Too many password reset attempts' });
expect(mockCheckBan).not.toHaveBeenCalled();
expect(mockValidatePasswordReset).not.toHaveBeenCalled();
expect(mockResetPasswordController).not.toHaveBeenCalled();
});
});