mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
💂 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:
parent
cb5454d364
commit
73c43ded25
12 changed files with 203 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
3
api/server/middleware/validateEmailLogin.js
Normal file
3
api/server/middleware/validateEmailLogin.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
const { validateEmailLogin } = require('@librechat/api');
|
||||
|
||||
module.exports = validateEmailLogin;
|
||||
|
|
@ -73,6 +73,7 @@ router.post(
|
|||
middleware.logHeaders,
|
||||
middleware.loginLimiter,
|
||||
middleware.checkBan,
|
||||
middleware.validateEmailLogin,
|
||||
middleware.requireLocalAuth,
|
||||
tenantContextMiddleware,
|
||||
requireAdminAccess,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ jest.mock('~/server/middleware', () => {
|
|||
setTwoFactorTempUser: pass,
|
||||
twoFactorTempLimiter: pass,
|
||||
checkBan: pass,
|
||||
validateEmailLogin: pass,
|
||||
requireLocalAuth: pass,
|
||||
requireLdapAuth: pass,
|
||||
registerLimiter: pass,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ router.post(
|
|||
middleware.logHeaders,
|
||||
middleware.loginLimiter,
|
||||
middleware.checkBan,
|
||||
middleware.validateEmailLogin,
|
||||
ldapAuth ? middleware.requireLdapAuth : middleware.requireLocalAuth,
|
||||
setBalanceConfig,
|
||||
loginController,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ jest.mock('~/server/middleware', () => {
|
|||
setTwoFactorTempUser: pass,
|
||||
twoFactorTempLimiter: pass,
|
||||
checkBan: (...args) => mockCheckBan(...args),
|
||||
validateEmailLogin: pass,
|
||||
requireLocalAuth: pass,
|
||||
requireLdapAuth: pass,
|
||||
registerLimiter: pass,
|
||||
|
|
|
|||
121
packages/api/src/middleware/email.spec.ts
Normal file
121
packages/api/src/middleware/email.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
27
packages/api/src/middleware/email.ts
Normal file
27
packages/api/src/middleware/email.ts
Normal 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.' });
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue