💂 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

@ -631,6 +631,7 @@ ILLEGAL_MODEL_REQ_SCORE=5
#========================#
ALLOW_EMAIL_LOGIN=true
# ALLOW_EMAIL_LOGIN_OVERRIDE=false # note: permits direct API email login while ALLOW_EMAIL_LOGIN=false; each use is logged
ALLOW_REGISTRATION=true
ALLOW_SOCIAL_LOGIN=false
ALLOW_SOCIAL_REGISTRATION=false

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,

View file

@ -0,0 +1,121 @@
import { logger } from '@librechat/data-schemas';
import type { NextFunction, Request, Response } from 'express';
import { validateEmailLogin } from './email';
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
warn: jest.fn(),
},
}));
describe('validateEmailLogin', () => {
const originalEnv = {
ALLOW_EMAIL_LOGIN: process.env.ALLOW_EMAIL_LOGIN,
ALLOW_EMAIL_LOGIN_OVERRIDE: process.env.ALLOW_EMAIL_LOGIN_OVERRIDE,
};
let req: Request;
let res: Response;
let next: jest.MockedFunction<NextFunction>;
function createRequest(ip = '127.0.0.1'): Request {
return { ip } as Request;
}
beforeEach(() => {
delete process.env.ALLOW_EMAIL_LOGIN;
delete process.env.ALLOW_EMAIL_LOGIN_OVERRIDE;
req = createRequest();
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
} as Partial<Response> as Response;
next = jest.fn();
(logger.warn as jest.Mock).mockClear();
});
afterAll(() => {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it('should allow login when ALLOW_EMAIL_LOGIN is unset (default)', () => {
validateEmailLogin(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should allow login when ALLOW_EMAIL_LOGIN is true', () => {
process.env.ALLOW_EMAIL_LOGIN = 'true';
validateEmailLogin(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should reject login with 403 when ALLOW_EMAIL_LOGIN is false', () => {
process.env.ALLOW_EMAIL_LOGIN = 'false';
validateEmailLogin(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Email login is not allowed.' });
});
it('should log blocked login attempts with the request IP', () => {
process.env.ALLOW_EMAIL_LOGIN = 'false';
req = createRequest('10.0.0.42');
validateEmailLogin(req, res, next);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('10.0.0.42'));
});
it('should treat non-true values as disabled', () => {
process.env.ALLOW_EMAIL_LOGIN = 'no';
validateEmailLogin(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('should allow login when disabled but ALLOW_EMAIL_LOGIN_OVERRIDE is true', () => {
process.env.ALLOW_EMAIL_LOGIN = 'false';
process.env.ALLOW_EMAIL_LOGIN_OVERRIDE = 'true';
validateEmailLogin(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should log override logins with the request IP', () => {
process.env.ALLOW_EMAIL_LOGIN = 'false';
process.env.ALLOW_EMAIL_LOGIN_OVERRIDE = 'true';
req = createRequest('10.0.0.42');
validateEmailLogin(req, res, next);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('ALLOW_EMAIL_LOGIN_OVERRIDE'));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('10.0.0.42'));
});
it('should ignore the override when email login is enabled', () => {
process.env.ALLOW_EMAIL_LOGIN_OVERRIDE = 'true';
validateEmailLogin(req, res, next);
expect(next).toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,27 @@
import { logger } from '@librechat/data-schemas';
import type { NextFunction, Request, Response } from 'express';
import { isEnabled } from '~/utils';
export function validateEmailLogin(
req: Request,
res: Response,
next: NextFunction,
): Response | void {
const emailLoginEnabled =
process.env.ALLOW_EMAIL_LOGIN === undefined || isEnabled(process.env.ALLOW_EMAIL_LOGIN);
if (emailLoginEnabled) {
next();
return;
}
if (isEnabled(process.env.ALLOW_EMAIL_LOGIN_OVERRIDE)) {
logger.warn(
`[validateEmailLogin] Email login is disabled; allowing login attempt via ALLOW_EMAIL_LOGIN_OVERRIDE. IP: ${req.ip}`,
);
next();
return;
}
logger.warn(`[validateEmailLogin] Login attempt while email login is disabled. IP: ${req.ip}`);
return res.status(403).json({ message: 'Email login is not allowed.' });
}

View file

@ -1,6 +1,7 @@
export * from './access';
export * from './admin';
export * from './error';
export * from './email';
export * from './notFound';
export * from './balance';
export * from './json';