💂 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>
This commit is contained in:
Cha 2026-07-09 21:55:36 +08:00 committed by GitHub
parent cb5454d364
commit 73c43ded25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 203 additions and 0 deletions

View file

@ -2,6 +2,7 @@ const validatePasswordReset = require('./validatePasswordReset');
const setTwoFactorTempUser = require('./setTwoFactorTempUser');
const validateRegistration = require('./validateRegistration');
const buildEndpointOption = require('./buildEndpointOption');
const validateEmailLogin = require('./validateEmailLogin');
const validateMessageReq = require('./validateMessageReq');
const { prepareMessageRequestValidation, sendValidationResponse } = require('./messageValidation');
const checkDomainAllowed = require('./checkDomainAllowed');
@ -53,4 +54,5 @@ module.exports = {
buildEndpointOption,
validateRegistration,
validatePasswordReset,
validateEmailLogin,
};

View file

@ -0,0 +1,3 @@
const { validateEmailLogin } = require('@librechat/api');
module.exports = validateEmailLogin;

View file

@ -73,6 +73,7 @@ router.post(
middleware.logHeaders,
middleware.loginLimiter,
middleware.checkBan,
middleware.validateEmailLogin,
middleware.requireLocalAuth,
tenantContextMiddleware,
requireAdminAccess,

View file

@ -97,6 +97,7 @@ jest.mock('~/server/middleware', () => ({
logHeaders: jest.fn((req, res, next) => next()),
loginLimiter: jest.fn((req, res, next) => next()),
checkBan: jest.fn((req, res, next) => next()),
validateEmailLogin: jest.fn((req, res, next) => next()),
requireLocalAuth: jest.fn((req, res, next) => next()),
requireJwtAuth: jest.fn((req, res, next) => next()),
checkDomainAllowed: jest.fn((req, res, next) => next()),
@ -106,6 +107,7 @@ const openIdClient = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, applyAdminRefresh, buildOpenIDRefreshParams } = require('@librechat/api');
const { getOpenIdConfig } = require('~/strategies');
const middleware = require('~/server/middleware');
const adminAuthRouter = require('./auth');
const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE;
@ -248,3 +250,44 @@ describe('admin auth OpenID refresh route', () => {
expect(debugOutput).not.toContain('https://api.example.com');
});
});
describe('admin local login route', () => {
let app;
beforeEach(() => {
jest.clearAllMocks();
app = express();
app.use(express.json());
app.use('/api/admin', adminAuthRouter);
});
it('applies the email login gate before local auth', async () => {
const response = await request(app).post('/api/admin/login/local').send({
email: 'admin@example.com',
password: 'password',
});
expect(response.status).toBe(200);
expect(middleware.validateEmailLogin).toHaveBeenCalledTimes(1);
expect(middleware.requireLocalAuth).toHaveBeenCalledTimes(1);
expect(middleware.validateEmailLogin.mock.invocationCallOrder[0]).toBeLessThan(
middleware.requireLocalAuth.mock.invocationCallOrder[0],
);
});
it('stops before local auth when the email login gate rejects the request', async () => {
middleware.validateEmailLogin.mockImplementationOnce((req, res) =>
res.status(403).json({ message: 'Email login is not allowed.' }),
);
const response = await request(app).post('/api/admin/login/local').send({
email: 'admin@example.com',
password: 'password',
});
expect(response.status).toBe(403);
expect(response.body).toEqual({ message: 'Email login is not allowed.' });
expect(middleware.requireLocalAuth).not.toHaveBeenCalled();
});
});

View file

@ -56,6 +56,7 @@ jest.mock('~/server/middleware', () => {
setTwoFactorTempUser: (...args) => mockSetTwoFactorTempUser(...args),
twoFactorTempLimiter: (...args) => mockTwoFactorTempLimiter(...args),
checkBan: (...args) => mockCheckBan(...args),
validateEmailLogin: pass,
requireLocalAuth: pass,
requireLdapAuth: pass,
registerLimiter: pass,

View file

@ -53,6 +53,7 @@ jest.mock('~/server/middleware', () => {
setTwoFactorTempUser: pass,
twoFactorTempLimiter: pass,
checkBan: pass,
validateEmailLogin: pass,
requireLocalAuth: pass,
requireLdapAuth: pass,
registerLimiter: pass,

View file

@ -45,6 +45,7 @@ router.post(
middleware.logHeaders,
middleware.loginLimiter,
middleware.checkBan,
middleware.validateEmailLogin,
ldapAuth ? middleware.requireLdapAuth : middleware.requireLocalAuth,
setBalanceConfig,
loginController,

View file

@ -56,6 +56,7 @@ jest.mock('~/server/middleware', () => {
setTwoFactorTempUser: pass,
twoFactorTempLimiter: pass,
checkBan: (...args) => mockCheckBan(...args),
validateEmailLogin: pass,
requireLocalAuth: pass,
requireLdapAuth: pass,
registerLimiter: pass,