chore: import upstream snapshot with attribution
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:12 +08:00
commit e115934061
3584 changed files with 848449 additions and 0 deletions
@@ -0,0 +1,987 @@
/**
* Integration test: verifies that requireJwtAuth chains tenantContextMiddleware
* after successful passport authentication, so ALS tenant context is set for
* all downstream middleware and route handlers.
*
* requireJwtAuth must chain tenantContextMiddleware after passport populates
* req.user (not at global app.use() scope where req.user is undefined).
* If the chaining is removed, these tests fail.
*/
const jwt = require('jsonwebtoken');
// ── Mocks ──────────────────────────────────────────────────────────────
let mockPassportError = null;
let mockRegisteredStrategies = new Set(['jwt']);
jest.mock('passport', () => ({
_strategy: jest.fn((strategy) => (mockRegisteredStrategies.has(strategy) ? {} : undefined)),
authenticate: jest.fn((strategy, _options, callback) => {
return (req, _res, _done) => {
if (mockPassportError) {
return callback(mockPassportError);
}
const strategyResult = req._mockStrategies?.[strategy];
if (strategyResult) {
return callback(
strategyResult.err ?? null,
strategyResult.user ?? false,
strategyResult.info,
strategyResult.status,
);
}
return callback(null, req._mockUser ?? false, { message: 'Unauthorized' }, 401);
};
}),
}));
jest.mock('@librechat/data-schemas', () => {
const { AsyncLocalStorage } = require('async_hooks');
const tenantStorage = new AsyncLocalStorage();
return {
getTenantId: () => tenantStorage.getStore()?.tenantId,
getUserId: () => tenantStorage.getStore()?.userId,
getRequestId: () => tenantStorage.getStore()?.requestId,
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
tenantStorage,
};
});
// Mock @librechat/api — the real tenantContextMiddleware is TS and cannot be
// required directly from CJS tests. This thin wrapper mirrors the real logic
// (read request context, call tenantStorage.run) using the same data-schemas
// primitives. The real implementation is covered by packages/api tenant.spec.ts.
jest.mock('@librechat/api', () => {
const { tenantStorage } = require('@librechat/data-schemas');
const normalizeAuthLogValue = (value) => {
if (value == null) {
return undefined;
}
if (Array.isArray(value)) {
for (const entry of value) {
const normalized = normalizeAuthLogValue(entry);
if (normalized) {
return normalized;
}
}
return undefined;
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || undefined;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return undefined;
};
const normalizeAuthLogContextValue = (value) => {
if (value == null) {
return undefined;
}
if (Array.isArray(value)) {
const values = value
.map((entry) => normalizeAuthLogValue(entry))
.filter((entry) => entry !== undefined);
return values.length > 0 ? values : undefined;
}
if (typeof value === 'string') {
return normalizeAuthLogValue(value);
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined;
}
if (typeof value === 'boolean') {
return value;
}
return undefined;
};
const getAuthFailureField = (source, field) => {
if (!source) {
return undefined;
}
if (typeof source === 'string') {
return field === 'message' ? source : undefined;
}
if (typeof source === 'object') {
try {
return source[field];
} catch {
return undefined;
}
}
return undefined;
};
const getAuthFailureReason = (err, info, fallback = 'Unauthorized') =>
normalizeAuthLogValue(getAuthFailureField(info, 'message')) ??
normalizeAuthLogValue(getAuthFailureField(err, 'message')) ??
fallback;
const getAuthFailureErrorName = (err, info) =>
normalizeAuthLogValue(getAuthFailureField(info, 'name')) ??
normalizeAuthLogValue(getAuthFailureField(err, 'name'));
const getSafeTokenProvider = (tokenProvider) => {
const normalized = normalizeAuthLogValue(tokenProvider);
if (!normalized) {
return undefined;
}
return normalized === 'openid' || normalized === 'librechat' ? normalized : 'other';
};
const normalizeRoutePath = (path) => {
if (typeof path === 'string') {
return normalizeAuthLogValue(path);
}
if (Array.isArray(path)) {
for (const entry of path) {
const normalized = normalizeRoutePath(entry);
if (normalized) {
return normalized;
}
}
}
return undefined;
};
const joinRoutePath = (baseUrl, routePath) => {
const normalizedRoute = routePath === '/' ? '' : routePath;
if (!baseUrl) {
return normalizedRoute || '/';
}
if (!normalizedRoute) {
return baseUrl;
}
return `${baseUrl.replace(/\/$/, '')}/${normalizedRoute.replace(/^\//, '')}`;
};
const bucketConcretePath = (path) => {
const queryless = path?.split('?')[0];
if (!queryless) {
return undefined;
}
const segments = queryless.split('/').filter(Boolean);
if (segments.length === 0) {
return '/';
}
if (segments[0] === 'api' && segments[1]) {
return `/${segments.slice(0, 2).join('/')}`;
}
return `/${segments[0]}`;
};
const getRequestPath = (req) => {
const baseUrl = normalizeAuthLogValue(req.baseUrl);
const routePath = normalizeRoutePath(req.route?.path);
if (routePath) {
return joinRoutePath(baseUrl, routePath);
}
if (baseUrl) {
return baseUrl;
}
const path =
normalizeAuthLogValue(req.path) ?? normalizeAuthLogValue(req.originalUrl ?? req.url);
return bucketConcretePath(path);
};
const compactAuthLogContext = (log) =>
Object.fromEntries(
Object.entries(log)
.map(([key, value]) => [key, normalizeAuthLogContextValue(value)])
.filter(([, value]) => value !== undefined),
);
const buildSafeAuthLogContext = (req, authState, extra = {}) =>
compactAuthLogContext({
...extra,
request_id:
normalizeAuthLogValue(req.requestId) ??
normalizeAuthLogValue(req.id) ??
normalizeAuthLogValue(req.headers?.['x-request-id']) ??
normalizeAuthLogValue(req.headers?.['x-correlation-id']),
method: normalizeAuthLogValue(req.method),
path: getRequestPath(req),
token_provider: getSafeTokenProvider(authState.tokenProvider),
openid_reuse_enabled: authState.openidReuseEnabled,
openid_jwt_available: authState.openidJwtAvailable,
has_openid_reuse_user_id: authState.hasOpenIdReuseUserId,
});
const formatAuthLogMessage = (message, context) => `${message} ${JSON.stringify(context)}`;
const normalizeContextValue = (value) => {
const trimmed = value?.trim?.();
return trimmed || undefined;
};
const getUserId = (user) =>
normalizeContextValue(user?.id?.toString?.()) ?? normalizeContextValue(user?._id?.toString?.());
const getRequestId = (req) =>
normalizeContextValue(req.requestId) ??
normalizeContextValue(req.id) ??
normalizeContextValue(req.headers?.['x-request-id']) ??
normalizeContextValue(req.headers?.['x-correlation-id']);
return {
isEnabled: jest.fn(() => false),
recordRumProxyRequest: jest.fn(),
getAuthFailureReason,
getAuthFailureErrorName,
buildSafeAuthLogContext,
formatAuthLogMessage,
maybeRefreshCloudFrontAuthCookiesMiddleware: jest.fn((req, res, next) => next()),
tenantContextMiddleware: (req, res, next) => {
const context = {
tenantId: normalizeContextValue(req.user?.tenantId),
userId: getUserId(req.user),
requestId: getRequestId(req),
};
if (!context.tenantId && !context.userId && !context.requestId) {
return next();
}
return tenantStorage.run(context, async () => next());
},
};
});
// ── Helpers ─────────────────────────────────────────────────────────────
const requireJwtAuth = require('../requireJwtAuth');
const { requireRumProxyAuth } = requireJwtAuth;
const { getTenantId, getUserId, logger } = require('@librechat/data-schemas');
const {
isEnabled,
maybeRefreshCloudFrontAuthCookiesMiddleware,
recordRumProxyRequest,
} = require('@librechat/api');
const passport = require('passport');
const jwtSecret = 'test-refresh-secret';
function mockReq(user, extra = {}) {
return { headers: {}, _mockUser: user, ...extra };
}
function signedOpenIdUserCookie(userId = 'user-openid') {
return jwt.sign({ id: userId }, jwtSecret);
}
function mockRes() {
return {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
};
}
/** Runs requireJwtAuth and returns the tenantId observed inside next(). */
function runAuth(user) {
return new Promise((resolve) => {
const req = mockReq(user);
const res = mockRes();
requireJwtAuth(req, res, () => {
resolve(getTenantId());
});
});
}
// ── Tests ──────────────────────────────────────────────────────────────
describe('requireJwtAuth tenant context chaining', () => {
const originalJwtSecret = process.env.JWT_REFRESH_SECRET;
beforeEach(() => {
process.env.JWT_REFRESH_SECRET = jwtSecret;
});
afterEach(() => {
mockPassportError = null;
mockRegisteredStrategies = new Set(['jwt']);
isEnabled.mockReturnValue(false);
maybeRefreshCloudFrontAuthCookiesMiddleware.mockClear();
logger.debug.mockClear();
logger.info.mockClear();
logger.warn.mockClear();
logger.error.mockClear();
recordRumProxyRequest.mockClear();
passport.authenticate.mockClear();
passport._strategy.mockClear();
if (originalJwtSecret === undefined) {
delete process.env.JWT_REFRESH_SECRET;
} else {
process.env.JWT_REFRESH_SECRET = originalJwtSecret;
}
});
it('forwards passport errors to next() without entering tenant middleware', async () => {
mockPassportError = new Error('JWT signature invalid');
const req = mockReq(undefined);
const res = mockRes();
const err = await new Promise((resolve) => {
requireJwtAuth(req, res, (e) => resolve(e));
});
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe('JWT signature invalid');
expect(getTenantId()).toBeUndefined();
});
it('sets ALS tenant context after passport auth succeeds', async () => {
const tenantId = await runAuth({ tenantId: 'tenant-abc', role: 'user' });
expect(tenantId).toBe('tenant-abc');
});
it('refreshes CloudFront auth cookies after passport auth succeeds', () => {
const req = mockReq({ tenantId: 'tenant-abc', role: 'user' });
const res = mockRes();
const next = jest.fn();
requireJwtAuth(req, res, next);
expect(maybeRefreshCloudFrontAuthCookiesMiddleware).toHaveBeenCalledWith(
req,
res,
expect.any(Function),
);
expect(next).toHaveBeenCalled();
});
it('refreshes CloudFront auth cookies inside the request context', () => {
let observedContext;
maybeRefreshCloudFrontAuthCookiesMiddleware.mockImplementationOnce(
(_req, _res, middlewareNext) => {
observedContext = {
tenantId: getTenantId(),
userId: getUserId(),
};
middlewareNext();
},
);
const req = mockReq({ id: 'user-123', tenantId: 'tenant-abc', role: 'user' });
const res = mockRes();
const next = jest.fn();
requireJwtAuth(req, res, next);
expect(observedContext).toEqual({ tenantId: 'tenant-abc', userId: 'user-123' });
expect(next).toHaveBeenCalled();
});
it('ALS tenant context is NOT set when user has no tenantId', async () => {
const tenantId = await runAuth({ role: 'user' });
expect(tenantId).toBeUndefined();
});
it('returns 401 when no strategy authenticates a user', async () => {
const req = mockReq(undefined);
const res = mockRes();
const next = jest.fn();
requireJwtAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(getTenantId()).toBeUndefined();
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'),
expect.objectContaining({
primary_strategy: 'jwt',
fallback_attempted: false,
fallback_succeeded: false,
attempted_strategies: ['jwt'],
final_strategy: 'jwt',
reason: 'Unauthorized',
status: 401,
}),
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('logs OpenID JWT expiry when JWT fallback succeeds', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
requestId: 'req-expired-success',
method: 'GET',
path: '/api/messages',
headers: {
cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`,
},
_mockStrategies: {
openidJwt: {
user: false,
info: { message: 'jwt expired', name: 'TokenExpiredError' },
status: 401,
},
jwt: { user: { id: 'user-jwt', tenantId: 'tenant-jwt', role: 'user' } },
},
});
const res = mockRes();
const next = jest.fn();
requireJwtAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.authStrategy).toBe('jwt');
expect(res.status).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
expect.objectContaining({
request_id: 'req-expired-success',
method: 'GET',
path: '/api/messages',
token_provider: 'openid',
openid_reuse_enabled: true,
openid_jwt_available: true,
has_openid_reuse_user_id: true,
primary_strategy: 'openidJwt',
fallback_strategy: 'jwt',
fallback_attempted: true,
reason: 'jwt expired',
error_name: 'TokenExpiredError',
status: 401,
}),
);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'),
expect.objectContaining({
request_id: 'req-expired-success',
auth_strategy: 'jwt',
primary_strategy: 'openidJwt',
fallback_strategy: 'jwt',
fallback_attempted: true,
fallback_succeeded: true,
primary_failure_reason: 'jwt expired',
reason: 'jwt expired',
error_name: 'TokenExpiredError',
}),
);
expect(logger.debug.mock.calls[0][0]).toContain('"reason":"jwt expired"');
expect(logger.debug.mock.calls[0][0]).toContain('"fallback_attempted":true');
expect(logger.debug.mock.calls[1][0]).toContain('"fallback_succeeded":true');
expect(logger.warn).not.toHaveBeenCalled();
});
it('does not let malformed Passport info break JWT fallback logging', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const info = {};
Object.defineProperties(info, {
message: {
get() {
throw new TypeError('message getter failed');
},
},
name: {
get() {
throw new TypeError('name getter failed');
},
},
});
const req = mockReq(undefined, {
requestId: 'req-malformed-info',
method: 'GET',
path: '/api/messages',
headers: {
cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`,
},
_mockStrategies: {
openidJwt: {
user: false,
info,
status: 401,
},
jwt: { user: { id: 'user-jwt', tenantId: 'tenant-jwt', role: 'user' } },
},
});
const res = mockRes();
const next = jest.fn();
expect(() => requireJwtAuth(req, res, next)).not.toThrow();
expect(next).toHaveBeenCalled();
expect(req.authStrategy).toBe('jwt');
expect(res.status).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
expect.objectContaining({
request_id: 'req-malformed-info',
fallback_attempted: true,
reason: 'Unauthorized',
status: 401,
}),
);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'),
expect.objectContaining({
request_id: 'req-malformed-info',
fallback_succeeded: true,
primary_failure_reason: 'Unauthorized',
}),
);
});
it('logs OpenID JWT expiry when JWT fallback fails', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
id: 'req-expired-fail',
method: 'POST',
originalUrl: '/api/ask?access_token=hidden',
headers: {
cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`,
},
_mockStrategies: {
openidJwt: {
user: false,
info: { message: 'jwt expired', name: 'TokenExpiredError' },
status: 401,
},
jwt: {
user: false,
info: { message: 'invalid signature', name: 'JsonWebTokenError' },
status: 401,
},
},
});
const res = mockRes();
const next = jest.fn();
requireJwtAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
expect.objectContaining({
request_id: 'req-expired-fail',
method: 'POST',
path: '/api/ask',
fallback_attempted: true,
reason: 'jwt expired',
error_name: 'TokenExpiredError',
status: 401,
}),
);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'),
expect.objectContaining({
request_id: 'req-expired-fail',
method: 'POST',
path: '/api/ask',
token_provider: 'openid',
attempted_strategies: ['openidJwt', 'jwt'],
final_strategy: 'jwt',
primary_strategy: 'openidJwt',
fallback_strategy: 'jwt',
fallback_attempted: true,
fallback_succeeded: false,
reason: 'invalid signature',
error_name: 'JsonWebTokenError',
status: 401,
}),
);
expect(logger.warn.mock.calls[0][0]).toContain('"reason":"invalid signature"');
expect(logger.warn.mock.calls[0][0]).toContain('"path":"/api/ask"');
});
it('does not fall back to OpenID JWT for bearer-only reuse requests', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
_mockStrategies: {
jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } },
},
});
const res = mockRes();
const next = jest.fn();
requireJwtAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(req.authStrategy).toBeUndefined();
expect(passport.authenticate).toHaveBeenCalledTimes(1);
expect(passport.authenticate).toHaveBeenCalledWith(
'jwt',
{ session: false },
expect.any(Function),
);
});
it('uses OpenID JWT before LibreChat JWT when the OpenID cookie is present', async () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
headers: { cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie()}` },
_mockStrategies: {
openidJwt: { user: { id: 'user-openid', tenantId: 'tenant-openid', role: 'user' } },
jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
},
});
const res = mockRes();
const tenantId = await new Promise((resolve) => {
requireJwtAuth(req, res, () => {
resolve(getTenantId());
});
});
expect(tenantId).toBe('tenant-openid');
expect(req.authStrategy).toBe('openidJwt');
expect(res.status).not.toHaveBeenCalled();
expect(passport.authenticate).toHaveBeenCalledWith(
'openidJwt',
{ session: false },
expect.any(Function),
);
expect(maybeRefreshCloudFrontAuthCookiesMiddleware).toHaveBeenCalledWith(
req,
res,
expect.any(Function),
);
});
it('logs OpenID user-id mismatch when JWT fallback succeeds', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
requestId: 'req-mismatch-success',
method: 'GET',
path: '/api/auth/me',
headers: {
cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-a')}`,
},
_mockStrategies: {
openidJwt: { user: { id: 'user-b', tenantId: 'tenant-openid', role: 'user' } },
jwt: { user: { id: 'user-a', tenantId: 'tenant-jwt', role: 'user' } },
},
});
const res = mockRes();
const next = jest.fn();
requireJwtAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.authStrategy).toBe('jwt');
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
expect.objectContaining({
request_id: 'req-mismatch-success',
primary_strategy: 'openidJwt',
fallback_strategy: 'jwt',
fallback_attempted: true,
reason: 'openid user-id mismatch',
status: 401,
}),
);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'),
expect.objectContaining({
request_id: 'req-mismatch-success',
auth_strategy: 'jwt',
fallback_attempted: true,
fallback_succeeded: true,
primary_failure_reason: 'openid user-id mismatch',
reason: 'openid user-id mismatch',
}),
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('logs OpenID user-id mismatch when JWT fallback fails', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
requestId: 'req-mismatch-fail',
method: 'GET',
path: '/api/auth/me',
headers: {
cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-a')}`,
},
_mockStrategies: {
openidJwt: { user: { id: 'user-b', tenantId: 'tenant-openid', role: 'user' } },
jwt: { user: false, info: { message: 'Unauthorized' }, status: 401 },
},
});
const res = mockRes();
const next = jest.fn();
requireJwtAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'),
expect.objectContaining({
request_id: 'req-mismatch-fail',
fallback_attempted: true,
reason: 'openid user-id mismatch',
status: 401,
}),
);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'),
expect.objectContaining({
request_id: 'req-mismatch-fail',
attempted_strategies: ['openidJwt', 'jwt'],
final_strategy: 'jwt',
fallback_attempted: true,
fallback_succeeded: false,
reason: 'Unauthorized',
status: 401,
}),
);
});
it('does not authenticate OpenID JWT when the reuse cookie belongs to another user', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
headers: {
cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-a')}`,
},
_mockStrategies: {
openidJwt: { user: { id: 'user-b', tenantId: 'tenant-openid', role: 'user' } },
jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
},
});
const res = mockRes();
const next = jest.fn();
requireJwtAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(req.authStrategy).toBeUndefined();
expect(passport.authenticate).toHaveBeenCalledTimes(2);
expect(passport.authenticate).toHaveBeenNthCalledWith(
1,
'openidJwt',
{ session: false },
expect.any(Function),
);
expect(passport.authenticate).toHaveBeenNthCalledWith(
2,
'jwt',
{ session: false },
expect.any(Function),
);
expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
});
it('does not use OpenID JWT when the signed OpenID reuse cookie is missing', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
headers: { cookie: 'token_provider=openid' },
_mockStrategies: {
jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } },
},
});
const res = mockRes();
const next = jest.fn();
requireJwtAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(req.authStrategy).toBeUndefined();
expect(passport.authenticate).toHaveBeenCalledTimes(1);
expect(passport.authenticate).toHaveBeenCalledWith(
'jwt',
{ session: false },
expect.any(Function),
);
expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
});
it('does not use OpenID JWT when the OpenID reuse cookie is invalid', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
headers: { cookie: 'token_provider=openid; openid_user_id=invalid-jwt' },
_mockStrategies: {
jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } },
},
});
const res = mockRes();
const next = jest.fn();
requireJwtAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(req.authStrategy).toBeUndefined();
expect(passport.authenticate).toHaveBeenCalledTimes(1);
expect(passport.authenticate).toHaveBeenCalledWith(
'jwt',
{ session: false },
expect.any(Function),
);
expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
});
it('skips OpenID JWT fallback when the strategy was not registered', async () => {
isEnabled.mockReturnValue(true);
const req = mockReq(undefined, {
_mockStrategies: {
jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } },
},
});
const res = mockRes();
const next = jest.fn();
requireJwtAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(req.authStrategy).toBeUndefined();
expect(passport.authenticate).toHaveBeenCalledTimes(1);
expect(passport.authenticate).toHaveBeenCalledWith(
'jwt',
{ session: false },
expect.any(Function),
);
});
it('concurrent requests get isolated tenant contexts', async () => {
const results = await Promise.all(
['tenant-1', 'tenant-2', 'tenant-3'].map((tid) => runAuth({ tenantId: tid, role: 'user' })),
);
expect(results).toEqual(['tenant-1', 'tenant-2', 'tenant-3']);
});
it('ALS context is not set at top-level scope (outside any request)', () => {
expect(getTenantId()).toBeUndefined();
});
});
describe('requireRumProxyAuth', () => {
const originalJwtSecret = process.env.JWT_REFRESH_SECRET;
beforeEach(() => {
process.env.JWT_REFRESH_SECRET = jwtSecret;
});
afterEach(() => {
mockPassportError = null;
mockRegisteredStrategies = new Set(['jwt']);
isEnabled.mockReturnValue(false);
maybeRefreshCloudFrontAuthCookiesMiddleware.mockClear();
logger.debug.mockClear();
logger.info.mockClear();
logger.warn.mockClear();
logger.error.mockClear();
recordRumProxyRequest.mockClear();
passport.authenticate.mockClear();
passport._strategy.mockClear();
if (originalJwtSecret === undefined) {
delete process.env.JWT_REFRESH_SECRET;
} else {
process.env.JWT_REFRESH_SECRET = originalJwtSecret;
}
});
it('authenticates telemetry with the LibreChat JWT strategy without tenant or cookie refresh middleware', () => {
const req = mockReq({ id: 'user-jwt', tenantId: 'tenant-jwt', role: 'user' });
const res = mockRes();
const next = jest.fn();
requireRumProxyAuth(req, res, next);
expect(passport.authenticate).toHaveBeenCalledWith(
'jwt',
{ session: false },
expect.any(Function),
);
expect(req.authStrategy).toBe('jwt');
expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
// Success is recorded by the proxy.
expect(recordRumProxyRequest).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});
it('authenticates telemetry with OpenID JWT reuse when the reuse cookie is present', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
headers: { cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie()}` },
_mockStrategies: {
openidJwt: { user: { id: 'user-openid', tenantId: 'tenant-openid', role: 'user' } },
jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
},
});
const res = mockRes();
const next = jest.fn();
requireRumProxyAuth(req, res, next);
expect(passport.authenticate).toHaveBeenCalledWith(
'openidJwt',
{ session: false },
expect.any(Function),
);
expect(req.authStrategy).toBe('openidJwt');
expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
expect(recordRumProxyRequest).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});
it('falls back to LibreChat JWT when OpenID JWT telemetry auth fails', () => {
isEnabled.mockReturnValue(true);
mockRegisteredStrategies.add('openidJwt');
const req = mockReq(undefined, {
headers: { cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie()}` },
_mockStrategies: {
openidJwt: {
user: false,
info: { message: 'jwt expired', name: 'TokenExpiredError' },
status: 401,
},
jwt: { user: { id: 'user-openid', tenantId: 'tenant-jwt', role: 'user' } },
},
});
const res = mockRes();
const next = jest.fn();
requireRumProxyAuth(req, res, next);
expect(passport.authenticate).toHaveBeenCalledTimes(2);
expect(req.authStrategy).toBe('jwt');
expect(recordRumProxyRequest).not.toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});
it('drops invalid telemetry auth with 204 instead of returning an app auth error', () => {
const req = mockReq(undefined, {
path: '/v1/traces',
_mockStrategies: {
jwt: {
user: false,
info: { message: 'invalid signature', name: 'JsonWebTokenError' },
status: 401,
},
},
});
const res = mockRes();
const next = jest.fn();
requireRumProxyAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled();
expect(recordRumProxyRequest).toHaveBeenCalledWith('traces', 'auth_drop');
expect(res.status).toHaveBeenCalledWith(204);
expect(res.end).toHaveBeenCalled();
});
it('records passport errors separately from ordinary telemetry auth drops', () => {
mockPassportError = new Error('passport unavailable');
const req = mockReq(undefined, { path: '/v1/logs' });
const res = mockRes();
const next = jest.fn();
requireRumProxyAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
expect(recordRumProxyRequest).toHaveBeenCalledWith('logs', 'auth_error');
expect(res.status).toHaveBeenCalledWith(204);
expect(res.end).toHaveBeenCalled();
});
});
@@ -0,0 +1,240 @@
jest.mock('~/models', () => ({
getConvo: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
createMessageRequestMiddleware:
jest.requireActual('@librechat/api').createMessageRequestMiddleware,
GenerationJobManager: {
getJob: jest.fn(),
},
isPendingActionStale: jest.fn(() => false),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
warn: jest.fn(),
},
}));
const validateMessageReq = require('../validateMessageReq');
const { getConvo } = require('~/models');
const { GenerationJobManager } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
function createResponse() {
const res = {
json: jest.fn(),
send: jest.fn(),
status: jest.fn(),
};
res.status.mockReturnValue(res);
return res;
}
describe('validateMessageReq', () => {
const userId = 'user-123';
beforeEach(() => {
jest.clearAllMocks();
});
it('should reject requests when URL and body conversationId values differ', async () => {
const req = {
params: { conversationId: 'convo-owned' },
body: { conversationId: 'convo-victim' },
user: { id: userId },
};
const res = createResponse();
const next = jest.fn();
await validateMessageReq(req, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Conversation ID mismatch' });
expect(getConvo).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
it('should reject requests when URL and nested message conversationId values differ', async () => {
const req = {
params: { conversationId: 'convo-owned' },
body: { message: { conversationId: 'convo-victim' } },
user: { id: userId },
};
const res = createResponse();
const next = jest.fn();
await validateMessageReq(req, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Conversation ID mismatch' });
expect(getConvo).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
it('should validate ownership against the URL conversationId when values match', async () => {
const req = {
params: { conversationId: 'convo-owned' },
body: { conversationId: 'convo-owned' },
user: { id: userId },
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue({ conversationId: 'convo-owned', user: userId });
await validateMessageReq(req, res, next);
expect(getConvo).toHaveBeenCalledWith(userId, 'convo-owned');
expect(next).toHaveBeenCalledTimes(1);
});
it('should allow message reads for an owned active generation job before the conversation is saved', async () => {
const req = {
method: 'GET',
params: { conversationId: 'active-convo' },
body: {},
user: { id: userId, tenantId: 'tenant-a' },
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue(null);
GenerationJobManager.getJob.mockResolvedValue({
status: 'running',
metadata: { userId, tenantId: 'tenant-a' },
});
await validateMessageReq(req, res, next);
expect(GenerationJobManager.getJob).toHaveBeenCalledWith('active-convo');
expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
});
it('should allow message reads for an owned active generation job without tenant metadata', async () => {
const req = {
method: 'GET',
params: { conversationId: 'active-convo' },
body: {},
user: { id: userId },
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue(null);
GenerationJobManager.getJob.mockResolvedValue({
status: 'running',
metadata: { userId },
});
await validateMessageReq(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
});
it('should reject active job message reads owned by another user', async () => {
const req = {
method: 'GET',
params: { conversationId: 'active-convo' },
body: {},
user: { id: userId },
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue(null);
GenerationJobManager.getJob.mockResolvedValue({
status: 'running',
metadata: { userId: 'another-user' },
});
await validateMessageReq(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' });
expect(next).not.toHaveBeenCalled();
});
it('should reject active job message reads from another tenant', async () => {
const req = {
method: 'GET',
params: { conversationId: 'active-convo' },
body: {},
user: { id: userId, tenantId: 'tenant-a' },
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue(null);
GenerationJobManager.getJob.mockResolvedValue({
status: 'running',
metadata: { userId, tenantId: 'tenant-b' },
});
await validateMessageReq(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' });
expect(next).not.toHaveBeenCalled();
});
it('should reject message-by-id reads before the conversation is saved', async () => {
const req = {
method: 'GET',
params: { conversationId: 'active-convo', messageId: 'message-id' },
body: {},
user: { id: userId },
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue(null);
await validateMessageReq(req, res, next);
expect(GenerationJobManager.getJob).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' });
expect(next).not.toHaveBeenCalled();
});
it('should return not found when active job lookup fails', async () => {
const req = {
method: 'GET',
params: { conversationId: 'active-convo' },
body: {},
user: { id: userId },
};
const res = createResponse();
const next = jest.fn();
const error = new Error('job store unavailable');
getConvo.mockResolvedValue(null);
GenerationJobManager.getJob.mockRejectedValue(error);
await validateMessageReq(req, res, next);
expect(GenerationJobManager.getJob).toHaveBeenCalledWith('active-convo');
expect(logger.warn).toHaveBeenCalledWith(
'[validateMessageReq] Active job lookup failed for active-convo:',
error,
);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' });
expect(next).not.toHaveBeenCalled();
});
it('should not allow unsaved conversation writes through active job ownership', async () => {
const req = {
method: 'POST',
params: { conversationId: 'active-convo' },
body: {},
user: { id: userId },
};
const res = createResponse();
const next = jest.fn();
getConvo.mockResolvedValue(null);
await validateMessageReq(req, res, next);
expect(GenerationJobManager.getJob).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(next).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,178 @@
const { ViolationTypes } = require('librechat-data-provider');
jest.mock('@librechat/api', () => ({
handleError: jest.fn(),
}));
jest.mock('~/server/controllers/ModelController', () => ({
getModelsConfig: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getEndpointsConfig: jest.fn(),
}));
jest.mock('~/cache', () => ({
logViolation: jest.fn(),
}));
const { handleError } = require('@librechat/api');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { getEndpointsConfig } = require('~/server/services/Config');
const { logViolation } = require('~/cache');
const validateModel = require('../validateModel');
describe('validateModel', () => {
let req, res, next;
beforeEach(() => {
jest.clearAllMocks();
req = { body: { model: 'gpt-4o', endpoint: 'openAI' } };
res = {};
next = jest.fn();
getEndpointsConfig.mockResolvedValue({
openAI: { userProvide: false },
});
getModelsConfig.mockResolvedValue({
openAI: ['gpt-4o', 'gpt-4o-mini'],
});
});
describe('format validation', () => {
it('rejects missing model', async () => {
req.body.model = undefined;
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Model not provided' });
expect(next).not.toHaveBeenCalled();
});
it('rejects non-string model', async () => {
req.body.model = 12345;
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Model not provided' });
expect(next).not.toHaveBeenCalled();
});
it('rejects model exceeding 256 chars', async () => {
req.body.model = 'a'.repeat(257);
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Invalid model identifier' });
});
it('rejects model with leading special character', async () => {
req.body.model = '.bad-model';
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Invalid model identifier' });
});
it('rejects model with script injection', async () => {
req.body.model = '<script>alert(1)</script>';
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Invalid model identifier' });
});
it('trims whitespace before validation', async () => {
req.body.model = ' gpt-4o ';
getModelsConfig.mockResolvedValue({ openAI: ['gpt-4o'] });
await validateModel(req, res, next);
expect(next).toHaveBeenCalled();
expect(handleError).not.toHaveBeenCalled();
});
it('rejects model with spaces in the middle', async () => {
req.body.model = 'gpt 4o';
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Invalid model identifier' });
});
it('accepts standard model IDs', async () => {
const validModels = [
'gpt-4o',
'claude-3-5-sonnet-20241022',
'us.amazon.nova-pro-v1:0',
'qwen/qwen3.6-plus-preview:free',
'Meta-Llama-3-8B-Instruct-4bit',
];
for (const model of validModels) {
jest.clearAllMocks();
req.body.model = model;
getEndpointsConfig.mockResolvedValue({ openAI: { userProvide: false } });
getModelsConfig.mockResolvedValue({ openAI: [model] });
next.mockClear();
await validateModel(req, res, next);
expect(next).toHaveBeenCalled();
expect(handleError).not.toHaveBeenCalled();
}
});
});
describe('userProvide early-return', () => {
it('calls next() immediately for userProvide endpoints without checking model list', async () => {
getEndpointsConfig.mockResolvedValue({
openAI: { userProvide: true },
});
req.body.model = 'any-model-from-user-key';
await validateModel(req, res, next);
expect(next).toHaveBeenCalled();
expect(getModelsConfig).not.toHaveBeenCalled();
});
it('does not call getModelsConfig for userProvide endpoints', async () => {
getEndpointsConfig.mockResolvedValue({
CustomEndpoint: { userProvide: true },
});
req.body = { model: 'custom-model', endpoint: 'CustomEndpoint' };
await validateModel(req, res, next);
expect(getModelsConfig).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});
});
describe('system endpoint list validation', () => {
it('rejects a model not in the available list', async () => {
req.body.model = 'not-in-list';
await validateModel(req, res, next);
expect(logViolation).toHaveBeenCalledWith(
req,
res,
ViolationTypes.ILLEGAL_MODEL_REQUEST,
expect.any(Object),
expect.anything(),
);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Illegal model request' });
expect(next).not.toHaveBeenCalled();
});
it('accepts a model in the available list', async () => {
req.body.model = 'gpt-4o';
await validateModel(req, res, next);
expect(next).toHaveBeenCalled();
expect(handleError).not.toHaveBeenCalled();
});
it('rejects when endpoint has no models loaded', async () => {
getModelsConfig.mockResolvedValue({ openAI: undefined });
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Endpoint models not loaded' });
});
it('rejects when modelsConfig is null', async () => {
getModelsConfig.mockResolvedValue(null);
await validateModel(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Models not loaded' });
});
});
});
+322
View File
@@ -0,0 +1,322 @@
const { logger } = require('@librechat/data-schemas');
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
const {
isEnabled,
sendEvent,
countTokens,
GenerationJobManager,
recordCollectedUsage,
sanitizeMessageForTransmit,
buildAbortedResponseMetadata,
} = require('@librechat/api');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const clearPendingReq = require('~/cache/clearPendingReq');
const { sendError } = require('~/server/middleware/error');
const { abortRun } = require('./abortRun');
const db = require('~/models');
/**
* @param {Error | unknown} error
* @returns {boolean}
*/
const isAbortError = (error) => {
const visited = new Set();
let current = error;
while (current && typeof current === 'object' && !visited.has(current)) {
visited.add(current);
const errorName = current.name;
const errorCode = current.code;
const errorMessage = typeof current.message === 'string' ? current.message : '';
if (
errorName === 'AbortError' ||
errorCode === 'ABORT_ERR' ||
errorMessage.includes('AbortError') ||
/(?:operation|request|stream) was aborted/i.test(errorMessage)
) {
return true;
}
current = current.cause;
}
return false;
};
/**
* Spend tokens for all models from collected usage.
* This handles both sequential and parallel agent execution.
*
* IMPORTANT: After spending, this function clears the collectedUsage array
* to prevent double-spending. The array is shared with AgentClient.collectedUsage,
* so clearing it here prevents the finally block from also spending tokens.
*
* @param {Object} params
* @param {string} params.userId - User ID
* @param {string} params.conversationId - Conversation ID
* @param {Array<Object>} params.collectedUsage - Usage metadata from all models
* @param {string} [params.fallbackModel] - Fallback model name if not in usage
* @param {string} [params.messageId] - The response message ID for transaction correlation
*/
async function spendCollectedUsage({
userId,
conversationId,
collectedUsage,
fallbackModel,
messageId,
}) {
if (!collectedUsage || collectedUsage.length === 0) {
return;
}
await recordCollectedUsage(
{
spendTokens: db.spendTokens,
spendStructuredTokens: db.spendStructuredTokens,
pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance },
},
{
user: userId,
conversationId,
collectedUsage,
context: 'abort',
messageId,
model: fallbackModel,
},
);
// Clear the array to prevent double-spending from the AgentClient finally block.
// The collectedUsage array is shared by reference with AgentClient.collectedUsage,
// so clearing it here ensures recordCollectedUsage() sees an empty array and returns early.
collectedUsage.length = 0;
}
/**
* Abort an active message generation.
* Uses GenerationJobManager for all agent requests.
* Since streamId === conversationId, we can directly abort by conversationId.
*/
async function abortMessage(req, res) {
const { abortKey, endpoint } = req.body;
if (isAssistantsEndpoint(endpoint)) {
return await abortRun(req, res);
}
const conversationId = abortKey?.split(':')?.[0] ?? req.user.id;
const userId = req.user.id;
// Use GenerationJobManager to abort the job (streamId === conversationId)
const abortResult = await GenerationJobManager.abortJob(conversationId);
if (!abortResult.success) {
if (!res.headersSent) {
return res.status(204).send({ message: 'Request not found' });
}
return;
}
const { jobData, content, text, collectedUsage } = abortResult;
const completionTokens = await countTokens(text);
const promptTokens = jobData?.promptTokens ?? 0;
const responseMessage = {
messageId: jobData?.responseMessageId,
parentMessageId: jobData?.userMessage?.messageId,
conversationId: jobData?.conversationId,
content,
text,
sender: jobData?.sender ?? 'AI',
finish_reason: 'incomplete',
endpoint: jobData?.endpoint,
iconURL: jobData?.iconURL,
model: jobData?.model,
unfinished: false,
error: false,
isCreatedByUser: false,
tokenCount: completionTokens,
};
/** Persist the usage/cost rollup + context breakdown for the stopped response
* so its branch/total cost and granular rows survive a reload, matching the
* normal completion path. */
const abortMetadata = buildAbortedResponseMetadata(jobData);
if (abortMetadata) {
responseMessage.metadata = abortMetadata;
}
// Spend tokens for ALL models from collectedUsage (handles parallel agents/addedConvo)
if (collectedUsage && collectedUsage.length > 0) {
await spendCollectedUsage({
userId,
conversationId: jobData?.conversationId,
collectedUsage,
fallbackModel: jobData?.model,
messageId: jobData?.responseMessageId,
});
} else {
// Fallback: no collected usage, use text-based token counting for primary model only
await db.spendTokens(
{ ...responseMessage, context: 'incomplete', user: userId },
{ promptTokens, completionTokens },
);
}
await db.saveMessage(
{
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
{ ...responseMessage, user: userId },
{ context: 'api/server/middleware/abortMiddleware.js' },
);
// Get conversation for title
const conversation = await db.getConvo(userId, conversationId);
const finalEvent = {
title: conversation && !conversation.title ? null : conversation?.title || 'New Chat',
final: true,
conversation,
requestMessage: jobData?.userMessage
? sanitizeMessageForTransmit({
messageId: jobData.userMessage.messageId,
parentMessageId: jobData.userMessage.parentMessageId,
conversationId: jobData.userMessage.conversationId,
text: jobData.userMessage.text,
quotes: jobData.userMessage.quotes,
isCreatedByUser: true,
})
: null,
responseMessage,
};
logger.debug(
`[abortMessage] ID: ${userId} | ${req.user.email} | Aborted request: ${conversationId}`,
);
if (res.headersSent) {
return sendEvent(res, finalEvent);
}
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(finalEvent));
}
const handleAbort = function () {
return async function (req, res) {
try {
if (isEnabled(process.env.LIMIT_CONCURRENT_MESSAGES)) {
await clearPendingReq({ userId: req.user.id });
}
return await abortMessage(req, res);
} catch (err) {
logger.error('[abortMessage] handleAbort error', err);
}
};
};
/**
* Handle abort errors during generation.
* @param {ServerResponse} res
* @param {ServerRequest} req
* @param {Error | unknown} error
* @param {Partial<TMessage> & { partialText?: string }} data
* @returns {Promise<void>}
*/
const handleAbortError = async (res, req, error, data) => {
const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data;
if (error?.message?.includes('base64')) {
logger.error('[handleAbortError] Error in base64 encoding', {
...error,
stack: smartTruncateText(error?.stack, 1000),
message: truncateText(error.message, 350),
});
} else if (isAbortError(error)) {
logger.debug('[handleAbortError] AI response aborted by user', {
conversationId,
code: error?.code,
name: error?.name,
message: truncateText(error?.message ?? 'AbortError', 350),
});
} else {
logger.error('[handleAbortError] AI response error; aborting request:', error);
}
if (error?.stack && error.stack.includes('google')) {
logger.warn(
`AI Response error for conversation ${conversationId} likely caused by Google censor/filter`,
);
}
let errorText = error?.message?.includes('"type"')
? error.message
: 'An error occurred while processing your request. Please contact the Admin.';
if (error?.type === ErrorTypes.INVALID_REQUEST) {
errorText = `{"type":"${ErrorTypes.INVALID_REQUEST}"}`;
}
if (error?.message?.includes("does not support 'system'")) {
errorText = `{"type":"${ErrorTypes.NO_SYSTEM_MESSAGES}"}`;
}
/**
* @param {string} partialText
* @returns {Promise<void>}
*/
const respondWithError = async (partialText) => {
const endpointOption = req.body?.endpointOption;
let options = {
sender,
messageId,
conversationId,
parentMessageId,
text: errorText,
user: req.user.id,
spec: endpointOption?.spec,
iconURL: endpointOption?.iconURL,
modelLabel: endpointOption?.modelLabel,
shouldSaveMessage: userMessageId != null,
model: endpointOption?.modelOptions?.model || req.body?.model,
};
if (req.body?.agent_id) {
options.agent_id = req.body.agent_id;
}
if (partialText) {
options = {
...options,
error: false,
unfinished: true,
text: partialText,
};
}
await sendError(req, res, options);
};
if (partialText && partialText.length > 5) {
try {
return await abortMessage(req, res);
} catch (err) {
logger.error('[handleAbortError] error while trying to abort message', err);
return respondWithError(partialText);
}
} else {
return respondWithError();
}
};
module.exports = {
handleAbort,
handleAbortError,
spendCollectedUsage,
};
@@ -0,0 +1,312 @@
/**
* Tests for abortMiddleware - spendCollectedUsage function
*
* This tests the token spending logic for abort scenarios,
* particularly for parallel agents (addedConvo) where multiple
* models need their tokens spent.
*
* spendCollectedUsage delegates to recordCollectedUsage from @librechat/api,
* passing pricing + bulkWriteOps deps, with context: 'abort'.
* After spending, it clears the collectedUsage array to prevent double-spending
* from the AgentClient finally block (which shares the same array reference).
*/
const mockSpendTokens = jest.fn().mockResolvedValue();
const mockSpendStructuredTokens = jest.fn().mockResolvedValue();
const mockRecordCollectedUsage = jest
.fn()
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
const mockGetMultiplier = jest.fn().mockReturnValue(1);
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
},
}));
jest.mock('@librechat/api', () => ({
countTokens: jest.fn().mockResolvedValue(100),
isEnabled: jest.fn().mockReturnValue(false),
sendEvent: jest.fn(),
GenerationJobManager: {
abortJob: jest.fn(),
},
recordCollectedUsage: mockRecordCollectedUsage,
sanitizeMessageForTransmit: jest.fn((msg) => msg),
}));
jest.mock('librechat-data-provider', () => ({
isAssistantsEndpoint: jest.fn().mockReturnValue(false),
ErrorTypes: { INVALID_REQUEST: 'INVALID_REQUEST', NO_SYSTEM_MESSAGES: 'NO_SYSTEM_MESSAGES' },
}));
jest.mock('~/app/clients/prompts', () => ({
truncateText: jest.fn((text) => text),
smartTruncateText: jest.fn((text) => text),
}));
jest.mock('~/cache/clearPendingReq', () => jest.fn().mockResolvedValue());
jest.mock('~/server/middleware/error', () => ({
sendError: jest.fn(),
}));
const mockUpdateBalance = jest.fn().mockResolvedValue({});
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
jest.mock('~/models', () => ({
saveMessage: jest.fn().mockResolvedValue(),
getConvo: jest.fn().mockResolvedValue({ title: 'Test Chat' }),
updateBalance: mockUpdateBalance,
bulkInsertTransactions: mockBulkInsertTransactions,
spendTokens: (...args) => mockSpendTokens(...args),
spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args),
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
}));
jest.mock('./abortRun', () => ({
abortRun: jest.fn(),
}));
const { logger } = require('@librechat/data-schemas');
const { sendError } = require('~/server/middleware/error');
const { handleAbortError, spendCollectedUsage } = require('./abortMiddleware');
const buildAbortRequest = () => ({
body: {
model: 'gpt-4',
},
user: {
id: 'user-123',
},
});
describe('abortMiddleware - spendCollectedUsage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('spendCollectedUsage delegation', () => {
it('should return early if collectedUsage is empty', async () => {
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage: [],
fallbackModel: 'gpt-4',
});
expect(mockRecordCollectedUsage).not.toHaveBeenCalled();
});
it('should return early if collectedUsage is null', async () => {
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage: null,
fallbackModel: 'gpt-4',
});
expect(mockRecordCollectedUsage).not.toHaveBeenCalled();
});
it('should call recordCollectedUsage with abort context and full deps', async () => {
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gpt-4',
messageId: 'msg-123',
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
{
spendTokens: expect.any(Function),
spendStructuredTokens: expect.any(Function),
pricing: {
getMultiplier: mockGetMultiplier,
getCacheMultiplier: mockGetCacheMultiplier,
},
bulkWriteOps: {
insertMany: mockBulkInsertTransactions,
updateBalance: mockUpdateBalance,
},
},
{
user: 'user-123',
conversationId: 'convo-123',
collectedUsage,
context: 'abort',
messageId: 'msg-123',
model: 'gpt-4',
},
);
});
it('should pass context abort for multiple models (parallel agents)', async () => {
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 80, output_tokens: 40, model: 'claude-3' },
{ input_tokens: 120, output_tokens: 60, model: 'gemini-pro' },
];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gpt-4',
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
context: 'abort',
collectedUsage,
}),
);
});
it('should handle real-world parallel agent abort scenario', async () => {
const collectedUsage = [
{ input_tokens: 31596, output_tokens: 151, model: 'gemini-3-flash-preview' },
{ input_tokens: 28000, output_tokens: 120, model: 'gpt-5.2' },
];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gemini-3-flash-preview',
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
user: 'user-123',
conversationId: 'convo-123',
context: 'abort',
model: 'gemini-3-flash-preview',
}),
);
});
/**
* Race condition prevention: after abort middleware spends tokens,
* the collectedUsage array is cleared so AgentClient.recordCollectedUsage()
* (which shares the same array reference) sees an empty array and returns early.
*/
it('should clear collectedUsage array after spending to prevent double-spending', async () => {
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 80, output_tokens: 40, model: 'claude-3' },
];
expect(collectedUsage.length).toBe(2);
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gpt-4',
});
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
expect(collectedUsage.length).toBe(0);
});
it('should await recordCollectedUsage before clearing array', async () => {
let resolved = false;
mockRecordCollectedUsage.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
resolved = true;
return { input_tokens: 100, output_tokens: 50 };
});
const collectedUsage = [
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
{ input_tokens: 80, output_tokens: 40, model: 'claude-3' },
];
await spendCollectedUsage({
userId: 'user-123',
conversationId: 'convo-123',
collectedUsage,
fallbackModel: 'gpt-4',
});
expect(resolved).toBe(true);
expect(collectedUsage.length).toBe(0);
});
});
});
describe('abortMiddleware - handleAbortError', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it.each([
[
'native DOMException AbortError',
new DOMException('The operation was aborted', 'AbortError'),
'AbortError',
],
[
'wrapped AbortError message',
new Error('SSE stream disconnected: AbortError: The operation was aborted'),
'Error',
],
[
'cause-nested AbortError',
new Error('Request failed', {
cause: new DOMException('The operation was aborted', 'AbortError'),
}),
'Error',
],
])('logs a %s as a debug event instead of an error', async (_label, error, name) => {
await handleAbortError({}, buildAbortRequest(), error, {
sender: 'AI',
conversationId: 'convo-123',
messageId: 'message-123',
parentMessageId: 'parent-123',
userMessageId: 'user-message-123',
});
expect(logger.error).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith('[handleAbortError] AI response aborted by user', {
conversationId: 'convo-123',
code: error.code,
name,
message: error.message,
});
expect(sendError).toHaveBeenCalledTimes(1);
});
it('keeps unexpected generation errors classified as errors', async () => {
const error = new Error('Provider failed');
await handleAbortError({}, buildAbortRequest(), error, {
sender: 'AI',
conversationId: 'convo-123',
messageId: 'message-123',
parentMessageId: 'parent-123',
userMessageId: 'user-message-123',
});
expect(logger.error).toHaveBeenCalledWith(
'[handleAbortError] AI response error; aborting request:',
error,
);
expect(logger.debug).not.toHaveBeenCalled();
expect(sendError).toHaveBeenCalledTimes(1);
});
});
+104
View File
@@ -0,0 +1,104 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, RunStatus, isUUID } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { checkMessageGaps, recordUsage } = require('~/server/services/Threads');
const { deleteMessages, getConvo } = require('~/models');
const getLogStores = require('~/cache/getLogStores');
const three_minutes = 1000 * 60 * 3;
async function abortRun(req, res) {
res.setHeader('Content-Type', 'application/json');
const { abortKey, endpoint } = req.body;
const [conversationId, latestMessageId] = abortKey.split(':');
const conversation = await getConvo(req.user.id, conversationId);
if (conversation?.model) {
req.body = req.body || {}; // Express 5: ensure req.body exists
req.body.model = conversation.model;
}
if (!isUUID.safeParse(conversationId).success) {
logger.error('[abortRun] Invalid conversationId', { conversationId });
return res.status(400).send({ message: 'Invalid conversationId' });
}
const cacheKey = `${req.user.id}:${conversationId}`;
const cache = getLogStores(CacheKeys.ABORT_KEYS);
const runValues = await cache.get(cacheKey);
if (!runValues) {
logger.warn('[abortRun] Run not found in cache', { cacheKey });
return res.status(204).send({ message: 'Run not found' });
}
const [thread_id, run_id] = runValues.split(':');
if (!run_id) {
logger.warn("[abortRun] Couldn't find run for cancel request", { thread_id });
return res.status(204).send({ message: 'Run not found' });
} else if (run_id === 'cancelled') {
logger.warn('[abortRun] Run already cancelled', { thread_id });
return res.status(204).send({ message: 'Run already cancelled' });
}
let runMessages = [];
/** @type {{ openai: OpenAI }} */
const { openai } = await initializeClient({ req, res });
try {
await cache.set(cacheKey, 'cancelled', three_minutes);
const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
logger.debug('[abortRun] Cancelled run:', cancelledRun);
} catch (error) {
logger.error('[abortRun] Error cancelling run', error);
if (
error?.message?.includes(RunStatus.CANCELLED) ||
error?.message?.includes(RunStatus.CANCELLING)
) {
return res.end();
}
}
try {
const run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
await recordUsage({
...run.usage,
model: run.model,
user: req.user.id,
conversationId,
});
} catch (error) {
logger.error('[abortRun] Error fetching or processing run', error);
}
/* TODO: a reconciling strategy between the existing intermediate message would be more optimal than deleting it */
await deleteMessages({
user: req.user.id,
unfinished: true,
conversationId,
});
runMessages = await checkMessageGaps({
openai,
run_id,
endpoint,
thread_id,
conversationId,
latestMessageId,
});
const finalEvent = {
final: true,
conversation,
runMessages,
};
if (res.headersSent && finalEvent) {
return sendEvent(res, finalEvent);
}
res.json(finalEvent);
}
module.exports = {
abortRun,
};
@@ -0,0 +1,193 @@
const { logger } = require('@librechat/data-schemas');
const {
Constants,
Permissions,
ResourceType,
SystemRoles,
PermissionTypes,
isAgentsEndpoint,
isEphemeralAgentId,
} = require('librechat-data-provider');
const { checkPermission } = require('~/server/services/PermissionService');
const { canAccessResource } = require('./canAccessResource');
const db = require('~/models');
const { getRoleByName, getAgent } = db;
/**
* Resolves custom agent ID (e.g., "agent_abc123") to a MongoDB document.
* @param {string} agentCustomId - Custom agent ID from request body
* @returns {Promise<Object|null>} Agent document with _id field, or null if ephemeral/not found
*/
const resolveAgentIdFromBody = async (agentCustomId) => {
if (isEphemeralAgentId(agentCustomId)) {
return null;
}
return getAgent({ id: agentCustomId });
};
/**
* Creates a `canAccessResource` middleware for the given agent ID
* and chains to the provided continuation on success.
*
* @param {string} agentId - The agent's custom string ID (e.g., "agent_abc123")
* @param {number} requiredPermission - Permission bit(s) required
* @param {import('express').Request} req
* @param {import('express').Response} res - Written on deny; continuation called on allow
* @param {Function} continuation - Called when the permission check passes
* @returns {Promise<void>}
*/
const checkAgentResourceAccess = (agentId, requiredPermission, req, res, continuation) => {
const middleware = canAccessResource({
resourceType: ResourceType.AGENT,
requiredPermission,
resourceIdParam: 'agent_id',
idResolver: () => resolveAgentIdFromBody(agentId),
});
const tempReq = {
...req,
params: { ...req.params, agent_id: agentId },
};
return middleware(tempReq, res, continuation);
};
/**
* Middleware factory that validates MULTI_CONVO:USE role permission and, when
* addedConvo.agent_id is a non-ephemeral agent, the same resource-level permission
* required for the primary agent (`requiredPermission`). Caches the resolved agent
* document on `req.resolvedAddedAgent` to avoid a duplicate DB fetch in `loadAddedAgent`.
*
* @param {number} requiredPermission - Permission bit(s) to check on the added agent resource
* @returns {(req: import('express').Request, res: import('express').Response, next: Function) => Promise<void>}
*/
const checkAddedConvoAccess = (requiredPermission) => async (req, res, next) => {
const addedConvo = req.body?.addedConvo;
if (!addedConvo || typeof addedConvo !== 'object' || Array.isArray(addedConvo)) {
return next();
}
try {
if (!req.user?.role) {
return res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions for multi-conversation',
});
}
if (req.user.role !== SystemRoles.ADMIN) {
const role = await getRoleByName(req.user.role);
const hasMultiConvo = role?.permissions?.[PermissionTypes.MULTI_CONVO]?.[Permissions.USE];
if (!hasMultiConvo) {
return res.status(403).json({
error: 'Forbidden',
message: 'Multi-conversation feature is not enabled',
});
}
}
const addedAgentId = addedConvo.agent_id;
if (!addedAgentId || typeof addedAgentId !== 'string' || isEphemeralAgentId(addedAgentId)) {
return next();
}
if (req.user.role === SystemRoles.ADMIN) {
return next();
}
const agent = await resolveAgentIdFromBody(addedAgentId);
if (!agent) {
return res.status(404).json({
error: 'Not Found',
message: `${ResourceType.AGENT} not found`,
});
}
const hasPermission = await checkPermission({
userId: req.user.id,
role: req.user.role,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
requiredPermission,
});
if (!hasPermission) {
return res.status(403).json({
error: 'Forbidden',
message: `Insufficient permissions to access this ${ResourceType.AGENT}`,
});
}
req.resolvedAddedAgent = agent;
return next();
} catch (error) {
logger.error('Failed to validate addedConvo access permissions', error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to validate addedConvo access permissions',
});
}
};
/**
* Middleware factory that checks agent access permissions from request body.
* Validates both the primary agent_id and, when present, addedConvo.agent_id
* (which also requires MULTI_CONVO:USE role permission).
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @returns {Function} Express middleware function
*
* @example
* router.post('/chat',
* canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW }),
* buildEndpointOption,
* chatController
* );
*/
const canAccessAgentFromBody = (options) => {
const { requiredPermission } = options;
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessAgentFromBody: requiredPermission is required and must be a number');
}
const addedConvoMiddleware = checkAddedConvoAccess(requiredPermission);
return async (req, res, next) => {
try {
const { endpoint, agent_id } = req.body;
let agentId = agent_id;
if (!isAgentsEndpoint(endpoint)) {
agentId = Constants.EPHEMERAL_AGENT_ID;
}
if (!agentId) {
return res.status(400).json({
error: 'Bad Request',
message: 'agent_id is required in request body',
});
}
const afterPrimaryCheck = () => addedConvoMiddleware(req, res, next);
if (isEphemeralAgentId(agentId)) {
return afterPrimaryCheck();
}
return checkAgentResourceAccess(agentId, requiredPermission, req, res, afterPrimaryCheck);
} catch (error) {
logger.error('Failed to validate agent access permissions', error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to validate agent access permissions',
});
}
};
};
module.exports = {
canAccessAgentFromBody,
};
@@ -0,0 +1,509 @@
const mongoose = require('mongoose');
const {
ResourceType,
SystemRoles,
PrincipalType,
PrincipalModel,
} = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
const { User, Role, AclEntry } = require('~/db/models');
const { createAgent } = require('~/models');
describe('canAccessAgentFromBody middleware', () => {
let mongoServer;
let req, res, next;
let testUser, otherUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
await Role.create({
name: 'test-role',
permissions: {
AGENTS: { USE: true, CREATE: true, SHARE: true },
MULTI_CONVO: { USE: true },
},
});
await Role.create({
name: 'no-multi-convo',
permissions: {
AGENTS: { USE: true, CREATE: true, SHARE: true },
MULTI_CONVO: { USE: false },
},
});
await Role.create({
name: SystemRoles.ADMIN,
permissions: {
AGENTS: { USE: true, CREATE: true, SHARE: true },
MULTI_CONVO: { USE: true },
},
});
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});
otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
req = {
user: { id: testUser._id, role: testUser.role },
params: {},
body: {
endpoint: 'agents',
agent_id: 'ephemeral_primary',
},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('middleware factory', () => {
test('throws if requiredPermission is missing', () => {
expect(() => canAccessAgentFromBody({})).toThrow(
'canAccessAgentFromBody: requiredPermission is required and must be a number',
);
});
test('throws if requiredPermission is not a number', () => {
expect(() => canAccessAgentFromBody({ requiredPermission: '1' })).toThrow(
'canAccessAgentFromBody: requiredPermission is required and must be a number',
);
});
test('returns a middleware function', () => {
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3);
});
});
describe('primary agent checks', () => {
test('returns 400 when agent_id is missing on agents endpoint', async () => {
req.body.agent_id = undefined;
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
});
test('proceeds for ephemeral primary agent without addedConvo', async () => {
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('proceeds for non-agents endpoint (ephemeral fallback)', async () => {
req.body.endpoint = 'openAI';
req.body.agent_id = undefined;
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('addedConvo — absent or invalid shape', () => {
test('calls next when addedConvo is absent', async () => {
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('calls next when addedConvo is a string', async () => {
req.body.addedConvo = 'not-an-object';
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('calls next when addedConvo is an array', async () => {
req.body.addedConvo = [{ agent_id: 'agent_something' }];
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('addedConvo — MULTI_CONVO permission gate', () => {
test('returns 403 when user lacks MULTI_CONVO:USE', async () => {
req.user.role = 'no-multi-convo';
req.body.addedConvo = { agent_id: 'agent_x', endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Multi-conversation feature is not enabled' }),
);
});
test('returns 403 when user.role is missing', async () => {
req.user = { id: testUser._id };
req.body.addedConvo = { agent_id: 'agent_x', endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
test('ADMIN bypasses MULTI_CONVO check', async () => {
req.user.role = SystemRoles.ADMIN;
req.body.addedConvo = { agent_id: 'ephemeral_x', endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('addedConvo — agent_id shape validation', () => {
test('calls next when agent_id is ephemeral', async () => {
req.body.addedConvo = { agent_id: 'ephemeral_xyz', endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('calls next when agent_id is absent', async () => {
req.body.addedConvo = { endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('calls next when agent_id is not a string (object injection)', async () => {
req.body.addedConvo = { agent_id: { $gt: '' }, endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('addedConvo — agent resource ACL (IDOR prevention)', () => {
let addedAgent;
beforeEach(async () => {
addedAgent = await createAgent({
id: `agent_added_${Date.now()}`,
name: 'Private Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: otherUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: addedAgent._id,
permBits: 15,
grantedBy: otherUser._id,
});
});
test('returns 403 when requester has no ACL for the added agent', async () => {
req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Insufficient permissions to access this agent',
}),
);
});
test('returns 404 when added agent does not exist', async () => {
req.body.addedConvo = {
agent_id: 'agent_nonexistent_999',
endpoint: 'agents',
model: 'gpt-4',
};
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
});
test('proceeds when requester has ACL for the added agent', async () => {
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: addedAgent._id,
permBits: 1,
grantedBy: otherUser._id,
});
req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('denies when ACL permission bits are insufficient', async () => {
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: addedAgent._id,
permBits: 1,
grantedBy: otherUser._id,
});
req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 2 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
test('caches resolved agent on req.resolvedAddedAgent', async () => {
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: addedAgent._id,
permBits: 1,
grantedBy: otherUser._id,
});
req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.resolvedAddedAgent).toBeDefined();
expect(req.resolvedAddedAgent._id.toString()).toBe(addedAgent._id.toString());
});
test('ADMIN bypasses agent resource ACL for addedConvo', async () => {
req.user.role = SystemRoles.ADMIN;
req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(req.resolvedAddedAgent).toBeUndefined();
});
});
describe('end-to-end: primary real agent + addedConvo real agent', () => {
let primaryAgent, addedAgent;
beforeEach(async () => {
primaryAgent = await createAgent({
id: `agent_primary_${Date.now()}`,
name: 'Primary Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: primaryAgent._id,
permBits: 15,
grantedBy: testUser._id,
});
addedAgent = await createAgent({
id: `agent_added_${Date.now()}`,
name: 'Added Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: otherUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: addedAgent._id,
permBits: 15,
grantedBy: otherUser._id,
});
req.body.agent_id = primaryAgent.id;
});
test('both checks pass when user has ACL for both agents', async () => {
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: addedAgent._id,
permBits: 1,
grantedBy: otherUser._id,
});
req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(req.resolvedAddedAgent).toBeDefined();
});
test('primary passes but addedConvo denied → 403', async () => {
req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
test('primary denied → 403 without reaching addedConvo check', async () => {
const foreignAgent = await createAgent({
id: `agent_foreign_${Date.now()}`,
name: 'Foreign Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: otherUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: foreignAgent._id,
permBits: 15,
grantedBy: otherUser._id,
});
req.body.agent_id = foreignAgent.id;
req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
describe('ephemeral primary + real addedConvo agent', () => {
let addedAgent;
beforeEach(async () => {
addedAgent = await createAgent({
id: `agent_added_${Date.now()}`,
name: 'Added Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: otherUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: addedAgent._id,
permBits: 15,
grantedBy: otherUser._id,
});
});
test('runs full addedConvo ACL check even when primary is ephemeral', async () => {
req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
test('proceeds when user has ACL for added agent (ephemeral primary)', async () => {
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: addedAgent._id,
permBits: 1,
grantedBy: otherUser._id,
});
req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' };
const middleware = canAccessAgentFromBody({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,59 @@
const { ResourceType } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { getAgent } = require('~/models');
/**
* Agent ID resolver function
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
*
* @param {string} agentCustomId - Custom agent ID from route parameter
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
*/
const resolveAgentId = async (agentCustomId) => {
return await getAgent({ id: agentCustomId });
};
/**
* Agent-specific middleware factory that creates middleware to check agent access permissions.
* This middleware extends the generic canAccessResource to handle agent custom ID resolution.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='id'] - The name of the route parameter containing the agent custom ID
* @returns {Function} Express middleware function
*
* @example
* // Basic usage for viewing agents
* router.get('/agents/:id',
* canAccessAgentResource({ requiredPermission: 1 }),
* getAgent
* );
*
* @example
* // Custom resource ID parameter and edit permission
* router.patch('/agents/:agent_id',
* canAccessAgentResource({
* requiredPermission: 2,
* resourceIdParam: 'agent_id'
* }),
* updateAgent
* );
*/
const canAccessAgentResource = (options) => {
const { requiredPermission, resourceIdParam = 'id' } = options;
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessAgentResource: requiredPermission is required and must be a number');
}
return canAccessResource({
resourceType: ResourceType.AGENT,
requiredPermission,
resourceIdParam,
idResolver: resolveAgentId,
});
};
module.exports = {
canAccessAgentResource,
};
@@ -0,0 +1,385 @@
const mongoose = require('mongoose');
const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { canAccessAgentResource } = require('./canAccessAgentResource');
const { User, Role, AclEntry } = require('~/db/models');
const { createAgent } = require('~/models');
describe('canAccessAgentResource middleware', () => {
let mongoServer;
let req, res, next;
let testUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
await Role.create({
name: 'test-role',
permissions: {
AGENTS: {
USE: true,
CREATE: true,
SHARE: true,
},
},
});
// Create a test user
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});
req = {
user: { id: testUser._id, role: testUser.role },
params: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('middleware factory', () => {
test('should throw error if requiredPermission is not provided', () => {
expect(() => canAccessAgentResource({})).toThrow(
'canAccessAgentResource: requiredPermission is required and must be a number',
);
});
test('should throw error if requiredPermission is not a number', () => {
expect(() => canAccessAgentResource({ requiredPermission: '1' })).toThrow(
'canAccessAgentResource: requiredPermission is required and must be a number',
);
});
test('should create middleware with default resourceIdParam', () => {
const middleware = canAccessAgentResource({ requiredPermission: 1 });
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3); // Express middleware signature
});
test('should create middleware with custom resourceIdParam', () => {
const middleware = canAccessAgentResource({
requiredPermission: 2,
resourceIdParam: 'agent_id',
});
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3);
});
});
describe('permission checking with real agents', () => {
test('should allow access when user is the agent author', async () => {
// Create an agent owned by the test user
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry for the author (owner permissions)
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when user is not the author and has no ACL entry', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Other User Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry for the other user (owner)
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: otherUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this agent',
});
});
test('should allow access when user has ACL entry with sufficient permissions', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other2@example.com',
name: 'Other User 2',
username: 'otheruser2',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Shared Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry granting view permission to test user
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 1, // VIEW permission
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when ACL permissions are insufficient', async () => {
// Create an agent owned by a different user
const otherUser = await User.create({
email: 'other3@example.com',
name: 'Other User 3',
username: 'otheruser3',
role: 'test-role',
});
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Limited Access Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
});
// Create ACL entry granting only view permission
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 1, // VIEW permission only
grantedBy: otherUser._id,
});
req.params.id = agent.id;
const middleware = canAccessAgentResource({ requiredPermission: 2 }); // EDIT permission required
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this agent',
});
});
test('should handle non-existent agent', async () => {
req.params.id = 'agent_nonexistent';
const middleware = canAccessAgentResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'agent not found',
});
});
test('should use custom resourceIdParam', async () => {
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Custom Param Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.agent_id = agent.id; // Using custom param name
const middleware = canAccessAgentResource({
requiredPermission: 1,
resourceIdParam: 'agent_id',
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('permission levels', () => {
let agent;
beforeEach(async () => {
agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Permission Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
});
// Create ACL entry with all permissions for the owner
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.id = agent.id;
});
test('should support view permission (1)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support edit permission (2)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 2 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support delete permission (4)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 4 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support share permission (8)', async () => {
const middleware = canAccessAgentResource({ requiredPermission: 8 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support combined permissions', async () => {
const viewAndEdit = 1 | 2; // 3
const middleware = canAccessAgentResource({ requiredPermission: viewAndEdit });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('integration with agent operations', () => {
test('should work with agent CRUD operations', async () => {
const agentId = `agent_${Date.now()}`;
// Create agent
const agent = await createAgent({
id: agentId,
name: 'Integration Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
description: 'Testing integration',
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.id = agentId;
// Test view access
const viewMiddleware = canAccessAgentResource({ requiredPermission: 1 });
await viewMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
jest.clearAllMocks();
// Update the agent
const { updateAgent } = require('~/models');
await updateAgent({ id: agentId }, { description: 'Updated description' });
// Test edit access
const editMiddleware = canAccessAgentResource({ requiredPermission: 2 });
await editMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,61 @@
const { ResourceType } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { findMCPServerByServerName } = require('~/models');
/**
* MCP Server name resolver function
* Resolves MCP server name (e.g., "my-mcp-server") to MongoDB ObjectId
*
* @param {string} serverName - Server name from route parameter
* @returns {Promise<Object|null>} MCP server document with _id field, or null if not found
*/
const resolveMCPServerName = async (serverName) => {
return await findMCPServerByServerName(serverName);
};
/**
* MCP Server-specific middleware factory that creates middleware to check MCP server access permissions.
* This middleware extends the generic canAccessResource to handle MCP server custom ID resolution.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='serverName'] - The name of the route parameter containing the MCP server custom ID
* @returns {Function} Express middleware function
*
* @example
* // Basic usage for viewing MCP servers
* router.get('/servers/:serverName',
* canAccessMCPServerResource({ requiredPermission: 1 }),
* getMCPServer
* );
*
* @example
* // Custom resource ID parameter and edit permission
* router.patch('/servers/:id',
* canAccessMCPServerResource({
* requiredPermission: 2,
* resourceIdParam: 'id'
* }),
* updateMCPServer
* );
*/
const canAccessMCPServerResource = (options) => {
const { requiredPermission, resourceIdParam = 'serverName' } = options;
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error(
'canAccessMCPServerResource: requiredPermission is required and must be a number',
);
}
return canAccessResource({
resourceType: ResourceType.MCPSERVER,
requiredPermission,
resourceIdParam,
idResolver: resolveMCPServerName,
});
};
module.exports = {
canAccessMCPServerResource,
};
@@ -0,0 +1,636 @@
const mongoose = require('mongoose');
const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
const { SystemCapabilities } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { canAccessMCPServerResource } = require('./canAccessMCPServerResource');
const { User, Role, AclEntry, SystemGrant } = require('~/db/models');
const { createMCPServer } = require('~/models');
describe('canAccessMCPServerResource middleware', () => {
let mongoServer;
let req, res, next;
let testUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
await Role.create({
name: 'test-role',
permissions: {
MCP_SERVERS: {
USE: true,
CREATE: true,
SHARE: true,
},
},
});
// Create a test user
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});
req = {
user: { id: testUser._id, role: testUser.role },
params: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('middleware factory', () => {
test('should throw error if requiredPermission is not provided', () => {
expect(() => canAccessMCPServerResource({})).toThrow(
'canAccessMCPServerResource: requiredPermission is required and must be a number',
);
});
test('should throw error if requiredPermission is not a number', () => {
expect(() => canAccessMCPServerResource({ requiredPermission: '1' })).toThrow(
'canAccessMCPServerResource: requiredPermission is required and must be a number',
);
});
test('should throw error if requiredPermission is null', () => {
expect(() => canAccessMCPServerResource({ requiredPermission: null })).toThrow(
'canAccessMCPServerResource: requiredPermission is required and must be a number',
);
});
test('should create middleware with default resourceIdParam (serverName)', () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3); // Express middleware signature
});
test('should create middleware with custom resourceIdParam', () => {
const middleware = canAccessMCPServerResource({
requiredPermission: 2,
resourceIdParam: 'mcpId',
});
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3);
});
});
describe('permission checking with real MCP servers', () => {
test('should allow access when user is the MCP server author', async () => {
// Create an MCP server owned by the test user
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Test MCP Server',
},
author: testUser._id,
});
// Create ACL entry for the author (owner permissions)
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when user is not the author and has no ACL entry', async () => {
// Create an MCP server owned by a different user
const otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Other User MCP Server',
},
author: otherUser._id,
});
// Create ACL entry for the other user (owner)
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: otherUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions
grantedBy: otherUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this mcpServer',
});
});
test('should allow access when user has ACL entry with sufficient permissions', async () => {
// Create an MCP server owned by a different user
const otherUser = await User.create({
email: 'other2@example.com',
name: 'Other User 2',
username: 'otheruser2',
role: 'test-role',
});
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Shared MCP Server',
},
author: otherUser._id,
});
// Create ACL entry granting view permission to test user
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 1, // VIEW permission
grantedBy: otherUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 }); // VIEW permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when ACL permissions are insufficient', async () => {
// Create an MCP server owned by a different user
const otherUser = await User.create({
email: 'other3@example.com',
name: 'Other User 3',
username: 'otheruser3',
role: 'test-role',
});
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Limited Access MCP Server',
},
author: otherUser._id,
});
// Create ACL entry granting only view permission
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 1, // VIEW permission only
grantedBy: otherUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 2 }); // EDIT permission required
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this mcpServer',
});
});
test('should handle non-existent MCP server', async () => {
req.params.serverName = 'non-existent-mcp-server';
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'mcpServer not found',
});
});
test('should use custom resourceIdParam', async () => {
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Custom Param MCP Server',
},
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.mcpId = mcpServer.serverName; // Using custom param name
const middleware = canAccessMCPServerResource({
requiredPermission: 1,
resourceIdParam: 'mcpId',
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('permission levels', () => {
let mcpServer;
beforeEach(async () => {
mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Permission Test MCP Server',
},
author: testUser._id,
});
// Create ACL entry with all permissions for the owner
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions (1+2+4+8)
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
});
test('should support view permission (1)', async () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support edit permission (2)', async () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 2 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support delete permission (4)', async () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 4 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support share permission (8)', async () => {
const middleware = canAccessMCPServerResource({ requiredPermission: 8 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should support combined permissions', async () => {
const viewAndEdit = 1 | 2; // 3
const middleware = canAccessMCPServerResource({ requiredPermission: viewAndEdit });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('integration with resolveMCPServerId', () => {
test('should resolve serverName to MongoDB ObjectId correctly', async () => {
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Integration Test MCP Server',
},
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
// Verify that req.resourceAccess was set correctly
expect(req.resourceAccess).toBeDefined();
expect(req.resourceAccess.resourceType).toBe(ResourceType.MCPSERVER);
expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer._id.toString());
expect(req.resourceAccess.customResourceId).toBe(mcpServer.serverName);
});
test('should work with MCP server CRUD operations', async () => {
// Create MCP server
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'CRUD Test MCP Server',
description: 'Testing integration',
},
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15, // All permissions
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
// Test view access
const viewMiddleware = canAccessMCPServerResource({ requiredPermission: 1 });
await viewMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
jest.clearAllMocks();
// Update the MCP server
const { updateMCPServer } = require('~/models');
await updateMCPServer(mcpServer.serverName, {
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'CRUD Test MCP Server',
description: 'Updated description',
},
});
// Test edit access
const editMiddleware = canAccessMCPServerResource({ requiredPermission: 2 });
await editMiddleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle stdio type MCP server', async () => {
const mcpServer = await createMCPServer({
config: {
type: 'stdio',
command: 'node',
args: ['server.js'],
title: 'Stdio MCP Server',
},
author: testUser._id,
});
// Create ACL entry for the author
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer._id,
permBits: 15,
grantedBy: testUser._id,
});
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.resourceAccess.resourceInfo.config.type).toBe('stdio');
});
});
describe('authentication and authorization edge cases', () => {
test('should return 400 when serverName parameter is missing', async () => {
// Don't set req.params.serverName
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: 'Bad Request',
message: 'serverName is required',
});
});
test('should return 401 when user is not authenticated', async () => {
req.user = null;
req.params.serverName = 'some-server';
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
});
test('should return 401 when user id is missing', async () => {
req.user = { role: 'test-role' }; // No id
req.params.serverName = 'some-server';
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
});
test('should allow users with MANAGE_MCP_SERVERS capability to bypass permission checks', async () => {
const { SystemRoles } = require('librechat-data-provider');
// Create an MCP server owned by another user
const otherUser = await User.create({
email: 'owner@example.com',
name: 'Owner User',
username: 'owneruser',
role: 'test-role',
});
const mcpServer = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp',
title: 'Admin Test MCP Server',
},
author: otherUser._id,
});
// Seed MANAGE_MCP_SERVERS capability for the ADMIN role
await SystemGrant.create({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.ADMIN,
capability: SystemCapabilities.MANAGE_MCP_SERVERS,
grantedAt: new Date(),
});
// Set user as admin
req.user = { id: testUser._id, role: SystemRoles.ADMIN };
req.params.serverName = mcpServer.serverName;
const middleware = canAccessMCPServerResource({ requiredPermission: 4 }); // DELETE permission
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
describe('error handling', () => {
test('should handle server returning null gracefully (treated as not found)', async () => {
// When an MCP server is not found, findMCPServerByServerName returns null
// which the middleware correctly handles as a 404
req.params.serverName = 'definitely-non-existent-server';
const middleware = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'mcpServer not found',
});
});
});
describe('multiple servers with same title', () => {
test('should handle MCP servers with auto-generated suffixes', async () => {
// Create multiple servers with the same title (will have different serverNames)
const mcpServer1 = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp1',
title: 'Duplicate Title',
},
author: testUser._id,
});
const mcpServer2 = await createMCPServer({
config: {
type: 'sse',
url: 'https://example.com/mcp2',
title: 'Duplicate Title',
},
author: testUser._id,
});
// Create ACL entries for both
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer1._id,
permBits: 15,
grantedBy: testUser._id,
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.MCPSERVER,
resourceId: mcpServer2._id,
permBits: 15,
grantedBy: testUser._id,
});
// Verify they have different serverNames
expect(mcpServer1.serverName).toBe('duplicate-title');
expect(mcpServer2.serverName).toBe('duplicate-title-2');
// Test access to first server
req.params.serverName = mcpServer1.serverName;
const middleware1 = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware1(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer1._id.toString());
jest.clearAllMocks();
// Test access to second server
req.params.serverName = mcpServer2.serverName;
const middleware2 = canAccessMCPServerResource({ requiredPermission: 1 });
await middleware2(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.resourceAccess.resourceId.toString()).toBe(mcpServer2._id.toString());
});
});
});
@@ -0,0 +1,61 @@
const { ResourceType } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { getPromptGroup } = require('~/models');
/**
* PromptGroup ID resolver function
* Resolves promptGroup ID to MongoDB ObjectId
*
* @param {string} groupId - PromptGroup ID from route parameter
* @returns {Promise<Object|null>} PromptGroup document with _id field, or null if not found
*/
const resolvePromptGroupId = async (groupId) => {
return await getPromptGroup({ _id: groupId });
};
/**
* PromptGroup-specific middleware factory that creates middleware to check promptGroup access permissions.
* This middleware extends the generic canAccessResource to handle promptGroup ID resolution.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='groupId'] - The name of the route parameter containing the promptGroup ID
* @returns {Function} Express middleware function
*
* @example
* // Basic usage for viewing promptGroups
* router.get('/prompts/groups/:groupId',
* canAccessPromptGroupResource({ requiredPermission: 1 }),
* getPromptGroup
* );
*
* @example
* // Custom resource ID parameter and edit permission
* router.patch('/prompts/groups/:id',
* canAccessPromptGroupResource({
* requiredPermission: 2,
* resourceIdParam: 'id'
* }),
* updatePromptGroup
* );
*/
const canAccessPromptGroupResource = (options) => {
const { requiredPermission, resourceIdParam = 'groupId' } = options;
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error(
'canAccessPromptGroupResource: requiredPermission is required and must be a number',
);
}
return canAccessResource({
resourceType: ResourceType.PROMPTGROUP,
requiredPermission,
resourceIdParam,
idResolver: resolvePromptGroupId,
});
};
module.exports = {
canAccessPromptGroupResource,
};
@@ -0,0 +1,55 @@
const { ResourceType } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { getPrompt } = require('~/models');
/**
* Prompt to PromptGroup ID resolver function
* Resolves prompt ID to its parent promptGroup ID
*
* @param {string} promptId - Prompt ID from route parameter
* @returns {Promise<Object|null>} Object with promptGroup's _id field, or null if not found
*/
const resolvePromptToGroupId = async (promptId) => {
const prompt = await getPrompt({ _id: promptId });
if (!prompt || !prompt.groupId) {
return null;
}
// Return an object with _id that matches the promptGroup ID
return { _id: prompt.groupId };
};
/**
* Middleware factory that checks promptGroup permissions when accessing individual prompts.
* This allows permission management at the promptGroup level while still supporting
* individual prompt access patterns.
*
* @param {Object} options - Configuration options
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='promptId'] - The name of the route parameter containing the prompt ID
* @returns {Function} Express middleware function
*
* @example
* // Check promptGroup permissions when viewing a prompt
* router.get('/prompts/:promptId',
* canAccessPromptViaGroup({ requiredPermission: 1 }),
* getPrompt
* );
*/
const canAccessPromptViaGroup = (options) => {
const { requiredPermission, resourceIdParam = 'promptId' } = options;
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessPromptViaGroup: requiredPermission is required and must be a number');
}
return canAccessResource({
resourceType: ResourceType.PROMPTGROUP,
requiredPermission,
resourceIdParam,
idResolver: resolvePromptToGroupId,
});
};
module.exports = {
canAccessPromptViaGroup,
};
@@ -0,0 +1,167 @@
const { logger, ResourceCapabilityMap } = require('@librechat/data-schemas');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { checkPermission } = require('~/server/services/PermissionService');
/**
* Generic base middleware factory that creates middleware to check resource access permissions.
* This middleware expects MongoDB ObjectIds as resource identifiers for ACL permission checks.
*
* @param {Object} options - Configuration options
* @param {string} options.resourceType - The type of resource (e.g., 'agent', 'file', 'project')
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='resourceId'] - The name of the route parameter containing the resource ID
* @param {Function} [options.idResolver] - Optional function to resolve custom IDs to ObjectIds
* @returns {Function} Express middleware function
*
* @example
* // Direct usage with ObjectId (for resources that use MongoDB ObjectId in routes)
* router.get('/prompts/:promptId',
* canAccessResource({ resourceType: 'prompt', requiredPermission: 1 }),
* getPrompt
* );
*
* @example
* // Usage with custom ID resolver (for resources that use custom string IDs)
* router.get('/agents/:id',
* canAccessResource({
* resourceType: 'agent',
* requiredPermission: 1,
* resourceIdParam: 'id',
* idResolver: (customId) => resolveAgentId(customId)
* }),
* getAgent
* );
*/
const canAccessResource = (options) => {
const {
resourceType,
requiredPermission,
resourceIdParam = 'resourceId',
idResolver = null,
} = options;
if (!resourceType || typeof resourceType !== 'string') {
throw new Error('canAccessResource: resourceType is required and must be a string');
}
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessResource: requiredPermission is required and must be a number');
}
return async (req, res, next) => {
try {
// Extract resource ID from route parameters
const rawResourceId = req.params[resourceIdParam];
if (!rawResourceId) {
logger.warn(`[canAccessResource] Missing ${resourceIdParam} in route parameters`);
return res.status(400).json({
error: 'Bad Request',
message: `${resourceIdParam} is required`,
});
}
// Check if user is authenticated
if (!req.user || !req.user.id) {
logger.warn(
`[canAccessResource] Unauthenticated request for ${resourceType} ${rawResourceId}`,
);
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
const cap = ResourceCapabilityMap[resourceType];
let hasCap = false;
try {
hasCap = cap != null && (await hasCapability(req.user, cap));
} catch (err) {
logger.warn(`[canAccessResource] capability check failed, denying bypass: ${err.message}`);
}
if (hasCap) {
logger.debug(
`[canAccessResource] ${cap} bypass for user ${req.user.id} on ${resourceType} ${rawResourceId}`,
);
return next();
}
const userId = req.user.id;
let resourceId = rawResourceId;
let resourceInfo = null;
// Resolve custom ID to ObjectId if resolver is provided
if (idResolver) {
logger.debug(
`[canAccessResource] Resolving ${resourceType} custom ID ${rawResourceId} to ObjectId`,
);
const resolutionResult = await idResolver(rawResourceId);
if (!resolutionResult) {
logger.warn(`[canAccessResource] ${resourceType} not found: ${rawResourceId}`);
return res.status(404).json({
error: 'Not Found',
message: `${resourceType} not found`,
});
}
// Handle different resolver return formats
if (typeof resolutionResult === 'string' || resolutionResult._id) {
resourceId = resolutionResult._id || resolutionResult;
resourceInfo = typeof resolutionResult === 'object' ? resolutionResult : null;
} else {
resourceId = resolutionResult;
}
logger.debug(
`[canAccessResource] Resolved ${resourceType} ${rawResourceId} to ObjectId ${resourceId}`,
);
}
// Check permissions using PermissionService with ObjectId
const hasPermission = await checkPermission({
userId,
role: req.user.role,
resourceType,
resourceId,
requiredPermission,
});
if (hasPermission) {
logger.debug(
`[canAccessResource] User ${userId} has permission ${requiredPermission} on ${resourceType} ${rawResourceId} (${resourceId})`,
);
req.resourceAccess = {
resourceType,
resourceId, // MongoDB ObjectId for ACL operations
customResourceId: rawResourceId, // Original ID from route params
permission: requiredPermission,
userId,
...(resourceInfo && { resourceInfo }),
};
return next();
}
logger.warn(
`[canAccessResource] User ${userId} denied access to ${resourceType} ${rawResourceId} ` +
`(required permission: ${requiredPermission})`,
);
return res.status(403).json({
error: 'Forbidden',
message: `Insufficient permissions to access this ${resourceType}`,
});
} catch (error) {
logger.error(`[canAccessResource] Error checking access for ${resourceType}:`, error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to check resource access permissions',
});
}
};
};
module.exports = {
canAccessResource,
};
@@ -0,0 +1,56 @@
const { ResourceType, PermissionBits } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { getSkillById } = require('~/models');
const { getDeploymentSkillById } = require('@librechat/api');
/**
* Skill-specific middleware factory that checks skill access permissions.
* Wraps the generic `canAccessResource` with the SKILL resource type and
* `getSkillById` as the ID resolver.
*
* @param {Object} options
* @param {number} options.requiredPermission - Permission bit required (1=view, 2=edit, 4=delete, 8=share)
* @param {string} [options.resourceIdParam='id'] - Route parameter name holding the skill id
* @returns {Function} Express middleware
*/
const canAccessSkillResource = (options) => {
const { requiredPermission, resourceIdParam = 'id' } = options || {};
if (!requiredPermission || typeof requiredPermission !== 'number') {
throw new Error('canAccessSkillResource: requiredPermission is required and must be a number');
}
const aclMiddleware = canAccessResource({
resourceType: ResourceType.SKILL,
requiredPermission,
resourceIdParam,
idResolver: getSkillById,
});
return (req, res, next) => {
const rawResourceId = req.params[resourceIdParam];
const deploymentSkill = rawResourceId ? getDeploymentSkillById(rawResourceId) : null;
if (!deploymentSkill) {
return aclMiddleware(req, res, next);
}
if (requiredPermission !== PermissionBits.VIEW) {
return res.status(403).json({
error: 'Forbidden',
message: 'Deployment skills are read-only',
});
}
req.resourceAccess = {
resourceType: ResourceType.SKILL,
resourceId: deploymentSkill._id,
customResourceId: rawResourceId,
permission: requiredPermission,
userId: req.user?.id,
resourceInfo: deploymentSkill,
};
return next();
};
};
module.exports = {
canAccessSkillResource,
};
@@ -0,0 +1,153 @@
const { logger } = require('@librechat/data-schemas');
const { PermissionBits, hasPermissions, ResourceType } = require('librechat-data-provider');
const { getEffectivePermissions } = require('~/server/services/PermissionService');
const { getAgents, getFiles } = require('~/models');
/**
* Checks if user has access to a file through agent permissions
* Files inherit permissions from agents they are attached to.
*/
const checkAgentBasedFileAccess = async ({ userId, role, fileId, fileOwner }) => {
try {
const fileOwnerId = fileOwner?.toString();
if (!fileOwnerId) {
return false;
}
/** Agents that have this file in their tool_resources */
const agentsWithFile = await getAgents({
$or: [
{ 'tool_resources.execute_code.file_ids': fileId },
{ 'tool_resources.file_search.file_ids': fileId },
{ 'tool_resources.image_edit.file_ids': fileId },
{ 'tool_resources.context.file_ids': fileId },
{ 'tool_resources.ocr.file_ids': fileId },
],
});
if (!agentsWithFile || agentsWithFile.length === 0) {
return false;
}
const userIdStr = userId.toString();
for (const agent of agentsWithFile) {
const agentAuthorId = agent.author?.toString();
if (!agentAuthorId) {
continue;
}
if (agentAuthorId === userIdStr) {
logger.debug(`[fileAccess] User is author of agent ${agent.id}`);
return true;
}
try {
const permissions = await getEffectivePermissions({
userId,
role,
resourceType: ResourceType.AGENT,
resourceId: agent._id || agent.id,
});
if (hasPermissions(permissions, PermissionBits.VIEW)) {
logger.debug(`[fileAccess] User ${userId} has VIEW permissions on agent ${agent.id}`);
return true;
}
} catch (permissionError) {
logger.warn(
`[fileAccess] Permission check failed for agent ${agent.id}:`,
permissionError.message,
);
}
}
return false;
} catch (error) {
logger.error('[fileAccess] Error checking agent-based access:', error);
return false;
}
};
const getTenantId = (value) => value?.toString?.() ?? null;
const denyFileAccess = (res) =>
res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions to access this file',
});
/**
* Middleware to check if user can access a file
* Checks: 1) File ownership, 2) Agent-based access through attached agents
*/
const fileAccess = async (req, res, next) => {
try {
const fileId = req.params.file_id;
const userId = req.user?.id;
const userRole = req.user?.role;
if (!fileId) {
return res.status(400).json({
error: 'Bad Request',
message: 'file_id is required',
});
}
if (!userId) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
const [file] = await getFiles({ file_id: fileId });
if (!file) {
return res.status(404).json({
error: 'Not Found',
message: 'File not found',
});
}
const fileTenantId = getTenantId(file.tenantId);
const userTenantId = getTenantId(req.user?.tenantId);
// Tenant-scoped files are restricted to their tenant. Legacy files without
// tenantId remain governed by owner/agent ACLs for non-tenant migrations.
if (fileTenantId && fileTenantId !== userTenantId) {
logger.warn(
`[fileAccess] User ${userId} denied cross-tenant access to file ${fileId} (route ${req.originalUrl})`,
);
return denyFileAccess(res);
}
if (file.user && file.user.toString() === userId) {
req.fileAccess = { file };
return next();
}
/** Agent-based access (file inherits agent permissions) */
const hasAgentAccess = await checkAgentBasedFileAccess({
userId,
role: userRole,
fileId,
fileOwner: file.user,
});
if (hasAgentAccess) {
req.fileAccess = { file };
return next();
}
logger.warn(
`[fileAccess] User ${userId} denied access to file ${fileId} (route ${req.originalUrl})`,
);
return denyFileAccess(res);
} catch (error) {
logger.error('[fileAccess] Error checking file access:', error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to check file access permissions',
});
}
};
module.exports = {
fileAccess,
};
@@ -0,0 +1,649 @@
const mongoose = require('mongoose');
const { tenantStorage } = require('@librechat/data-schemas');
const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { fileAccess } = require('./fileAccess');
const { User, Role, AclEntry } = require('~/db/models');
const { createAgent, createFile } = require('~/models');
describe('fileAccess middleware', () => {
let mongoServer;
let req, res, next;
let testUser, otherUser, thirdUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
// Create test role
await Role.create({
name: 'test-role',
permissions: {
AGENTS: {
USE: true,
CREATE: true,
SHARE: true,
},
},
});
// Create test users
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'test-role',
});
otherUser = await User.create({
email: 'other@example.com',
name: 'Other User',
username: 'otheruser',
role: 'test-role',
});
thirdUser = await User.create({
email: 'third@example.com',
name: 'Third User',
username: 'thirduser',
role: 'test-role',
});
// Setup request/response objects
req = {
user: { id: testUser._id.toString(), role: testUser.role },
params: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('basic file access', () => {
test('should allow access when user owns the file', async () => {
// Create a file owned by testUser
await createFile({
user: testUser._id.toString(),
file_id: 'file_owned_by_user',
filepath: '/test/file.txt',
filename: 'file.txt',
type: 'text/plain',
size: 100,
});
req.params.file_id = 'file_owned_by_user';
await fileAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
expect(req.fileAccess.file).toBeDefined();
expect(res.status).not.toHaveBeenCalled();
});
test('should deny access when user does not own the file and no agent access', async () => {
// Create a file owned by otherUser
await createFile({
user: otherUser._id.toString(),
file_id: 'file_owned_by_other',
filepath: '/test/file.txt',
filename: 'file.txt',
type: 'text/plain',
size: 100,
});
req.params.file_id = 'file_owned_by_other';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this file',
});
});
test('should deny access when tenant does not match even if user owns the file', async () => {
await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
createFile({
user: testUser._id.toString(),
file_id: 'file_owned_by_user_other_tenant',
filepath: '/test/file.txt',
filename: 'file.txt',
type: 'text/plain',
size: 100,
tenantId: 'tenant-a',
}),
);
req.user.tenantId = 'tenant-b';
req.params.file_id = 'file_owned_by_user_other_tenant';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to access this file',
});
});
test('should allow tenant-scoped users to access owned legacy files without tenantId', async () => {
await createFile({
user: testUser._id.toString(),
file_id: 'legacy_file_owned_by_user',
filepath: '/test/legacy.txt',
filename: 'legacy.txt',
type: 'text/plain',
size: 100,
});
req.user.tenantId = 'tenant-b';
req.params.file_id = 'legacy_file_owned_by_user';
await fileAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.fileAccess.file.file_id).toBe('legacy_file_owned_by_user');
expect(res.status).not.toHaveBeenCalled();
});
test('should return 404 when file does not exist', async () => {
req.params.file_id = 'non_existent_file';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Not Found',
message: 'File not found',
});
});
test('should return 400 when file_id is missing', async () => {
// Don't set file_id in params
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: 'Bad Request',
message: 'file_id is required',
});
});
test('should return 401 when user is not authenticated', async () => {
req.user = null;
req.params.file_id = 'some_file';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
});
});
describe('agent-based file access', () => {
beforeEach(async () => {
// Create a file owned by otherUser (not testUser)
await createFile({
user: otherUser._id.toString(),
file_id: 'shared_file_via_agent',
filepath: '/test/shared.txt',
filename: 'shared.txt',
type: 'text/plain',
size: 100,
});
});
test('should allow access when user authored an agent with an attached file from another owner', async () => {
await createAgent({
id: `agent_${Date.now()}`,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
tool_resources: {
file_search: {
file_ids: ['shared_file_via_agent'],
},
},
});
req.params.file_id = 'shared_file_via_agent';
await fileAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
});
test('should allow access when user has VIEW permission on agent with file', async () => {
// Create agent owned by otherUser
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Shared Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: {
execute_code: {
file_ids: ['shared_file_via_agent'],
},
},
});
// Grant VIEW permission to testUser
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 1, // VIEW permission
grantedBy: otherUser._id,
});
req.params.file_id = 'shared_file_via_agent';
await fileAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
});
test('should deny cross-tenant access even when user has VIEW permission on agent with file', async () => {
await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
createFile({
user: otherUser._id.toString(),
file_id: 'cross_tenant_shared_file',
filepath: '/test/cross-tenant.txt',
filename: 'cross-tenant.txt',
type: 'text/plain',
size: 100,
tenantId: 'tenant-a',
}),
);
const agent = await createAgent({
id: `agent_cross_tenant_${Date.now()}`,
name: 'Cross Tenant Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: {
execute_code: {
file_ids: ['cross_tenant_shared_file'],
},
},
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 1,
grantedBy: otherUser._id,
});
req.user.tenantId = 'tenant-b';
req.params.file_id = 'cross_tenant_shared_file';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
test('should check file in ocr tool_resources', async () => {
const agent = await createAgent({
id: `agent_ocr_${Date.now()}`,
name: 'OCR Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: {
ocr: {
file_ids: ['shared_file_via_agent'],
},
},
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 1,
grantedBy: otherUser._id,
});
req.params.file_id = 'shared_file_via_agent';
await fileAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
});
test('should check file in image_edit tool_resources', async () => {
const agent = await createAgent({
id: `agent_image_${Date.now()}`,
name: 'Image Edit Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: {
image_edit: {
file_ids: ['shared_file_via_agent'],
},
},
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 1,
grantedBy: otherUser._id,
});
req.params.file_id = 'shared_file_via_agent';
await fileAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
});
test('should deny access when user has no permission on agent with file', async () => {
// Create agent owned by otherUser without granting permission to testUser
const agent = await createAgent({
id: `agent_${Date.now()}`,
name: 'Private Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: {
file_search: {
file_ids: ['shared_file_via_agent'],
},
},
});
// Create ACL entry for otherUser only (owner)
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: otherUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
permBits: 15, // All permissions
grantedBy: otherUser._id,
});
req.params.file_id = 'shared_file_via_agent';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
describe('multiple agents with same file', () => {
/**
* This test suite verifies that when multiple agents have the same file,
* all agents are checked for permissions, not just the first one found.
* This ensures users can access files through any agent they have permission for.
*/
test('should check ALL agents with file, not just first one', async () => {
// Create a file owned by someone else
await createFile({
user: thirdUser._id.toString(),
file_id: 'multi_agent_file',
filepath: '/test/multi.txt',
filename: 'multi.txt',
type: 'text/plain',
size: 100,
});
// Create first agent (owned by otherUser, no access for testUser)
const agent1 = await createAgent({
id: 'agent_no_access',
name: 'No Access Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: {
file_search: {
file_ids: ['multi_agent_file'],
},
},
});
// Create ACL for agent1 - only otherUser has access
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: otherUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent1._id,
permBits: 15,
grantedBy: otherUser._id,
});
// Create second agent (owned by the file owner, and testUser has VIEW access)
const agent2 = await createAgent({
id: 'agent_with_access',
name: 'Accessible Agent',
provider: 'openai',
model: 'gpt-4',
author: thirdUser._id,
tool_resources: {
file_search: {
file_ids: ['multi_agent_file'],
},
},
});
// Grant testUser VIEW access to agent2
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent2._id,
permBits: 1, // VIEW permission
grantedBy: thirdUser._id,
});
req.params.file_id = 'multi_agent_file';
await fileAccess(req, res, next);
/**
* Should succeed because testUser has access to an attached agent,
* even though a non-owner agent without access is found first.
*/
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
expect(res.status).not.toHaveBeenCalled();
});
test('should find file in any agent tool_resources type', async () => {
// Create a file
await createFile({
user: otherUser._id.toString(),
file_id: 'multi_tool_file',
filepath: '/test/tool.txt',
filename: 'tool.txt',
type: 'text/plain',
size: 100,
});
// Agent 1: file in file_search (no access for testUser)
await createAgent({
id: 'agent_file_search',
name: 'File Search Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: {
file_search: {
file_ids: ['multi_tool_file'],
},
},
});
// Agent 2: same file in execute_code (testUser has access)
const agent2 = await createAgent({
id: 'agent_execute_code',
name: 'Execute Code Agent',
provider: 'openai',
model: 'gpt-4',
author: otherUser._id,
tool_resources: {
execute_code: {
file_ids: ['multi_tool_file'],
},
},
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agent2._id,
permBits: 1,
grantedBy: otherUser._id,
});
// Agent 3: same file in ocr on the requesting user's own agent
await createAgent({
id: 'agent_ocr',
name: 'OCR Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
tool_resources: {
ocr: {
file_ids: ['multi_tool_file'],
},
},
});
req.params.file_id = 'multi_tool_file';
await fileAccess(req, res, next);
/**
* Should succeed through an attached agent,
* even if other agents with the file are found first.
*/
expect(next).toHaveBeenCalled();
expect(req.fileAccess).toBeDefined();
});
});
describe('edge cases', () => {
test('should handle agent with empty tool_resources', async () => {
await createFile({
user: otherUser._id.toString(),
file_id: 'orphan_file',
filepath: '/test/orphan.txt',
filename: 'orphan.txt',
type: 'text/plain',
size: 100,
});
// Create agent with no files in tool_resources
await createAgent({
id: `agent_empty_${Date.now()}`,
name: 'Empty Resources Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
tool_resources: {},
});
req.params.file_id = 'orphan_file';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
test('should handle agent with null tool_resources', async () => {
await createFile({
user: otherUser._id.toString(),
file_id: 'another_orphan_file',
filepath: '/test/orphan2.txt',
filename: 'orphan2.txt',
type: 'text/plain',
size: 100,
});
// Create agent with null tool_resources
await createAgent({
id: `agent_null_${Date.now()}`,
name: 'Null Resources Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
tool_resources: null,
});
req.params.file_id = 'another_orphan_file';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
test('should deny agent-based access when file has no owner', async () => {
await mongoose.models.File.collection.insertOne({
file_id: 'ownerless_file',
filepath: '/test/ownerless.txt',
filename: 'ownerless.txt',
type: 'text/plain',
bytes: 100,
object: 'file',
});
await createAgent({
id: `agent_ownerless_${Date.now()}`,
name: 'Ownerless File Agent',
provider: 'openai',
model: 'gpt-4',
author: testUser._id,
tool_resources: {
file_search: {
file_ids: ['ownerless_file'],
},
},
});
req.params.file_id = 'ownerless_file';
await fileAccess(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
});
@@ -0,0 +1,17 @@
const { canAccessResource } = require('./canAccessResource');
const { canAccessAgentResource } = require('./canAccessAgentResource');
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
const { canAccessPromptViaGroup } = require('./canAccessPromptViaGroup');
const { canAccessPromptGroupResource } = require('./canAccessPromptGroupResource');
const { canAccessMCPServerResource } = require('./canAccessMCPServerResource');
const { canAccessSkillResource } = require('./canAccessSkillResource');
module.exports = {
canAccessResource,
canAccessAgentResource,
canAccessAgentFromBody,
canAccessPromptViaGroup,
canAccessPromptGroupResource,
canAccessMCPServerResource,
canAccessSkillResource,
};
@@ -0,0 +1,45 @@
const { v4 } = require('uuid');
const { handleAbortError } = require('~/server/middleware/abortMiddleware');
/**
* Checks if the assistant is supported or excluded
* @param {object} req - Express Request
* @param {object} req.body - The request payload.
* @param {object} res - Express Response
* @param {function} next - Express next middleware function.
* @returns {Promise<void>}
*/
const validateAssistant = async (req, res, next) => {
const { endpoint, conversationId, assistant_id, messageId } = req.body;
const appConfig = req.config;
/** @type {Partial<TAssistantEndpoint>} */
const assistantsConfig = appConfig.endpoints?.[endpoint];
if (!assistantsConfig) {
return next();
}
const { supportedIds, excludedIds } = assistantsConfig;
const error = { message: 'validateAssistant: Assistant not supported' };
if (supportedIds?.length && !supportedIds.includes(assistant_id)) {
return await handleAbortError(res, req, error, {
sender: 'System',
conversationId,
messageId: v4(),
parentMessageId: messageId,
error,
});
} else if (excludedIds?.length && excludedIds.includes(assistant_id)) {
return await handleAbortError(res, req, error, {
sender: 'System',
conversationId,
messageId: v4(),
parentMessageId: messageId,
});
}
return next();
};
module.exports = validateAssistant;
@@ -0,0 +1,53 @@
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { getAssistant } = require('~/models');
/**
* Checks if the assistant is supported or excluded
* @param {object} params
* @param {object} params.req - Express Request
* @param {object} params.req.body - The request payload.
* @param {string} params.overrideEndpoint - The override endpoint
* @param {string} params.overrideAssistantId - The override assistant ID
* @param {OpenAIClient} params.openai - OpenAI API Client
* @returns {Promise<void>}
*/
const validateAuthor = async ({ req, openai, overrideEndpoint, overrideAssistantId }) => {
const endpoint = overrideEndpoint ?? req.body.endpoint ?? req.query.endpoint;
const assistant_id =
overrideAssistantId ?? req.params.id ?? req.body.assistant_id ?? req.query.assistant_id;
const appConfig = req.config;
/** @type {Partial<TAssistantEndpoint>} */
const assistantsConfig = appConfig.endpoints?.[endpoint];
if (!assistantsConfig) {
return;
}
if (!assistantsConfig.privateAssistants) {
return;
}
let canManageAssistants = false;
try {
canManageAssistants = await hasCapability(req.user, SystemCapabilities.MANAGE_ASSISTANTS);
} catch (err) {
logger.warn(`[validateAuthor] capability check failed, denying bypass: ${err.message}`);
}
if (canManageAssistants) {
logger.debug(`[validateAuthor] MANAGE_ASSISTANTS bypass for user ${req.user.id}`);
return;
}
const assistantDoc = await getAssistant({ assistant_id, user: req.user.id });
if (assistantDoc) {
return;
}
const assistant = await openai.beta.assistants.retrieve(assistant_id);
if (req.user.id !== assistant?.metadata?.author) {
throw new Error(`Assistant ${assistant_id} is not authored by the user.`);
}
};
module.exports = validateAuthor;
@@ -0,0 +1,158 @@
const {
handleError,
applyModelSpecPreset,
findModelSpecByName,
isModelSpecEndpointMatch,
resolveModelSpecPromptPrefixVariables,
} = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
EndpointURLs,
EModelEndpoint,
isAgentsEndpoint,
parseCompactConvo,
getDefaultParamsEndpoint,
} = require('librechat-data-provider');
const azureAssistants = require('~/server/services/Endpoints/azureAssistants');
const assistants = require('~/server/services/Endpoints/assistants');
const { getEndpointsConfig } = require('~/server/services/Config');
const agents = require('~/server/services/Endpoints/agents');
const { updateFilesUsage } = require('~/models');
const buildFunction = {
[EModelEndpoint.agents]: agents.buildOptions,
[EModelEndpoint.assistants]: assistants.buildOptions,
[EModelEndpoint.azureAssistants]: azureAssistants.buildOptions,
};
async function buildEndpointOption(req, res, next) {
const { endpoint, endpointType } = req.body;
const isAgents =
isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]);
let endpointsConfig;
try {
endpointsConfig = await getEndpointsConfig(req);
} catch (error) {
logger.error('Error fetching endpoints config in buildEndpointOption', error);
}
const defaultParamsEndpoint = getDefaultParamsEndpoint(endpointsConfig, endpoint);
let parsedBody;
try {
parsedBody = parseCompactConvo({
endpoint,
endpointType,
conversation: req.body,
defaultParamsEndpoint,
});
} catch (error) {
logger.error(`Error parsing compact conversation for endpoint ${endpoint}`, error);
logger.debug({
'Error parsing compact conversation': { endpoint, endpointType, conversation: req.body },
});
return handleError(res, { text: 'Error parsing conversation' });
}
const appConfig = req.config;
let appliedModelSpecPrivateFields = new Set();
if (appConfig.modelSpecs?.list?.length && appConfig.modelSpecs?.enforce) {
/** @type {{ list: TModelSpec[] }}*/
const { list } = appConfig.modelSpecs;
const rawSpec = req.body.spec;
const spec = parsedBody.spec ?? (typeof rawSpec === 'string' ? rawSpec : undefined);
const rawChatProjectId = req.body.chatProjectId;
const parsedBodyForModelSpec =
parsedBody.chatProjectId === undefined &&
(typeof rawChatProjectId === 'string' || rawChatProjectId === null)
? { ...parsedBody, chatProjectId: rawChatProjectId }
: parsedBody;
if (!spec) {
return handleError(res, { text: 'No model spec selected' });
}
const currentModelSpec = findModelSpecByName({ list }, spec);
if (!currentModelSpec) {
return handleError(res, { text: 'Invalid model spec' });
}
if (!isModelSpecEndpointMatch(currentModelSpec, endpoint)) {
return handleError(res, { text: 'Model spec mismatch' });
}
try {
const result = applyModelSpecPreset({
modelSpec: currentModelSpec,
parsedBody: parsedBodyForModelSpec,
endpoint,
endpointType,
defaultParamsEndpoint,
includePresetDefaults: true,
});
parsedBody = result.parsedBody;
appliedModelSpecPrivateFields = result.appliedPrivateFields;
} catch (error) {
logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
return handleError(res, { text: 'Error parsing model spec' });
}
} else if (parsedBody.spec && appConfig.modelSpecs?.list) {
const modelSpec = findModelSpecByName(appConfig.modelSpecs, parsedBody.spec);
if (modelSpec) {
if (!isModelSpecEndpointMatch(modelSpec, endpoint)) {
return handleError(res, { text: 'Model spec mismatch' });
}
try {
const result = applyModelSpecPreset({
modelSpec,
parsedBody,
endpoint,
endpointType,
defaultParamsEndpoint,
});
parsedBody = result.parsedBody;
appliedModelSpecPrivateFields = result.appliedPrivateFields;
} catch (error) {
logger.error(`Error parsing model spec for endpoint ${endpoint}`, error);
return handleError(res, { text: 'Error parsing model spec' });
}
}
}
if (!isAgents && appliedModelSpecPrivateFields.has('promptPrefix')) {
parsedBody = resolveModelSpecPromptPrefixVariables(
parsedBody,
req.user,
req.body.clientTimestamp,
);
}
try {
const builder = isAgents
? (...args) => buildFunction[EModelEndpoint.agents](req, ...args)
: buildFunction[endpointType ?? endpoint];
// TODO: use object params
req.body = req.body || {}; // Express 5: ensure req.body exists
req.body.endpointOption = await builder(endpoint, parsedBody, endpointType);
if (req.body.files && !isAgents) {
req.body.endpointOption.attachments = updateFilesUsage(req.body.files, undefined, {
user: req.user.id,
tenantId: req.user.tenantId,
});
}
next();
} catch (error) {
logger.error(
`Error building endpoint option for endpoint ${endpoint} with type ${endpointType}`,
error,
);
return handleError(res, { text: 'Error building endpoint option' });
}
}
module.exports = buildEndpointOption;
@@ -0,0 +1,521 @@
/**
* Wrap parseCompactConvo: the REAL function runs, but jest can observe
* calls and return values. Must be declared before require('./buildEndpointOption')
* so the destructured reference in the middleware captures the wrapper.
*/
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
...actual,
parseCompactConvo: jest.fn((...args) => actual.parseCompactConvo(...args)),
};
});
const { EModelEndpoint, parseCompactConvo } = require('librechat-data-provider');
const mockBuildOptions = jest.fn((_endpoint, parsedBody) => ({
...parsedBody,
endpoint: _endpoint,
}));
const mockAgentBuildOptions = jest.fn((_req, endpoint, parsedBody) => ({
...parsedBody,
endpoint,
}));
jest.mock('~/server/services/Endpoints/azureAssistants', () => ({
buildOptions: mockBuildOptions,
}));
jest.mock('~/server/services/Endpoints/assistants', () => ({
buildOptions: mockBuildOptions,
}));
jest.mock('~/server/services/Endpoints/agents', () => ({
buildOptions: mockAgentBuildOptions,
}));
jest.mock('~/models', () => ({
updateFilesUsage: jest.fn(),
}));
const { updateFilesUsage } = require('~/models');
const mockGetEndpointsConfig = jest.fn();
jest.mock('~/server/services/Config', () => ({
getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args),
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
handleError: jest.fn(),
}));
const buildEndpointOption = require('./buildEndpointOption');
const createReq = (body, config = {}) => ({
body,
config,
baseUrl: '/api/chat',
});
const createRes = () => ({
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
});
describe('buildEndpointOption - defaultParamsEndpoint parsing', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should pass defaultParamsEndpoint to parseCompactConvo and preserve maxOutputTokens', async () => {
mockGetEndpointsConfig.mockResolvedValue({
AnthropicClaude: {
type: EModelEndpoint.custom,
customParams: {
defaultParamsEndpoint: EModelEndpoint.anthropic,
},
},
});
const req = createReq(
{
endpoint: 'AnthropicClaude',
endpointType: EModelEndpoint.custom,
model: 'anthropic/claude-opus-4.5',
temperature: 0.7,
maxOutputTokens: 8192,
topP: 0.9,
maxContextTokens: 50000,
},
{ modelSpecs: null },
);
await buildEndpointOption(req, createRes(), jest.fn());
expect(parseCompactConvo).toHaveBeenCalledWith(
expect.objectContaining({
defaultParamsEndpoint: EModelEndpoint.anthropic,
}),
);
const parsedResult = parseCompactConvo.mock.results[0].value;
expect(parsedResult.maxOutputTokens).toBe(8192);
expect(parsedResult.topP).toBe(0.9);
expect(parsedResult.temperature).toBe(0.7);
expect(parsedResult.maxContextTokens).toBe(50000);
});
it('should strip maxOutputTokens when no defaultParamsEndpoint is configured', async () => {
mockGetEndpointsConfig.mockResolvedValue({
MyOpenRouter: {
type: EModelEndpoint.custom,
},
});
const req = createReq(
{
endpoint: 'MyOpenRouter',
endpointType: EModelEndpoint.custom,
model: 'gpt-4o',
temperature: 0.7,
maxOutputTokens: 8192,
max_tokens: 4096,
},
{ modelSpecs: null },
);
await buildEndpointOption(req, createRes(), jest.fn());
expect(parseCompactConvo).toHaveBeenCalledWith(
expect.objectContaining({
defaultParamsEndpoint: undefined,
}),
);
const parsedResult = parseCompactConvo.mock.results[0].value;
expect(parsedResult.maxOutputTokens).toBeUndefined();
expect(parsedResult.max_tokens).toBe(4096);
expect(parsedResult.temperature).toBe(0.7);
});
it('should strip bedrock region from custom endpoint without defaultParamsEndpoint', async () => {
mockGetEndpointsConfig.mockResolvedValue({
MyEndpoint: {
type: EModelEndpoint.custom,
},
});
const req = createReq(
{
endpoint: 'MyEndpoint',
endpointType: EModelEndpoint.custom,
model: 'gpt-4o',
temperature: 0.7,
region: 'us-east-1',
},
{ modelSpecs: null },
);
await buildEndpointOption(req, createRes(), jest.fn());
const parsedResult = parseCompactConvo.mock.results[0].value;
expect(parsedResult.region).toBeUndefined();
expect(parsedResult.temperature).toBe(0.7);
});
it('should pass defaultParamsEndpoint when re-parsing enforced model spec', async () => {
mockGetEndpointsConfig.mockResolvedValue({
AnthropicClaude: {
type: EModelEndpoint.custom,
customParams: {
defaultParamsEndpoint: EModelEndpoint.anthropic,
},
},
});
const modelSpec = {
name: 'claude-opus-4.5',
preset: {
endpoint: 'AnthropicClaude',
endpointType: EModelEndpoint.custom,
model: 'anthropic/claude-opus-4.5',
temperature: 0.7,
maxOutputTokens: 8192,
maxContextTokens: 50000,
},
};
const req = createReq(
{
endpoint: 'AnthropicClaude',
endpointType: EModelEndpoint.custom,
spec: 'claude-opus-4.5',
model: 'anthropic/claude-opus-4.5',
temperature: 0.1,
topP: 0.2,
chatProjectId: 'project-1',
},
{
modelSpecs: {
enforce: true,
list: [modelSpec],
},
},
);
req.baseUrl = '/api/agents/chat';
await buildEndpointOption(req, createRes(), jest.fn());
const enforcedCall = parseCompactConvo.mock.calls[1];
expect(enforcedCall[0]).toEqual(
expect.objectContaining({
defaultParamsEndpoint: EModelEndpoint.anthropic,
}),
);
const enforcedResult = parseCompactConvo.mock.results[1].value;
expect(enforcedResult.maxOutputTokens).toBe(8192);
expect(enforcedResult.temperature).toBe(0.7);
expect(enforcedResult.topP).toBeUndefined();
expect(enforcedResult.maxContextTokens).toBe(50000);
expect(enforcedResult.chatProjectId).toBe('project-1');
expect(req.body.endpointOption.chatProjectId).toBe('project-1');
});
it('should rebuild enforced custom specs from the backend preset when compact parsing drops raw fields', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const modelSpec = {
name: 'approved-custom',
preset: {
endpoint: 'Mock Provider A',
endpointType: EModelEndpoint.custom,
model: 'mock-model-a',
promptPrefix: 'Use the approved custom model spec.',
},
};
const req = createReq(
{
endpoint: 'Mock Provider A',
endpointType: EModelEndpoint.custom,
spec: 'approved-custom',
model: { stale: 'cached-client-value' },
agent_id: 'agent_from_cached_client_state',
chatProjectId: 'project-1',
},
{
modelSpecs: {
enforce: true,
list: [modelSpec],
},
},
);
req.baseUrl = '/api/agents/chat';
await buildEndpointOption(req, createRes(), jest.fn());
expect(parseCompactConvo.mock.results[0].value).toEqual({});
expect(req.body.endpointOption.spec).toBe('approved-custom');
expect(req.body.endpointOption.model).toBe('mock-model-a');
expect(req.body.endpointOption.promptPrefix).toBe('Use the approved custom model spec.');
expect(req.body.endpointOption.chatProjectId).toBe('project-1');
});
it('should restore private model spec preset fields in non-enforced mode', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const modelSpec = {
name: 'guarded-openai',
iconURL: 'openAI',
preset: {
endpoint: EModelEndpoint.openAI,
model: 'gpt-4o',
promptPrefix: 'private prompt prefix',
instructions: 'private instructions',
additional_instructions: 'private additional instructions',
temperature: 0.2,
maxContextTokens: 10000,
},
};
const req = createReq(
{
endpoint: EModelEndpoint.openAI,
spec: 'guarded-openai',
model: 'gpt-4o',
temperature: 0.8,
},
{
modelSpecs: {
enforce: false,
list: [modelSpec],
},
},
);
req.baseUrl = '/api/agents/chat';
await buildEndpointOption(req, createRes(), jest.fn());
expect(req.body.endpointOption.promptPrefix).toBe('private prompt prefix');
expect(req.body.endpointOption.instructions).toBeUndefined();
expect(req.body.endpointOption.additional_instructions).toBeUndefined();
expect(req.body.endpointOption.temperature).toBe(0.8);
expect(req.body.endpointOption.maxContextTokens).toBeUndefined();
expect(req.body.endpointOption.iconURL).toBe('openAI');
});
it('should reject non-enforced model specs for a different endpoint', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const req = createReq(
{
endpoint: EModelEndpoint.openAI,
spec: 'guarded-google',
model: 'gpt-4o',
},
{
modelSpecs: {
enforce: false,
list: [
{
name: 'guarded-google',
preset: {
endpoint: EModelEndpoint.google,
model: 'gemini-pro',
promptPrefix: 'private google prompt',
},
},
],
},
},
);
const res = createRes();
const next = jest.fn();
const { handleError } = require('@librechat/api');
await buildEndpointOption(req, res, next);
expect(handleError).toHaveBeenCalledWith(res, { text: 'Model spec mismatch' });
expect(mockAgentBuildOptions).not.toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
it('should restore private model spec examples when the parser supplies an empty default', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const examples = [{ input: { content: 'hello' }, output: { content: 'world' } }];
const req = createReq(
{
endpoint: EModelEndpoint.google,
spec: 'guarded-google',
model: 'gemini-pro',
},
{
modelSpecs: {
enforce: false,
list: [
{
name: 'guarded-google',
preset: {
endpoint: EModelEndpoint.google,
model: 'gemini-pro',
examples,
},
},
],
},
},
);
req.baseUrl = '/api/agents/chat';
await buildEndpointOption(req, createRes(), jest.fn());
expect(req.body.endpointOption.examples).toEqual(examples);
});
it('should resolve special variables for restored non-agent promptPrefix', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const req = createReq(
{
endpoint: EModelEndpoint.assistants,
spec: 'guarded-assistant',
assistant_id: 'asst_123',
},
{
modelSpecs: {
enforce: false,
list: [
{
name: 'guarded-assistant',
preset: {
endpoint: EModelEndpoint.assistants,
assistant_id: 'asst_123',
promptPrefix: 'Help {{current_user}}.',
},
},
],
},
},
);
req.user = { name: 'Ada' };
await buildEndpointOption(req, createRes(), jest.fn());
expect(req.body.endpointOption.promptPrefix).toBe('Help Ada.');
});
it('should leave restored agent promptPrefix variables for agent initialization', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const req = createReq(
{
endpoint: EModelEndpoint.openAI,
spec: 'guarded-openai',
model: 'gpt-4o',
},
{
modelSpecs: {
enforce: false,
list: [
{
name: 'guarded-openai',
preset: {
endpoint: EModelEndpoint.openAI,
model: 'gpt-4o',
promptPrefix: 'Help {{current_user}}.',
},
},
],
},
},
);
req.baseUrl = '/api/agents/chat';
req.user = { name: 'Ada' };
await buildEndpointOption(req, createRes(), jest.fn());
expect(req.body.endpointOption.promptPrefix).toBe('Help {{current_user}}.');
});
it('should fall back to OpenAI schema when getEndpointsConfig fails', async () => {
mockGetEndpointsConfig.mockRejectedValue(new Error('Config unavailable'));
const req = createReq(
{
endpoint: 'AnthropicClaude',
endpointType: EModelEndpoint.custom,
model: 'anthropic/claude-opus-4.5',
temperature: 0.7,
maxOutputTokens: 8192,
max_tokens: 4096,
},
{ modelSpecs: null },
);
await buildEndpointOption(req, createRes(), jest.fn());
expect(parseCompactConvo).toHaveBeenCalledWith(
expect.objectContaining({
defaultParamsEndpoint: undefined,
}),
);
const parsedResult = parseCompactConvo.mock.results[0].value;
expect(parsedResult.maxOutputTokens).toBeUndefined();
expect(parsedResult.max_tokens).toBe(4096);
});
it('should scope non-agent chat attachment usage updates to the authenticated user', async () => {
const attachments = Promise.resolve([]);
updateFilesUsage.mockReturnValueOnce(attachments);
mockGetEndpointsConfig.mockResolvedValue({});
const req = createReq(
{
endpoint: EModelEndpoint.assistants,
assistant_id: 'asst_123',
files: [{ file_id: 'forged-file-id' }],
},
{ modelSpecs: null },
);
req.user = { id: 'user-1' };
await buildEndpointOption(req, createRes(), jest.fn());
expect(updateFilesUsage).toHaveBeenCalledWith(req.body.files, undefined, {
user: 'user-1',
tenantId: undefined,
});
expect(req.body.endpointOption.attachments).toBe(attachments);
});
it('should not enter the enforce branch when modelSpecs.list is empty', async () => {
mockGetEndpointsConfig.mockResolvedValue({});
const req = createReq(
{
endpoint: EModelEndpoint.openAI,
model: 'gpt-4',
},
{
modelSpecs: {
enforce: true,
list: [],
},
},
);
const res = createRes();
const { handleError } = require('@librechat/api');
await buildEndpointOption(req, res, jest.fn());
expect(handleError).not.toHaveBeenCalledWith(
res,
expect.objectContaining({ text: 'No model spec selected' }),
);
expect(handleError).not.toHaveBeenCalledWith(
res,
expect.objectContaining({ text: 'Invalid model spec' }),
);
});
});
@@ -0,0 +1,6 @@
const mongoose = require('mongoose');
const { createSharedLinkAccessMiddleware } = require('@librechat/api');
const canAccessSharedLink = createSharedLinkAccessMiddleware({ mongoose });
module.exports = canAccessSharedLink;
+45
View File
@@ -0,0 +1,45 @@
const { isEnabled } = require('@librechat/api');
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
/**
* Checks if the user can delete their account
*
* @async
* @function
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Next middleware function
*
* @returns {Promise<function|Object>} - Returns a Promise which when resolved calls next middleware if the user can delete their account
*/
const canDeleteAccount = async (req, res, next = () => {}) => {
const { user } = req;
const { ALLOW_ACCOUNT_DELETION = true } = process.env;
if (isEnabled(ALLOW_ACCOUNT_DELETION)) {
return next();
}
let hasAdminAccess = false;
if (user) {
try {
const id = user.id ?? user._id?.toString();
if (id) {
hasAdminAccess = await hasCapability(
{ id, role: user.role ?? '', tenantId: user.tenantId },
SystemCapabilities.ACCESS_ADMIN,
);
}
} catch (err) {
logger.warn(`[canDeleteAccount] capability check failed, denying: ${err.message}`);
}
}
if (hasAdminAccess) {
logger.debug(`[canDeleteAccount] ACCESS_ADMIN bypass for user ${user.id}`);
return next();
}
logger.error(`[User] [Delete Account] [User cannot delete account] [User: ${user?.id}]`);
return res.status(403).send({ message: 'You do not have permission to delete this account' });
};
module.exports = canDeleteAccount;
@@ -0,0 +1,180 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { SystemRoles, PrincipalType } = require('librechat-data-provider');
const { SystemCapabilities } = require('@librechat/data-schemas');
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: { error: jest.fn(), warn: jest.fn(), debug: jest.fn(), info: jest.fn() },
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(() => ({
get: jest.fn(),
set: jest.fn(),
})),
}));
const { User, SystemGrant } = require('~/db/models');
const canDeleteAccount = require('./canDeleteAccount');
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
delete process.env.ALLOW_ACCOUNT_DELETION;
});
const makeRes = () => {
const send = jest.fn();
const status = jest.fn().mockReturnValue({ send });
return { status, send };
};
describe('canDeleteAccount', () => {
describe('ALLOW_ACCOUNT_DELETION=true (default)', () => {
it('calls next without hitting the DB', async () => {
process.env.ALLOW_ACCOUNT_DELETION = 'true';
const next = jest.fn();
const req = { user: { id: 'user-1', role: SystemRoles.USER } };
await canDeleteAccount(req, makeRes(), next);
expect(next).toHaveBeenCalled();
});
it('skips capability check entirely when deletion is allowed', async () => {
process.env.ALLOW_ACCOUNT_DELETION = 'true';
const next = jest.fn();
const req = { user: { id: 'user-1', role: SystemRoles.USER } };
await canDeleteAccount(req, makeRes(), next);
expect(next).toHaveBeenCalled();
const grantCount = await SystemGrant.countDocuments();
expect(grantCount).toBe(0);
});
});
describe('ALLOW_ACCOUNT_DELETION=false', () => {
beforeEach(() => {
process.env.ALLOW_ACCOUNT_DELETION = 'false';
});
it('allows admin with ACCESS_ADMIN grant (real DB check)', async () => {
const admin = await User.create({
name: 'Admin',
email: 'admin@test.com',
password: 'password123',
provider: 'local',
role: SystemRoles.ADMIN,
});
await SystemGrant.create({
principalType: PrincipalType.ROLE,
principalId: SystemRoles.ADMIN,
capability: SystemCapabilities.ACCESS_ADMIN,
grantedAt: new Date(),
});
const next = jest.fn();
const req = { user: { id: admin._id.toString(), role: SystemRoles.ADMIN } };
await canDeleteAccount(req, makeRes(), next);
expect(next).toHaveBeenCalled();
});
it('blocks regular user without ACCESS_ADMIN grant', async () => {
const user = await User.create({
name: 'Regular',
email: 'user@test.com',
password: 'password123',
provider: 'local',
role: SystemRoles.USER,
});
const next = jest.fn();
const res = makeRes();
const req = { user: { id: user._id.toString(), role: SystemRoles.USER } };
await canDeleteAccount(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('blocks admin role WITHOUT the ACCESS_ADMIN grant', async () => {
const admin = await User.create({
name: 'Admin No Grant',
email: 'admin2@test.com',
password: 'password123',
provider: 'local',
role: SystemRoles.ADMIN,
});
const next = jest.fn();
const res = makeRes();
const req = { user: { id: admin._id.toString(), role: SystemRoles.ADMIN } };
await canDeleteAccount(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('allows user-level grant (not just role-level)', async () => {
const user = await User.create({
name: 'Privileged User',
email: 'priv@test.com',
password: 'password123',
provider: 'local',
role: SystemRoles.USER,
});
await SystemGrant.create({
principalType: PrincipalType.USER,
principalId: user._id,
capability: SystemCapabilities.ACCESS_ADMIN,
grantedAt: new Date(),
});
const next = jest.fn();
const req = { user: { id: user._id.toString(), role: SystemRoles.USER } };
await canDeleteAccount(req, makeRes(), next);
expect(next).toHaveBeenCalled();
});
it('blocks when user is undefined — does not throw', async () => {
const next = jest.fn();
const res = makeRes();
await canDeleteAccount({ user: undefined }, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('blocks when user is null — does not throw', async () => {
const next = jest.fn();
const res = makeRes();
await canDeleteAccount({ user: null }, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
});
+145
View File
@@ -0,0 +1,145 @@
const { Keyv } = require('keyv');
const uap = require('ua-parser-js');
const { logger } = require('@librechat/data-schemas');
const { ViolationTypes } = require('librechat-data-provider');
const { isEnabled, keyvMongo, removePorts } = require('@librechat/api');
const { getLogStores } = require('~/cache');
const denyRequest = require('./denyRequest');
const { findUser } = require('~/models');
const banCache = new Keyv({ store: keyvMongo, namespace: ViolationTypes.BAN, ttl: 0 });
const message = 'Your account has been temporarily banned due to violations of our service.';
/** @returns {string} Cache key for ban lookups, prefixed for Redis or raw for MongoDB */
const getBanCacheKey = (prefix, value, useRedis) => {
if (!value) {
return '';
}
return useRedis ? `ban_cache:${prefix}:${value}` : value;
};
/**
* Respond to the request if the user is banned.
*
* @async
* @function
* @param {Object} req - Express Request object.
* @param {Object} res - Express Response object.
*
* @returns {Promise<Object>} - Returns a Promise which when resolved sends a response status of 403 with a specific message if request is not of api/agents/chat. If it is, calls `denyRequest()` function.
*/
const banResponse = async (req, res) => {
const ua = uap(req.headers['user-agent']);
const { baseUrl, originalUrl } = req;
if (!ua.browser.name) {
return res.status(403).json({ message });
} else if (baseUrl === '/api/agents' && originalUrl.startsWith('/api/agents/chat')) {
return await denyRequest(req, res, { type: ViolationTypes.BAN });
}
return res.status(403).json({ message });
};
/**
* Checks if the source IP or user is banned or not.
*
* @async
* @function
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @param {import('express').NextFunction} next - Next middleware function.
*
* @returns {Promise<function|Object>} - Returns a Promise which when resolved calls next middleware if user or source IP is not banned. Otherwise calls `banResponse()` and sets ban details in `banCache`.
*/
const checkBan = async (req, res, next = () => {}) => {
try {
const { BAN_VIOLATIONS } = process.env ?? {};
if (!isEnabled(BAN_VIOLATIONS)) {
return next();
}
req.ip = removePorts(req);
let userId = req.user?.id ?? req.user?._id ?? null;
if (!userId && req?.body?.email) {
const user = await findUser({ email: req.body.email }, '_id');
userId = user?._id ? user._id.toString() : userId;
}
if (!userId && !req.ip) {
return next();
}
const useRedis = isEnabled(process.env.USE_REDIS);
const ipKey = getBanCacheKey('ip', req.ip, useRedis);
const userKey = getBanCacheKey('user', userId, useRedis);
const [cachedIPBan, cachedUserBan] = await Promise.all([
ipKey ? banCache.get(ipKey) : undefined,
userKey ? banCache.get(userKey) : undefined,
]);
if (cachedIPBan || cachedUserBan) {
req.banned = true;
return await banResponse(req, res);
}
const banLogs = getLogStores(ViolationTypes.BAN);
const duration = banLogs.opts.ttl;
if (duration <= 0) {
return next();
}
const [ipBan, userBan] = await Promise.all([
req.ip ? banLogs.get(req.ip) : undefined,
userId ? banLogs.get(userId) : undefined,
]);
const banData = ipBan || userBan;
if (!banData) {
return next();
}
const expiresAt = Number(banData.expiresAt);
if (!banData.expiresAt || isNaN(expiresAt)) {
req.banned = true;
return await banResponse(req, res);
}
const timeLeft = expiresAt - Date.now();
if (timeLeft <= 0) {
const cleanups = [];
if (ipBan) {
cleanups.push(banLogs.delete(req.ip));
}
if (userBan) {
cleanups.push(banLogs.delete(userId));
}
await Promise.all(cleanups);
return next();
}
const cacheWrites = [];
if (ipKey) {
cacheWrites.push(banCache.set(ipKey, banData, timeLeft));
}
if (userKey) {
cacheWrites.push(banCache.set(userKey, banData, timeLeft));
}
await Promise.all(cacheWrites).catch((err) =>
logger.warn('[checkBan] Failed to write ban cache:', err),
);
req.banned = true;
return await banResponse(req, res);
} catch (error) {
logger.error('Error in checkBan middleware:', error);
return next(error);
}
};
module.exports = checkBan;
@@ -0,0 +1,34 @@
const { logger } = require('@librechat/data-schemas');
const { getAppConfigOptionsFromUser, isEmailDomainAllowed } = require('@librechat/api');
const { getAppConfig } = require('~/server/services/Config');
/**
* Checks the domain's social login is allowed
*
* @async
* @function
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @param {Function} next - Next middleware function.
*
* @returns {Promise<void>} - Calls next middleware if the domain's email is allowed, otherwise redirects to login
*/
const checkDomainAllowed = async (req, res, next) => {
try {
const email = req?.user?.email;
const appConfig = await getAppConfig(getAppConfigOptionsFromUser(req?.user));
if (email && !isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error(`[Social Login] [Social Login not allowed] [Email: ${email}]`);
res.redirect('/login');
return;
}
next();
} catch (error) {
logger.error('[checkDomainAllowed] Error checking domain:', error);
res.redirect('/login');
}
};
module.exports = checkDomainAllowed;
+30
View File
@@ -0,0 +1,30 @@
const { getInvite: getInviteFn } = require('@librechat/api');
const { createToken, findToken, deleteTokens } = require('~/models');
const getInvite = (encodedToken, email) =>
getInviteFn(encodedToken, email, { createToken, findToken });
async function checkInviteUser(req, res, next) {
const token = req.body.token;
if (!token || token === 'undefined') {
next();
return;
}
try {
const invite = await getInvite(token, req.body.email);
if (!invite || invite.error === true) {
return res.status(400).json({ message: 'Invalid invite token' });
}
await deleteTokens({ token: invite.token });
req.invite = invite;
next();
} catch (error) {
return res.status(429).json({ message: error.message });
}
}
module.exports = checkInviteUser;
@@ -0,0 +1,106 @@
const { logger } = require('@librechat/data-schemas');
const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider');
const { getRoleByName } = require('~/models');
const VALID_PRINCIPAL_TYPES = new Set([
PrincipalType.USER,
PrincipalType.GROUP,
PrincipalType.ROLE,
]);
/**
* Middleware to check if user has permission to access people picker functionality.
* Validates requested principal types via `type` (singular) and `types` (comma-separated or array)
* query parameters against the caller's role permissions:
* - user: requires VIEW_USERS permission
* - group: requires VIEW_GROUPS permission
* - role: requires VIEW_ROLES permission
* - no type filter (mixed search): requires at least one of the above
*/
const checkPeoplePickerAccess = async (req, res, next) => {
try {
const user = req.user;
if (!user || !user.role) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
const role = await getRoleByName(user.role);
if (!role || !role.permissions) {
return res.status(403).json({
error: 'Forbidden',
message: 'No permissions configured for user role',
});
}
const { type, types } = req.query;
const peoplePickerPerms = role.permissions[PermissionTypes.PEOPLE_PICKER] || {};
const canViewUsers = peoplePickerPerms[Permissions.VIEW_USERS] === true;
const canViewGroups = peoplePickerPerms[Permissions.VIEW_GROUPS] === true;
const canViewRoles = peoplePickerPerms[Permissions.VIEW_ROLES] === true;
const permissionChecks = {
[PrincipalType.USER]: {
hasPermission: canViewUsers,
message: 'Insufficient permissions to search for users',
},
[PrincipalType.GROUP]: {
hasPermission: canViewGroups,
message: 'Insufficient permissions to search for groups',
},
[PrincipalType.ROLE]: {
hasPermission: canViewRoles,
message: 'Insufficient permissions to search for roles',
},
};
const requestedTypes = new Set();
if (type && VALID_PRINCIPAL_TYPES.has(type)) {
requestedTypes.add(type);
}
if (types) {
const typesArray = Array.isArray(types) ? types : types.split(',');
for (const t of typesArray) {
if (VALID_PRINCIPAL_TYPES.has(t)) {
requestedTypes.add(t);
}
}
}
for (const requested of requestedTypes) {
const check = permissionChecks[requested];
if (!check.hasPermission) {
return res.status(403).json({
error: 'Forbidden',
message: check.message,
});
}
}
if (requestedTypes.size === 0 && !canViewUsers && !canViewGroups && !canViewRoles) {
return res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions to search for users, groups, or roles',
});
}
next();
} catch (error) {
logger.error(
`[checkPeoplePickerAccess][${req.user?.id}] error for type=${req.query.type}, types=${req.query.types}`,
error,
);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to check permissions',
});
}
};
module.exports = {
checkPeoplePickerAccess,
};
@@ -0,0 +1,416 @@
const { logger } = require('@librechat/data-schemas');
const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider');
const { checkPeoplePickerAccess } = require('./checkPeoplePickerAccess');
const { getRoleByName } = require('~/models');
jest.mock('~/models');
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
error: jest.fn(),
},
}));
describe('checkPeoplePickerAccess', () => {
let req, res, next;
beforeEach(() => {
req = {
user: { id: 'user123', role: 'USER' },
query: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
it('should return 401 if user is not authenticated', async () => {
req.user = null;
await checkPeoplePickerAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
expect(next).not.toHaveBeenCalled();
});
it('should return 403 if role has no permissions', async () => {
getRoleByName.mockResolvedValue(null);
await checkPeoplePickerAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'No permissions configured for user role',
});
expect(next).not.toHaveBeenCalled();
});
it('should allow access when searching for users with VIEW_USERS permission', async () => {
req.query.type = PrincipalType.USER;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: false,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should deny access when searching for users without VIEW_USERS permission', async () => {
req.query.type = PrincipalType.USER;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: false,
[Permissions.VIEW_GROUPS]: true,
[Permissions.VIEW_ROLES]: true,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to search for users',
});
expect(next).not.toHaveBeenCalled();
});
it('should allow access when searching for groups with VIEW_GROUPS permission', async () => {
req.query.type = PrincipalType.GROUP;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: false,
[Permissions.VIEW_GROUPS]: true,
[Permissions.VIEW_ROLES]: false,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should deny access when searching for groups without VIEW_GROUPS permission', async () => {
req.query.type = PrincipalType.GROUP;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: true,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to search for groups',
});
expect(next).not.toHaveBeenCalled();
});
it('should allow access when searching for roles with VIEW_ROLES permission', async () => {
req.query.type = PrincipalType.ROLE;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: false,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: true,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should deny access when searching for roles without VIEW_ROLES permission', async () => {
req.query.type = PrincipalType.ROLE;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: true,
[Permissions.VIEW_ROLES]: false,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to search for roles',
});
expect(next).not.toHaveBeenCalled();
});
it('should deny access when using types param to bypass type-specific check', async () => {
req.query.types = PrincipalType.GROUP;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: false,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to search for groups',
});
expect(next).not.toHaveBeenCalled();
});
it('should deny access when types contains any unpermitted type', async () => {
req.query.types = `${PrincipalType.USER},${PrincipalType.ROLE}`;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: false,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to search for roles',
});
expect(next).not.toHaveBeenCalled();
});
it('should allow access when all requested types are permitted', async () => {
req.query.types = `${PrincipalType.USER},${PrincipalType.GROUP}`;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: true,
[Permissions.VIEW_ROLES]: false,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should validate types when provided as array (Express qs parsing)', async () => {
req.query.types = [PrincipalType.GROUP, PrincipalType.ROLE];
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: true,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to search for groups',
});
expect(next).not.toHaveBeenCalled();
});
it('should enforce permissions for combined type and types params', async () => {
req.query.type = PrincipalType.USER;
req.query.types = PrincipalType.GROUP;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: false,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to search for groups',
});
expect(next).not.toHaveBeenCalled();
});
it('should treat all-invalid types values as mixed search', async () => {
req.query.types = 'foobar';
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: false,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should deny when types is empty string and user has no permissions', async () => {
req.query.types = '';
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: false,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: false,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to search for users, groups, or roles',
});
expect(next).not.toHaveBeenCalled();
});
it('should treat types=public as mixed search since PUBLIC is not a searchable principal type', async () => {
req.query.types = PrincipalType.PUBLIC;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: true,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: false,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should allow mixed search when user has at least one permission', async () => {
// No type specified = mixed search
req.query.type = undefined;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: false,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: true,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should deny mixed search when user has no permissions', async () => {
// No type specified = mixed search
req.query.type = undefined;
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: false,
[Permissions.VIEW_GROUPS]: false,
[Permissions.VIEW_ROLES]: false,
},
},
});
await checkPeoplePickerAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to search for users, groups, or roles',
});
expect(next).not.toHaveBeenCalled();
});
it('should handle errors gracefully', async () => {
const error = new Error('Database error');
getRoleByName.mockRejectedValue(error);
await checkPeoplePickerAccess(req, res, next);
expect(logger.error).toHaveBeenCalledWith(
'[checkPeoplePickerAccess][user123] error for type=undefined, types=undefined',
error,
);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: 'Failed to check permissions',
});
expect(next).not.toHaveBeenCalled();
});
it('should handle missing permissions object gracefully', async () => {
req.query.type = PrincipalType.USER;
getRoleByName.mockResolvedValue({
permissions: {}, // No PEOPLE_PICKER permissions
});
await checkPeoplePickerAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: 'Insufficient permissions to search for users',
});
expect(next).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,8 @@
const { createSharePolicyMiddleware } = require('@librechat/api');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { getRoleByName } = require('~/models');
module.exports = createSharePolicyMiddleware({
getRoleByName,
hasCapability,
});
@@ -0,0 +1,306 @@
jest.mock('~/models', () => ({
getRoleByName: jest.fn(),
}));
jest.mock('~/server/middleware/roles/capabilities', () => ({
hasCapability: jest.fn(),
}));
const { ResourceType, PermissionTypes, Permissions } = require('librechat-data-provider');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { checkShareAccess, checkSharePublicAccess } = require('./checkSharePublicAccess');
const { getRoleByName } = require('~/models');
describe('checkSharePublicAccess middleware', () => {
let mockReq;
let mockRes;
let mockNext;
beforeEach(() => {
jest.clearAllMocks();
hasCapability.mockResolvedValue(false);
mockReq = {
user: { id: 'user123', role: 'USER' },
params: { resourceType: ResourceType.AGENT },
body: {},
};
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
mockNext = jest.fn();
});
it('should call next() when public is not true', async () => {
mockReq.body = { public: false };
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should call next() when public is undefined', async () => {
mockReq.body = { updated: [] };
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should return 401 when user is not authenticated', async () => {
mockReq.body = { public: true };
mockReq.user = null;
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
expect(mockNext).not.toHaveBeenCalled();
});
it('should return 403 when user role has no SHARE_PUBLIC permission for agents', async () => {
mockReq.body = { public: true };
mockReq.params = { resourceType: ResourceType.AGENT };
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.AGENTS]: {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: false,
},
},
});
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: `You do not have permission to share ${ResourceType.AGENT} resources publicly`,
});
expect(mockNext).not.toHaveBeenCalled();
});
it('should call next() when user has SHARE_PUBLIC permission for agents', async () => {
mockReq.body = { public: true };
mockReq.params = { resourceType: ResourceType.AGENT };
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.AGENTS]: {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
},
});
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should check prompts permission for promptgroup resource type', async () => {
mockReq.body = { public: true };
mockReq.params = { resourceType: ResourceType.PROMPTGROUP };
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.PROMPTS]: {
[Permissions.SHARE_PUBLIC]: true,
},
},
});
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
});
it('should check mcp_servers permission for mcpserver resource type', async () => {
mockReq.body = { public: true };
mockReq.params = { resourceType: ResourceType.MCPSERVER };
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.MCP_SERVERS]: {
[Permissions.SHARE_PUBLIC]: true,
},
},
});
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
});
it('should return 400 for unsupported resource type', async () => {
mockReq.body = { public: true };
mockReq.params = { resourceType: 'unsupported' };
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Bad Request',
message: 'Unsupported resource type for public sharing: unsupported',
});
});
it('should return 403 when role has no permissions object', async () => {
mockReq.body = { public: true };
getRoleByName.mockResolvedValue({ permissions: null });
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(403);
});
it('should return 500 on error', async () => {
mockReq.body = { public: true };
getRoleByName.mockRejectedValue(new Error('Database error'));
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: 'Failed to check public sharing permissions',
});
});
});
describe('checkShareAccess middleware', () => {
let mockReq;
let mockRes;
let mockNext;
beforeEach(() => {
jest.clearAllMocks();
hasCapability.mockResolvedValue(false);
mockReq = {
user: { id: 'user123', role: 'USER' },
params: { resourceType: ResourceType.SKILL },
body: { updated: [] },
};
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
mockNext = jest.fn();
});
it('should return 403 when user role has no SHARE permission for skills', async () => {
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.SKILLS]: {
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
},
},
});
await checkShareAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: `You do not have permission to share ${ResourceType.SKILL} resources`,
});
expect(mockNext).not.toHaveBeenCalled();
});
it('should call next() when user has resource management capability for skills', async () => {
hasCapability.mockResolvedValue(true);
await checkShareAccess(mockReq, mockRes, mockNext);
expect(hasCapability).toHaveBeenCalledWith(mockReq.user, 'manage:skills');
expect(getRoleByName).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should call next() when user role has SHARE permission for skills', async () => {
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.SKILLS]: {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: false,
},
},
});
await checkShareAccess(mockReq, mockRes, mockNext);
expect(hasCapability).toHaveBeenCalledWith(mockReq.user, 'manage:skills');
expect(mockNext).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should return 401 when user is not authenticated', async () => {
mockReq.user = null;
await checkShareAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
expect(mockNext).not.toHaveBeenCalled();
});
it('should return 400 for unsupported resource type', async () => {
mockReq.params = { resourceType: 'unsupported' };
await checkShareAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Bad Request',
message: 'Unsupported resource type for sharing: unsupported',
});
});
it('should return 403 when role has no permissions object', async () => {
getRoleByName.mockResolvedValue({ permissions: null });
await checkShareAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockNext).not.toHaveBeenCalled();
});
it('should return 500 on error', async () => {
getRoleByName.mockRejectedValue(new Error('Database error'));
await checkShareAccess(mockReq, mockRes, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(500);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: 'Failed to check sharing permissions',
});
});
it('should reuse the role permission lookup for public sharing checks', async () => {
mockReq.body = { updated: [], public: true };
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.SKILLS]: {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
},
});
await checkShareAccess(mockReq, mockRes, mockNext);
await checkSharePublicAccess(mockReq, mockRes, mockNext);
expect(getRoleByName).toHaveBeenCalledTimes(1);
expect(mockNext).toHaveBeenCalledTimes(2);
expect(mockRes.status).not.toHaveBeenCalled();
});
});
+27
View File
@@ -0,0 +1,27 @@
const { logger } = require('@librechat/data-schemas');
const { getAppConfigOptionsFromUser } = require('@librechat/api');
const { getAppConfig } = require('~/server/services/Config');
const configMiddleware = async (req, res, next) => {
try {
req.config = await getAppConfig(getAppConfigOptionsFromUser(req.user));
next();
} catch (error) {
logger.error('Config middleware error:', {
error: error.message,
userRole: req.user?.role,
path: req.path,
});
try {
req.config = await getAppConfig({ tenantId: req.user?.tenantId });
next();
} catch (fallbackError) {
logger.error('Fallback config middleware error:', fallbackError);
next(fallbackError);
}
}
};
module.exports = configMiddleware;
+67
View File
@@ -0,0 +1,67 @@
const crypto = require('crypto');
const { sendEvent } = require('@librechat/api');
const { getResponseSender, Constants } = require('librechat-data-provider');
const { sendError } = require('~/server/middleware/error');
const { saveMessage } = require('~/models');
/**
* Denies a request by sending an error message and optionally saves the user's message.
*
* @async
* @function
* @param {Object} req - Express request object.
* @param {Object} req.body - The body of the request.
* @param {string} [req.body.messageId] - The ID of the message.
* @param {string} [req.body.conversationId] - The ID of the conversation.
* @param {string} [req.body.parentMessageId] - The ID of the parent message.
* @param {string} req.body.text - The text of the message.
* @param {Object} res - Express response object.
* @param {string} errorMessage - The error message to be sent.
* @returns {Promise<Object>} A promise that resolves with the error response.
* @throws {Error} Throws an error if there's an issue saving the message or sending the error.
*/
const denyRequest = async (req, res, errorMessage) => {
let responseText = errorMessage;
if (typeof errorMessage === 'object') {
responseText = JSON.stringify(errorMessage);
}
const { messageId, conversationId: _convoId, parentMessageId, text } = req.body;
const conversationId = _convoId ?? crypto.randomUUID();
const userMessage = {
sender: 'User',
messageId: messageId ?? crypto.randomUUID(),
parentMessageId,
conversationId,
isCreatedByUser: true,
text,
};
sendEvent(res, { message: userMessage, created: true });
const shouldSaveMessage = _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT;
if (shouldSaveMessage) {
await saveMessage(
{
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
{ ...userMessage, user: req.user.id },
{ context: `api/server/middleware/denyRequest.js - ${responseText}` },
);
}
return await sendError(req, res, {
sender: getResponseSender(req.body),
messageId: crypto.randomUUID(),
conversationId,
parentMessageId: userMessage.messageId,
text: responseText,
shouldSaveMessage,
user: req.user.id,
});
};
module.exports = denyRequest;
+110
View File
@@ -0,0 +1,110 @@
const crypto = require('crypto');
const { logger } = require('@librechat/data-schemas');
const { parseConvo } = require('librechat-data-provider');
const { sendEvent, handleError, sanitizeMessageForTransmit } = require('@librechat/api');
const { saveMessage, getMessages, getConvo } = require('~/models');
/**
* Processes an error with provided options, saves the error message and sends a corresponding SSE response
* @async
* @param {object} req - The request.
* @param {object} res - The response.
* @param {object} options - The options for handling the error containing message properties.
* @param {object} options.user - The user ID.
* @param {string} options.sender - The sender of the message.
* @param {string} options.conversationId - The conversation ID.
* @param {string} options.messageId - The message ID.
* @param {string} options.parentMessageId - The parent message ID.
* @param {string} options.text - The error message.
* @param {boolean} options.shouldSaveMessage - [Optional] Whether the message should be saved. Default is true.
* @param {function} callback - [Optional] The callback function to be executed.
*/
const sendError = async (req, res, options, callback) => {
const {
user,
sender,
conversationId,
messageId,
parentMessageId,
text,
shouldSaveMessage,
...rest
} = options;
const errorMessage = {
sender,
messageId: messageId ?? crypto.randomUUID(),
conversationId,
parentMessageId,
unfinished: false,
error: true,
final: true,
text,
isCreatedByUser: false,
...rest,
};
if (callback && typeof callback === 'function') {
await callback();
}
if (shouldSaveMessage) {
await saveMessage(
{
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
interfaceConfig: req?.config?.interfaceConfig,
},
{ ...errorMessage, user },
{
context: 'api/server/utils/streamResponse.js - sendError',
},
);
}
if (!errorMessage.error) {
const requestMessage = { messageId: parentMessageId, conversationId };
let query = [],
convo = {};
try {
query = await getMessages(requestMessage);
convo = await getConvo(user, conversationId);
} catch (err) {
logger.error('[sendError] Error retrieving conversation data:', err);
convo = parseConvo(errorMessage);
}
return sendEvent(res, {
final: true,
requestMessage: sanitizeMessageForTransmit(query?.[0] ?? requestMessage),
responseMessage: errorMessage,
conversation: convo,
});
}
handleError(res, errorMessage);
};
/**
* Sends the response based on whether headers have been sent or not.
* @param {ServerRequest} req - The server response.
* @param {Express.Response} res - The server response.
* @param {Object} data - The data to be sent.
* @param {string} [errorMessage] - The error message, if any.
*/
const sendResponse = (req, res, data, errorMessage) => {
if (!res.headersSent) {
if (errorMessage) {
return res.status(500).json({ error: errorMessage });
}
return res.json(data);
}
if (errorMessage) {
return sendError(req, res, { ...data, text: errorMessage });
}
return sendEvent(res, data);
};
module.exports = {
sendError,
sendResponse,
};
+56
View File
@@ -0,0 +1,56 @@
const validatePasswordReset = require('./validatePasswordReset');
const setTwoFactorTempUser = require('./setTwoFactorTempUser');
const validateRegistration = require('./validateRegistration');
const buildEndpointOption = require('./buildEndpointOption');
const validateMessageReq = require('./validateMessageReq');
const { prepareMessageRequestValidation, sendValidationResponse } = require('./messageValidation');
const checkDomainAllowed = require('./checkDomainAllowed');
const requireLocalAuth = require('./requireLocalAuth');
const canDeleteAccount = require('./canDeleteAccount');
const accessResources = require('./accessResources');
const requireLdapAuth = require('./requireLdapAuth');
const abortMiddleware = require('./abortMiddleware');
const checkInviteUser = require('./checkInviteUser');
const requireJwtAuth = require('./requireJwtAuth');
const { requireRumProxyAuth } = require('./requireJwtAuth');
const configMiddleware = require('./config/app');
const validateModel = require('./validateModel');
const moderateText = require('./moderateText');
const logHeaders = require('./logHeaders');
const setHeaders = require('./setHeaders');
const validate = require('./validate');
const limiters = require('./limiters');
const uaParser = require('./uaParser');
const checkBan = require('./checkBan');
const noIndex = require('./noIndex');
const roles = require('./roles');
module.exports = {
...abortMiddleware,
...validate,
...limiters,
...roles,
...accessResources,
noIndex,
checkBan,
uaParser,
setHeaders,
logHeaders,
moderateText,
validateModel,
requireJwtAuth,
requireRumProxyAuth,
setTwoFactorTempUser,
checkInviteUser,
requireLdapAuth,
requireLocalAuth,
canDeleteAccount,
configMiddleware,
checkDomainAllowed,
validateMessageReq,
sendValidationResponse,
prepareMessageRequestValidation,
buildEndpointOption,
validateRegistration,
validatePasswordReset,
};
@@ -0,0 +1,80 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { limiterCache, removePorts } = require('@librechat/api');
const logViolation = require('~/cache/logViolation');
const getEnvironmentVariables = () => {
const FORK_IP_MAX = parseInt(process.env.FORK_IP_MAX) || 30;
const FORK_IP_WINDOW = parseInt(process.env.FORK_IP_WINDOW) || 1;
const FORK_USER_MAX = parseInt(process.env.FORK_USER_MAX) || 7;
const FORK_USER_WINDOW = parseInt(process.env.FORK_USER_WINDOW) || 1;
const FORK_VIOLATION_SCORE = process.env.FORK_VIOLATION_SCORE;
const forkIpWindowMs = FORK_IP_WINDOW * 60 * 1000;
const forkIpMax = FORK_IP_MAX;
const forkIpWindowInMinutes = forkIpWindowMs / 60000;
const forkUserWindowMs = FORK_USER_WINDOW * 60 * 1000;
const forkUserMax = FORK_USER_MAX;
const forkUserWindowInMinutes = forkUserWindowMs / 60000;
return {
forkIpWindowMs,
forkIpMax,
forkIpWindowInMinutes,
forkUserWindowMs,
forkUserMax,
forkUserWindowInMinutes,
forkViolationScore: FORK_VIOLATION_SCORE,
};
};
const createForkHandler = (ip = true) => {
const {
forkIpMax,
forkUserMax,
forkViolationScore,
forkIpWindowInMinutes,
forkUserWindowInMinutes,
} = getEnvironmentVariables();
return async (req, res) => {
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
const errorMessage = {
type,
max: ip ? forkIpMax : forkUserMax,
limiter: ip ? 'ip' : 'user',
windowInMinutes: ip ? forkIpWindowInMinutes : forkUserWindowInMinutes,
};
await logViolation(req, res, type, errorMessage, forkViolationScore);
res.status(429).json({ message: 'Too many requests. Try again later' });
};
};
const createForkLimiters = () => {
const { forkIpWindowMs, forkIpMax, forkUserWindowMs, forkUserMax } = getEnvironmentVariables();
const ipLimiterOptions = {
windowMs: forkIpWindowMs,
max: forkIpMax,
handler: createForkHandler(),
keyGenerator: removePorts,
store: limiterCache('fork_ip_limiter'),
};
const userLimiterOptions = {
windowMs: forkUserWindowMs,
max: forkUserMax,
handler: createForkHandler(false),
keyGenerator: function (req) {
return req.user?.id;
},
store: limiterCache('fork_user_limiter'),
};
const forkIpLimiter = rateLimit(ipLimiterOptions);
const forkUserLimiter = rateLimit(userLimiterOptions);
return { forkIpLimiter, forkUserLimiter };
};
module.exports = { createForkLimiters };
@@ -0,0 +1,81 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { limiterCache, removePorts } = require('@librechat/api');
const logViolation = require('~/cache/logViolation');
const getEnvironmentVariables = () => {
const IMPORT_IP_MAX = parseInt(process.env.IMPORT_IP_MAX) || 100;
const IMPORT_IP_WINDOW = parseInt(process.env.IMPORT_IP_WINDOW) || 15;
const IMPORT_USER_MAX = parseInt(process.env.IMPORT_USER_MAX) || 50;
const IMPORT_USER_WINDOW = parseInt(process.env.IMPORT_USER_WINDOW) || 15;
const IMPORT_VIOLATION_SCORE = process.env.IMPORT_VIOLATION_SCORE;
const importIpWindowMs = IMPORT_IP_WINDOW * 60 * 1000;
const importIpMax = IMPORT_IP_MAX;
const importIpWindowInMinutes = importIpWindowMs / 60000;
const importUserWindowMs = IMPORT_USER_WINDOW * 60 * 1000;
const importUserMax = IMPORT_USER_MAX;
const importUserWindowInMinutes = importUserWindowMs / 60000;
return {
importIpWindowMs,
importIpMax,
importIpWindowInMinutes,
importUserWindowMs,
importUserMax,
importUserWindowInMinutes,
importViolationScore: IMPORT_VIOLATION_SCORE,
};
};
const createImportHandler = (ip = true) => {
const {
importIpMax,
importUserMax,
importViolationScore,
importIpWindowInMinutes,
importUserWindowInMinutes,
} = getEnvironmentVariables();
return async (req, res) => {
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
const errorMessage = {
type,
max: ip ? importIpMax : importUserMax,
limiter: ip ? 'ip' : 'user',
windowInMinutes: ip ? importIpWindowInMinutes : importUserWindowInMinutes,
};
await logViolation(req, res, type, errorMessage, importViolationScore);
res.status(429).json({ message: 'Too many conversation import requests. Try again later' });
};
};
const createImportLimiters = () => {
const { importIpWindowMs, importIpMax, importUserWindowMs, importUserMax } =
getEnvironmentVariables();
const ipLimiterOptions = {
windowMs: importIpWindowMs,
max: importIpMax,
handler: createImportHandler(),
keyGenerator: removePorts,
store: limiterCache('import_ip_limiter'),
};
const userLimiterOptions = {
windowMs: importUserWindowMs,
max: importUserMax,
handler: createImportHandler(false),
keyGenerator: function (req) {
return req.user?.id;
},
store: limiterCache('import_user_limiter'),
};
const importIpLimiter = rateLimit(ipLimiterOptions);
const importUserLimiter = rateLimit(userLimiterOptions);
return { importIpLimiter, importUserLimiter };
};
module.exports = { createImportLimiters };
+34
View File
@@ -0,0 +1,34 @@
const createTTSLimiters = require('./ttsLimiters');
const createSTTLimiters = require('./sttLimiters');
const loginLimiter = require('./loginLimiter');
const importLimiters = require('./importLimiters');
const uploadLimiters = require('./uploadLimiters');
const forkLimiters = require('./forkLimiters');
const registerLimiter = require('./registerLimiter');
const toolCallLimiter = require('./toolCallLimiter');
const messageLimiters = require('./messageLimiters');
const promptUsageLimiter = require('./promptUsageLimiter');
const verifyEmailLimiter = require('./verifyEmailLimiter');
const resetPasswordLimiter = require('./resetPasswordLimiter');
const twoFactorTempLimiter = require('./twoFactorTempLimiter');
const verifyEmailSubmissionLimiter = require('./verifyEmailSubmissionLimiter');
const resetPasswordSubmissionLimiter = require('./resetPasswordSubmissionLimiter');
module.exports = {
...uploadLimiters,
...importLimiters,
...messageLimiters,
...forkLimiters,
...promptUsageLimiter,
loginLimiter,
registerLimiter,
toolCallLimiter,
createTTSLimiters,
createSTTLimiters,
verifyEmailLimiter,
resetPasswordLimiter,
verifyEmailSubmissionLimiter,
resetPasswordSubmissionLimiter,
twoFactorTempLimiter,
};
@@ -0,0 +1,34 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { limiterCache, removePorts } = require('@librechat/api');
const { logViolation } = require('~/cache');
const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env;
const windowMs = LOGIN_WINDOW * 60 * 1000;
const max = LOGIN_MAX;
const windowInMinutes = windowMs / 60000;
const message = `Too many login attempts, please try again after ${windowInMinutes} minutes.`;
const handler = async (req, res) => {
const type = ViolationTypes.LOGINS;
const errorMessage = {
type,
max,
windowInMinutes,
};
await logViolation(req, res, type, errorMessage, score);
return res.status(429).json({ message });
};
const limiterOptions = {
windowMs,
max,
handler,
keyGenerator: removePorts,
store: limiterCache('login_limiter'),
};
const loginLimiter = rateLimit(limiterOptions);
module.exports = loginLimiter;
@@ -0,0 +1,80 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { limiterCache, removePorts } = require('@librechat/api');
const denyRequest = require('~/server/middleware/denyRequest');
const { logViolation } = require('~/cache');
const {
MESSAGE_IP_MAX = 40,
MESSAGE_IP_WINDOW = 1,
MESSAGE_USER_MAX = 40,
MESSAGE_USER_WINDOW = 1,
MESSAGE_VIOLATION_SCORE: score,
} = process.env;
const ipWindowMs = MESSAGE_IP_WINDOW * 60 * 1000;
const ipMax = MESSAGE_IP_MAX;
const ipWindowInMinutes = ipWindowMs / 60000;
const userWindowMs = MESSAGE_USER_WINDOW * 60 * 1000;
const userMax = MESSAGE_USER_MAX;
const userWindowInMinutes = userWindowMs / 60000;
/**
* Creates either an IP/User message request rate limiter for excessive requests
* that properly logs and denies the violation.
*
* @param {boolean} [ip=true] - Whether to create an IP limiter or a user limiter.
* @returns {function} A rate limiter function.
*
*/
const createHandler = (ip = true) => {
return async (req, res) => {
const type = ViolationTypes.MESSAGE_LIMIT;
const errorMessage = {
type,
max: ip ? ipMax : userMax,
limiter: ip ? 'ip' : 'user',
windowInMinutes: ip ? ipWindowInMinutes : userWindowInMinutes,
};
await logViolation(req, res, type, errorMessage, score);
return await denyRequest(req, res, errorMessage);
};
};
/**
* Message request rate limiters
*/
const ipLimiterOptions = {
windowMs: ipWindowMs,
max: ipMax,
handler: createHandler(),
keyGenerator: removePorts,
store: limiterCache('message_ip_limiter'),
};
const userLimiterOptions = {
windowMs: userWindowMs,
max: userMax,
handler: createHandler(false),
keyGenerator: function (req) {
return req.user?.id;
},
store: limiterCache('message_user_limiter'),
};
/**
* Message request rate limiter by IP
*/
const messageIpLimiter = rateLimit(ipLimiterOptions);
/**
* Message request rate limiter by userId
*/
const messageUserLimiter = rateLimit(userLimiterOptions);
module.exports = {
messageIpLimiter,
messageUserLimiter,
};
@@ -0,0 +1,17 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@librechat/api');
const PROMPT_USAGE_WINDOW_MS = 60 * 1000; // 1 minute
const PROMPT_USAGE_MAX = 30; // 30 usage increments per user per minute
const promptUsageLimiter = rateLimit({
windowMs: PROMPT_USAGE_WINDOW_MS,
max: PROMPT_USAGE_MAX,
handler: (_req, res) => {
res.status(429).json({ message: 'Too many prompt usage requests. Try again later' });
},
keyGenerator: (req) => req.user?.id,
store: limiterCache('prompt_usage_limiter'),
});
module.exports = { promptUsageLimiter };
@@ -0,0 +1,34 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { limiterCache, removePorts } = require('@librechat/api');
const { logViolation } = require('~/cache');
const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env;
const windowMs = REGISTER_WINDOW * 60 * 1000;
const max = REGISTER_MAX;
const windowInMinutes = windowMs / 60000;
const message = `Too many accounts created, please try again after ${windowInMinutes} minutes`;
const handler = async (req, res) => {
const type = ViolationTypes.REGISTRATIONS;
const errorMessage = {
type,
max,
windowInMinutes,
};
await logViolation(req, res, type, errorMessage, score);
return res.status(429).json({ message });
};
const limiterOptions = {
windowMs,
max,
handler,
keyGenerator: removePorts,
store: limiterCache('register_limiter'),
};
const registerLimiter = rateLimit(limiterOptions);
module.exports = registerLimiter;
@@ -0,0 +1,38 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { limiterCache, removePorts } = require('@librechat/api');
const { logViolation } = require('~/cache');
const {
RESET_PASSWORD_WINDOW = 2,
RESET_PASSWORD_MAX = 2,
RESET_PASSWORD_VIOLATION_SCORE: score,
} = process.env;
const windowMs = RESET_PASSWORD_WINDOW * 60 * 1000;
const max = RESET_PASSWORD_MAX;
const windowInMinutes = windowMs / 60000;
const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`;
const handler = async (req, res) => {
const type = ViolationTypes.RESET_PASSWORD_LIMIT;
const errorMessage = {
type,
max,
windowInMinutes,
};
await logViolation(req, res, type, errorMessage, score);
return res.status(429).json({ message });
};
const limiterOptions = {
windowMs,
max,
handler,
keyGenerator: removePorts,
store: limiterCache('reset_password_limiter'),
};
const resetPasswordLimiter = rateLimit(limiterOptions);
module.exports = resetPasswordLimiter;
@@ -0,0 +1,39 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { limiterCache, removePorts } = require('@librechat/api');
const { logViolation } = require('~/cache');
const {
RESET_PASSWORD_SUBMISSION_WINDOW = process.env.RESET_PASSWORD_WINDOW ?? 2,
RESET_PASSWORD_SUBMISSION_MAX = process.env.RESET_PASSWORD_MAX ?? 2,
RESET_PASSWORD_SUBMISSION_VIOLATION_SCORE: score,
} = process.env;
const windowMs = RESET_PASSWORD_SUBMISSION_WINDOW * 60 * 1000;
const max = RESET_PASSWORD_SUBMISSION_MAX;
const windowInMinutes = windowMs / 60000;
const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`;
const handler = async (req, res) => {
const type = ViolationTypes.RESET_PASSWORD_LIMIT;
const errorMessage = {
type,
max,
windowInMinutes,
limiter: 'submission',
};
await logViolation(req, res, type, errorMessage, score);
return res.status(429).json({ message });
};
const limiterOptions = {
windowMs,
max,
handler,
keyGenerator: removePorts,
store: limiterCache('reset_password_submission_limiter'),
};
const resetPasswordSubmissionLimiter = rateLimit(limiterOptions);
module.exports = resetPasswordSubmissionLimiter;
@@ -0,0 +1,77 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { limiterCache, removePorts } = require('@librechat/api');
const logViolation = require('~/cache/logViolation');
const getEnvironmentVariables = () => {
const STT_IP_MAX = parseInt(process.env.STT_IP_MAX) || 100;
const STT_IP_WINDOW = parseInt(process.env.STT_IP_WINDOW) || 1;
const STT_USER_MAX = parseInt(process.env.STT_USER_MAX) || 50;
const STT_USER_WINDOW = parseInt(process.env.STT_USER_WINDOW) || 1;
const STT_VIOLATION_SCORE = process.env.STT_VIOLATION_SCORE;
const sttIpWindowMs = STT_IP_WINDOW * 60 * 1000;
const sttIpMax = STT_IP_MAX;
const sttIpWindowInMinutes = sttIpWindowMs / 60000;
const sttUserWindowMs = STT_USER_WINDOW * 60 * 1000;
const sttUserMax = STT_USER_MAX;
const sttUserWindowInMinutes = sttUserWindowMs / 60000;
return {
sttIpWindowMs,
sttIpMax,
sttIpWindowInMinutes,
sttUserWindowMs,
sttUserMax,
sttUserWindowInMinutes,
sttViolationScore: STT_VIOLATION_SCORE,
};
};
const createSTTHandler = (ip = true) => {
const { sttIpMax, sttIpWindowInMinutes, sttUserMax, sttUserWindowInMinutes, sttViolationScore } =
getEnvironmentVariables();
return async (req, res) => {
const type = ViolationTypes.STT_LIMIT;
const errorMessage = {
type,
max: ip ? sttIpMax : sttUserMax,
limiter: ip ? 'ip' : 'user',
windowInMinutes: ip ? sttIpWindowInMinutes : sttUserWindowInMinutes,
};
await logViolation(req, res, type, errorMessage, sttViolationScore);
res.status(429).json({ message: 'Too many STT requests. Try again later' });
};
};
const createSTTLimiters = () => {
const { sttIpWindowMs, sttIpMax, sttUserWindowMs, sttUserMax } = getEnvironmentVariables();
const ipLimiterOptions = {
windowMs: sttIpWindowMs,
max: sttIpMax,
handler: createSTTHandler(),
keyGenerator: removePorts,
store: limiterCache('stt_ip_limiter'),
};
const userLimiterOptions = {
windowMs: sttUserWindowMs,
max: sttUserMax,
handler: createSTTHandler(false),
keyGenerator: function (req) {
return req.user?.id;
},
store: limiterCache('stt_user_limiter'),
};
const sttIpLimiter = rateLimit(ipLimiterOptions);
const sttUserLimiter = rateLimit(userLimiterOptions);
return { sttIpLimiter, sttUserLimiter };
};
module.exports = createSTTLimiters;
@@ -0,0 +1,33 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@librechat/api');
const { ViolationTypes } = require('librechat-data-provider');
const logViolation = require('~/cache/logViolation');
const { TOOL_CALL_VIOLATION_SCORE: score } = process.env;
const handler = async (req, res) => {
const type = ViolationTypes.TOOL_CALL_LIMIT;
const errorMessage = {
type,
max: 1,
limiter: 'user',
windowInMinutes: 1,
};
await logViolation(req, res, type, errorMessage, score);
res.status(429).json({ message: 'Too many tool call requests. Try again later' });
};
const limiterOptions = {
windowMs: 1000,
max: 1,
handler,
keyGenerator: function (req) {
return req.user?.id;
},
store: limiterCache('tool_call_limiter'),
};
const toolCallLimiter = rateLimit(limiterOptions);
module.exports = toolCallLimiter;
@@ -0,0 +1,77 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { limiterCache, removePorts } = require('@librechat/api');
const logViolation = require('~/cache/logViolation');
const getEnvironmentVariables = () => {
const TTS_IP_MAX = parseInt(process.env.TTS_IP_MAX) || 100;
const TTS_IP_WINDOW = parseInt(process.env.TTS_IP_WINDOW) || 1;
const TTS_USER_MAX = parseInt(process.env.TTS_USER_MAX) || 50;
const TTS_USER_WINDOW = parseInt(process.env.TTS_USER_WINDOW) || 1;
const TTS_VIOLATION_SCORE = process.env.TTS_VIOLATION_SCORE;
const ttsIpWindowMs = TTS_IP_WINDOW * 60 * 1000;
const ttsIpMax = TTS_IP_MAX;
const ttsIpWindowInMinutes = ttsIpWindowMs / 60000;
const ttsUserWindowMs = TTS_USER_WINDOW * 60 * 1000;
const ttsUserMax = TTS_USER_MAX;
const ttsUserWindowInMinutes = ttsUserWindowMs / 60000;
return {
ttsIpWindowMs,
ttsIpMax,
ttsIpWindowInMinutes,
ttsUserWindowMs,
ttsUserMax,
ttsUserWindowInMinutes,
ttsViolationScore: TTS_VIOLATION_SCORE,
};
};
const createTTSHandler = (ip = true) => {
const { ttsIpMax, ttsIpWindowInMinutes, ttsUserMax, ttsUserWindowInMinutes, ttsViolationScore } =
getEnvironmentVariables();
return async (req, res) => {
const type = ViolationTypes.TTS_LIMIT;
const errorMessage = {
type,
max: ip ? ttsIpMax : ttsUserMax,
limiter: ip ? 'ip' : 'user',
windowInMinutes: ip ? ttsIpWindowInMinutes : ttsUserWindowInMinutes,
};
await logViolation(req, res, type, errorMessage, ttsViolationScore);
res.status(429).json({ message: 'Too many TTS requests. Try again later' });
};
};
const createTTSLimiters = () => {
const { ttsIpWindowMs, ttsIpMax, ttsUserWindowMs, ttsUserMax } = getEnvironmentVariables();
const ipLimiterOptions = {
windowMs: ttsIpWindowMs,
max: ttsIpMax,
handler: createTTSHandler(),
keyGenerator: removePorts,
store: limiterCache('tts_ip_limiter'),
};
const userLimiterOptions = {
windowMs: ttsUserWindowMs,
max: ttsUserMax,
handler: createTTSHandler(false),
keyGenerator: function (req) {
return req.user?.id;
},
store: limiterCache('tts_user_limiter'),
};
const ttsIpLimiter = rateLimit(ipLimiterOptions);
const ttsUserLimiter = rateLimit(userLimiterOptions);
return { ttsIpLimiter, ttsUserLimiter };
};
module.exports = createTTSLimiters;
@@ -0,0 +1,101 @@
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');
const {
LOGIN_WINDOW = 5,
LOGIN_MAX = 7,
LOGIN_VIOLATION_SCORE,
TWO_FACTOR_TEMP_WINDOW = LOGIN_WINDOW,
TWO_FACTOR_TEMP_MAX = LOGIN_MAX,
TWO_FACTOR_TEMP_VIOLATION_SCORE,
} = process.env;
const windowMs = TWO_FACTOR_TEMP_WINDOW * 60 * 1000;
const max = TWO_FACTOR_TEMP_MAX;
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;
}
try {
const payload = jwt.verify(tempToken, process.env.JWT_SECRET);
return payload?.userId ?? null;
} catch {
return null;
}
};
const createHandler = (limiter) => async (req, res) => {
const type = ViolationTypes.LOGINS;
const errorMessage = {
type,
max,
limiter,
windowInMinutes,
};
const userId = getTempTokenUserId(req.body?.tempToken);
if (userId && !req.user) {
req.user = { id: userId };
} else if (userId && !req.user.id && !req.user._id) {
req.user.id = userId;
}
await logViolation(req, res, type, errorMessage, score);
return res.status(429).json({ message });
};
const ipLimiterOptions = {
windowMs,
max,
handler: createHandler('ip'),
keyGenerator: removePorts,
store: limiterCache('two_factor_temp_limiter'),
};
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;
@@ -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,
});
});
});
@@ -0,0 +1,85 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { limiterCache, removePorts } = require('@librechat/api');
const logViolation = require('~/cache/logViolation');
const getEnvironmentVariables = () => {
const FILE_UPLOAD_IP_MAX = parseInt(process.env.FILE_UPLOAD_IP_MAX) || 100;
const FILE_UPLOAD_IP_WINDOW = parseInt(process.env.FILE_UPLOAD_IP_WINDOW) || 15;
const FILE_UPLOAD_USER_MAX = parseInt(process.env.FILE_UPLOAD_USER_MAX) || 50;
const FILE_UPLOAD_USER_WINDOW = parseInt(process.env.FILE_UPLOAD_USER_WINDOW) || 15;
const FILE_UPLOAD_VIOLATION_SCORE = process.env.FILE_UPLOAD_VIOLATION_SCORE;
const fileUploadIpWindowMs = FILE_UPLOAD_IP_WINDOW * 60 * 1000;
const fileUploadIpMax = FILE_UPLOAD_IP_MAX;
const fileUploadIpWindowInMinutes = fileUploadIpWindowMs / 60000;
const fileUploadUserWindowMs = FILE_UPLOAD_USER_WINDOW * 60 * 1000;
const fileUploadUserMax = FILE_UPLOAD_USER_MAX;
const fileUploadUserWindowInMinutes = fileUploadUserWindowMs / 60000;
return {
fileUploadIpWindowMs,
fileUploadIpMax,
fileUploadIpWindowInMinutes,
fileUploadUserWindowMs,
fileUploadUserMax,
fileUploadUserWindowInMinutes,
fileUploadViolationScore: FILE_UPLOAD_VIOLATION_SCORE,
};
};
const createFileUploadHandler = (ip = true) => {
const {
fileUploadIpMax,
fileUploadIpWindowInMinutes,
fileUploadUserMax,
fileUploadUserWindowInMinutes,
fileUploadViolationScore,
} = getEnvironmentVariables();
return async (req, res) => {
const type = ViolationTypes.FILE_UPLOAD_LIMIT;
const errorMessage = {
type,
max: ip ? fileUploadIpMax : fileUploadUserMax,
limiter: ip ? 'ip' : 'user',
windowInMinutes: ip ? fileUploadIpWindowInMinutes : fileUploadUserWindowInMinutes,
};
await logViolation(req, res, type, errorMessage, fileUploadViolationScore);
res.status(429).json({ message: 'Too many file upload requests. Try again later' });
};
};
const createFileLimiters = () => {
const { fileUploadIpWindowMs, fileUploadIpMax, fileUploadUserWindowMs, fileUploadUserMax } =
getEnvironmentVariables();
const ipLimiterOptions = {
windowMs: fileUploadIpWindowMs,
max: fileUploadIpMax,
handler: createFileUploadHandler(),
keyGenerator: removePorts,
store: limiterCache('file_upload_ip_limiter'),
};
const userLimiterOptions = {
windowMs: fileUploadUserWindowMs,
max: fileUploadUserMax,
handler: createFileUploadHandler(false),
keyGenerator: function (req) {
return req.user?.id;
},
store: limiterCache('file_upload_user_limiter'),
};
const fileUploadIpLimiter = rateLimit(ipLimiterOptions);
const fileUploadUserLimiter = rateLimit(userLimiterOptions);
return { fileUploadIpLimiter, fileUploadUserLimiter };
};
module.exports = {
createFileLimiters,
};
@@ -0,0 +1,38 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { limiterCache, removePorts } = require('@librechat/api');
const { logViolation } = require('~/cache');
const {
VERIFY_EMAIL_WINDOW = 2,
VERIFY_EMAIL_MAX = 2,
VERIFY_EMAIL_VIOLATION_SCORE: score,
} = process.env;
const windowMs = VERIFY_EMAIL_WINDOW * 60 * 1000;
const max = VERIFY_EMAIL_MAX;
const windowInMinutes = windowMs / 60000;
const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`;
const handler = async (req, res) => {
const type = ViolationTypes.VERIFY_EMAIL_LIMIT;
const errorMessage = {
type,
max,
windowInMinutes,
};
await logViolation(req, res, type, errorMessage, score);
return res.status(429).json({ message });
};
const limiterOptions = {
windowMs,
max,
handler,
keyGenerator: removePorts,
store: limiterCache('verify_email_limiter'),
};
const verifyEmailLimiter = rateLimit(limiterOptions);
module.exports = verifyEmailLimiter;
@@ -0,0 +1,39 @@
const rateLimit = require('express-rate-limit');
const { ViolationTypes } = require('librechat-data-provider');
const { limiterCache, removePorts } = require('@librechat/api');
const { logViolation } = require('~/cache');
const {
VERIFY_EMAIL_SUBMISSION_WINDOW = process.env.VERIFY_EMAIL_WINDOW ?? 2,
VERIFY_EMAIL_SUBMISSION_MAX = process.env.VERIFY_EMAIL_MAX ?? 2,
VERIFY_EMAIL_SUBMISSION_VIOLATION_SCORE: score,
} = process.env;
const windowMs = VERIFY_EMAIL_SUBMISSION_WINDOW * 60 * 1000;
const max = VERIFY_EMAIL_SUBMISSION_MAX;
const windowInMinutes = windowMs / 60000;
const message = `Too many attempts, please try again after ${windowInMinutes} minute(s)`;
const handler = async (req, res) => {
const type = ViolationTypes.VERIFY_EMAIL_LIMIT;
const errorMessage = {
type,
max,
windowInMinutes,
limiter: 'submission',
};
await logViolation(req, res, type, errorMessage, score);
return res.status(429).json({ message });
};
const limiterOptions = {
windowMs,
max,
handler,
keyGenerator: removePorts,
store: limiterCache('verify_email_submission_limiter'),
};
const verifyEmailSubmissionLimiter = rateLimit(limiterOptions);
module.exports = verifyEmailSubmissionLimiter;
+32
View File
@@ -0,0 +1,32 @@
const { logger } = require('@librechat/data-schemas');
/**
* Middleware to log Forwarded Headers
* @function
* @param {ServerRequest} req - Express request object containing user information.
* @param {ServerResponse} res - Express response object.
* @param {import('express').NextFunction} next - Next middleware function.
* @throws {Error} Throws an error if the user exceeds the concurrent request limit.
*/
const logHeaders = (req, res, next) => {
try {
const forwardedHeaders = {};
if (req.headers['x-forwarded-for']) {
forwardedHeaders['x-forwarded-for'] = req.headers['x-forwarded-for'];
}
if (req.headers['x-forwarded-host']) {
forwardedHeaders['x-forwarded-host'] = req.headers['x-forwarded-host'];
}
if (req.headers['x-forwarded-proto']) {
forwardedHeaders['x-forwarded-proto'] = req.headers['x-forwarded-proto'];
}
if (Object.keys(forwardedHeaders).length > 0) {
logger.debug('X-Forwarded headers detected in OAuth request:', forwardedHeaders);
}
} catch (error) {
logger.error('Error logging X-Forwarded headers:', error);
}
next();
};
module.exports = logHeaders;
@@ -0,0 +1,14 @@
const {
GenerationJobManager,
createMessageRequestMiddleware,
isPendingActionStale,
} = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { getConvo } = require('~/models');
module.exports = createMessageRequestMiddleware({
getConvo,
getJob: (conversationId) => GenerationJobManager.getJob(conversationId),
isPendingActionStale,
logger,
});
+100
View File
@@ -0,0 +1,100 @@
const axios = require('axios');
const { isEnabled, getReferencedQuotes, mergeQuotedText } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const denyRequest = require('./denyRequest');
async function moderateText(req, res, next) {
if (!isEnabled(process.env.OPENAI_MODERATION)) {
return next();
}
try {
const { text } = req.body;
/**
* Moderate the typed text, each quoted excerpt, and the merged blockquote+text
* exactly as the model receives it. Quotes are normalized via
* `getReferencedQuotes` first (matching `BaseClient`); moderating the merged
* string also covers content split across a quote and the typed body. The
* moderation API accepts an array of inputs.
*
* `answer` covers the HITL resume payload (POST /agents/chat/resume) for an
* ask-user question — the user's free-form text — so it's moderated like a typed
* message. A tool-approval resume carries no user text and is skipped below.
*/
let safeText = '';
if (typeof text === 'string') {
safeText = text;
} else if (typeof req.body.answer === 'string') {
safeText = req.body.answer;
}
const inputs = [];
if (safeText.length > 0) {
inputs.push(safeText);
}
const quotes = getReferencedQuotes(req.body.quotes);
if (quotes != null) {
inputs.push(...quotes);
inputs.push(mergeQuotedText(safeText, quotes));
}
// A tool-approval resume can carry user-authored text in `decisions[]`: the
// `respond` substitute result, a `reject` reason, and `edit`ed tool arguments —
// moderate all of them like typed text (edited args stringified).
if (Array.isArray(req.body.decisions)) {
for (const decision of req.body.decisions) {
if (typeof decision?.responseText === 'string' && decision.responseText.length > 0) {
inputs.push(decision.responseText);
}
if (typeof decision?.reason === 'string' && decision.reason.length > 0) {
inputs.push(decision.reason);
}
if (decision?.editedArguments != null) {
try {
const edited = JSON.stringify(decision.editedArguments);
if (typeof edited === 'string' && edited.length > 0) {
inputs.push(edited);
}
} catch {
/* ignore unstringifiable edited args */
}
}
}
}
// Nothing to moderate (e.g. a tool-approval resume with no `respond` text) —
// don't post an empty/undefined `input`, which the moderation API rejects and which
// would otherwise deny the request.
if (inputs.length === 0) {
return next();
}
const input = inputs.length > 1 ? inputs : inputs[0];
const response = await axios.post(
process.env.OPENAI_MODERATION_REVERSE_PROXY || 'https://api.openai.com/v1/moderations',
{
input,
},
{
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_MODERATION_API_KEY}`,
},
},
);
const results = response.data.results;
const flagged = results.some((result) => result.flagged);
if (flagged) {
const type = ErrorTypes.MODERATION;
const errorMessage = { type };
return await denyRequest(req, res, errorMessage);
}
} catch (error) {
logger.error('Error in moderateText:', error);
const errorMessage = 'error in moderation check';
return await denyRequest(req, res, errorMessage);
}
next();
}
module.exports = moderateText;
+11
View File
@@ -0,0 +1,11 @@
const noIndex = (req, res, next) => {
const shouldNoIndex = process.env.NO_INDEX ? process.env.NO_INDEX === 'true' : true;
if (shouldNoIndex) {
res.setHeader('X-Robots-Tag', 'noindex');
}
next();
};
module.exports = noIndex;
+35
View File
@@ -0,0 +1,35 @@
const cookies = require('cookie');
const passport = require('passport');
const { isEnabled, tenantContextMiddleware } = require('@librechat/api');
const hasPassportStrategy = (strategy) =>
typeof passport._strategy === 'function' && passport._strategy(strategy) != null;
// This middleware does not require authentication,
// but if the user is authenticated, it will set the user object
// and establish tenant ALS context.
const optionalJwtAuth = (req, res, next) => {
const cookieHeader = req.headers.cookie;
const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null;
const useOpenIdJwt =
tokenProvider === 'openid' &&
isEnabled(process.env.OPENID_REUSE_TOKENS) &&
hasPassportStrategy('openidJwt');
const callback = (err, user) => {
if (err) {
return next(err);
}
if (user) {
req.user = user;
req.authStrategy = useOpenIdJwt ? 'openidJwt' : 'jwt';
return tenantContextMiddleware(req, res, next);
}
next();
};
if (useOpenIdJwt) {
return passport.authenticate('openidJwt', { session: false }, callback)(req, res, next);
}
passport.authenticate('jwt', { session: false }, callback)(req, res, next);
};
module.exports = optionalJwtAuth;
@@ -0,0 +1,88 @@
const cookie = require('cookie');
const jwt = require('jsonwebtoken');
const { isEnabled } = require('@librechat/api');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const { SystemRoles } = require('librechat-data-provider');
const { getUserById, findSession } = require('~/models');
const verifySignedUserId = (token) => {
try {
const payload = jwt.verify(token, process.env.JWT_REFRESH_SECRET);
return typeof payload?.id === 'string' ? payload.id : null;
} catch {
return null;
}
};
const getRefreshTokenUserId = async (token) => {
const userId = verifySignedUserId(token);
if (!userId) {
return null;
}
const session = await runAsSystem(() => findSession({ userId, refreshToken: token }));
return session ? userId : null;
};
const getOpenIdUserId = (parsed, req) => {
if (parsed.token_provider !== 'openid' || !isEnabled(process.env.OPENID_REUSE_TOKENS)) {
return null;
}
const sessionRefreshToken = req.session?.openidTokens?.refreshToken;
if (!parsed.refreshToken || parsed.refreshToken !== sessionRefreshToken) {
return null;
}
return verifySignedUserId(parsed.openid_user_id);
};
/**
* Fallback auth for share file routes that are hit by `<img>`/anchor requests,
* which can't carry the bearer access token. Resolves the viewer from the
* `refreshToken` cookie (or an active OpenID session plus signed `openid_user_id`
* cookie) so non-public shared links can authorize the viewer's ACL. Never
* blocks: on any failure it leaves `req.user` unset and lets
* `canAccessSharedLink` decide (public access, 401, or 403).
*/
const optionalShareFileAuth = async (req, res, next) => {
if (req.user) {
return next();
}
try {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) {
return next();
}
const parsed = cookie.parse(cookieHeader);
const userId =
getOpenIdUserId(parsed, req) ||
(parsed.refreshToken ? await getRefreshTokenUserId(parsed.refreshToken) : null);
if (!userId) {
return next();
}
// Resolve in system context: this runs before canAccessSharedLink establishes
// the share tenant, so under strict tenant isolation a tenant-scoped User
// query would otherwise throw. The viewer's id comes from verified, active
// cookie auth; the share's tenant-scoped ACL check still gates access.
const user = await runAsSystem(() =>
getUserById(userId, '-password -__v -totpSecret -backupCodes'),
);
if (user) {
user.id = user._id.toString();
if (!user.role) {
user.role = SystemRoles.USER;
}
req.user = user;
}
} catch (error) {
logger.warn('[optionalShareFileAuth] cookie auth failed:', error?.message);
}
return next();
};
module.exports = optionalShareFileAuth;
@@ -0,0 +1,136 @@
const mockVerify = jest.fn();
const mockGetUserById = jest.fn();
const mockFindSession = jest.fn();
const mockRunAsSystem = jest.fn((fn) => fn());
jest.mock('jsonwebtoken', () => ({ verify: (...args) => mockVerify(...args) }));
jest.mock('@librechat/api', () => ({ isEnabled: (v) => v === 'true' || v === true }), {
virtual: true,
});
jest.mock(
'@librechat/data-schemas',
() => ({
logger: { warn: jest.fn(), error: jest.fn() },
runAsSystem: (...args) => mockRunAsSystem(...args),
}),
{ virtual: true },
);
jest.mock('librechat-data-provider', () => ({ SystemRoles: { USER: 'USER' } }), {
virtual: true,
});
jest.mock('~/models', () => ({
getUserById: (...args) => mockGetUserById(...args),
findSession: (...args) => mockFindSession(...args),
}));
const optionalShareFileAuth = require('./optionalShareFileAuth');
const run = async (req) => {
const next = jest.fn();
await optionalShareFileAuth(req, {}, next);
return next;
};
describe('optionalShareFileAuth', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.JWT_REFRESH_SECRET = 'test-secret';
});
it('short-circuits when a bearer user is already set (no cookie work)', async () => {
const req = { user: { id: 'u1' }, headers: { cookie: 'refreshToken=x' } };
const next = await run(req);
expect(next).toHaveBeenCalledTimes(1);
expect(mockVerify).not.toHaveBeenCalled();
expect(mockGetUserById).not.toHaveBeenCalled();
expect(mockFindSession).not.toHaveBeenCalled();
});
it('resolves the viewer from a valid refreshToken cookie with a live session', async () => {
mockVerify.mockReturnValue({ id: 'viewer-1' });
mockFindSession.mockResolvedValue({ _id: 'session-1' });
mockGetUserById.mockResolvedValue({ _id: 'viewer-1', role: 'USER' });
const req = { headers: { cookie: 'refreshToken=good.jwt' } };
const next = await run(req);
expect(next).toHaveBeenCalledTimes(1);
expect(mockVerify).toHaveBeenCalledWith('good.jwt', 'test-secret');
expect(mockFindSession).toHaveBeenCalledWith({ userId: 'viewer-1', refreshToken: 'good.jwt' });
expect(mockRunAsSystem).toHaveBeenCalledTimes(2);
expect(req.user).toMatchObject({ id: 'viewer-1', role: 'USER' });
});
it('defaults the role to USER when the record has none', async () => {
mockVerify.mockReturnValue({ id: 'viewer-2' });
mockFindSession.mockResolvedValue({ _id: 'session-2' });
mockGetUserById.mockResolvedValue({ _id: 'viewer-2' });
const req = { headers: { cookie: 'refreshToken=good.jwt' } };
await run(req);
expect(req.user.role).toBe('USER');
});
it('leaves req.user unset when there is no cookie', async () => {
const req = { headers: {} };
const next = await run(req);
expect(next).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
expect(mockGetUserById).not.toHaveBeenCalled();
});
it('leaves req.user unset when the refresh token has no live session', async () => {
mockVerify.mockReturnValue({ id: 'viewer-3' });
mockFindSession.mockResolvedValue(null);
const req = { headers: { cookie: 'refreshToken=revoked.jwt' } };
const next = await run(req);
expect(next).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
expect(mockFindSession).toHaveBeenCalledWith({
userId: 'viewer-3',
refreshToken: 'revoked.jwt',
});
expect(mockRunAsSystem).toHaveBeenCalledTimes(1);
expect(mockGetUserById).not.toHaveBeenCalled();
});
it('leaves req.user unset when the token is invalid', async () => {
mockVerify.mockImplementation(() => {
throw new Error('bad token');
});
const req = { headers: { cookie: 'refreshToken=bad' } };
const next = await run(req);
expect(next).toHaveBeenCalledTimes(1);
expect(req.user).toBeUndefined();
expect(mockGetUserById).not.toHaveBeenCalled();
});
it('uses the signed openid_user_id cookie only for active OpenID-reuse sessions', async () => {
process.env.OPENID_REUSE_TOKENS = 'true';
mockVerify.mockReturnValue({ id: 'oidc-1' });
mockGetUserById.mockResolvedValue({ _id: 'oidc-1', role: 'USER' });
const req = {
headers: {
cookie: 'token_provider=openid; refreshToken=stored-refresh; openid_user_id=signed.jwt',
},
session: { openidTokens: { refreshToken: 'stored-refresh' } },
};
await run(req);
expect(mockVerify).toHaveBeenCalledWith('signed.jwt', 'test-secret');
expect(mockFindSession).not.toHaveBeenCalled();
expect(req.user).toMatchObject({ id: 'oidc-1' });
delete process.env.OPENID_REUSE_TOKENS;
});
it('leaves req.user unset for OpenID-reuse cookies without an active matching session', async () => {
process.env.OPENID_REUSE_TOKENS = 'true';
mockVerify.mockReturnValue({ id: 'oidc-2' });
const req = {
headers: {
cookie: 'token_provider=openid; refreshToken=stale-refresh; openid_user_id=signed.jwt',
},
session: { openidTokens: { refreshToken: 'current-refresh' } },
};
await run(req);
expect(req.user).toBeUndefined();
expect(mockGetUserById).not.toHaveBeenCalled();
delete process.env.OPENID_REUSE_TOKENS;
});
});
+239
View File
@@ -0,0 +1,239 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const passport = require('passport');
const { logger } = require('@librechat/data-schemas');
const {
isEnabled,
tenantContextMiddleware,
getAuthFailureReason,
getAuthFailureErrorName,
buildSafeAuthLogContext,
formatAuthLogMessage,
maybeRefreshCloudFrontAuthCookiesMiddleware,
recordRumProxyRequest,
} = require('@librechat/api');
const hasPassportStrategy = (strategy) =>
typeof passport._strategy === 'function' && passport._strategy(strategy) != null;
const getValidOpenIdReuseUserId = (parsedCookies) => {
const openidUserId = parsedCookies.openid_user_id;
if (!openidUserId || !process.env.JWT_REFRESH_SECRET) {
return null;
}
try {
const payload = jwt.verify(openidUserId, process.env.JWT_REFRESH_SECRET);
return typeof payload === 'object' && payload != null && typeof payload.id === 'string'
? payload.id
: null;
} catch {
return null;
}
};
const getAuthenticatedUserId = (user) => user?.id?.toString?.() ?? user?._id?.toString?.();
const refreshCloudFrontCookies =
maybeRefreshCloudFrontAuthCookiesMiddleware ?? ((_req, _res, next) => next());
const getAuthStrategies = (req) => {
const cookieHeader = req.headers.cookie;
const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
const tokenProvider = parsedCookies.token_provider;
const openidReuseEnabled = isEnabled(process.env.OPENID_REUSE_TOKENS);
const openidJwtAvailable = openidReuseEnabled && hasPassportStrategy('openidJwt');
const openIdReuseUserId = getValidOpenIdReuseUserId(parsedCookies);
const useOpenIdJwt =
tokenProvider === 'openid' && openidJwtAvailable && openIdReuseUserId != null;
return {
tokenProvider,
openidReuseEnabled,
openidJwtAvailable,
openIdReuseUserId,
strategies: useOpenIdJwt ? ['openidJwt', 'jwt'] : ['jwt'],
};
};
const dropRumTelemetry = (res) => {
if (!res.headersSent) {
res.status(204).end();
}
};
// Keep in sync with packages/api/src/rum/proxy.ts; auth drops are recorded before proxy code runs.
const getRumProxyEndpoint = (req) => {
if (req.path === '/v1/traces') {
return 'traces';
}
if (req.path === '/v1/logs') {
return 'logs';
}
return 'unknown';
};
const isOpenIdReuseUser = (strategy, user, openIdReuseUserId) =>
strategy !== 'openidJwt' || getAuthenticatedUserId(user) === openIdReuseUserId;
/**
* Custom Middleware to handle JWT authentication, with support for OpenID token reuse.
* Switches between JWT and OpenID authentication based on cookies and environment settings.
*
* After successful authentication (req.user populated), automatically chains into
* `tenantContextMiddleware` to propagate request context into AsyncLocalStorage
* for downstream Mongoose tenant isolation and structured logging.
*/
const requireJwtAuth = (req, res, next) => {
const { tokenProvider, openidReuseEnabled, openidJwtAvailable, openIdReuseUserId, strategies } =
getAuthStrategies(req);
const authLogState = {
tokenProvider,
openidReuseEnabled,
openidJwtAvailable,
hasOpenIdReuseUserId: openIdReuseUserId != null,
};
let primaryFailureReason;
let primaryFailureErrorName;
let fallbackAttempted = false;
const logOpenIdFallbackAttempt = ({ fallbackStrategy, reason, errorName, status }) => {
primaryFailureReason = reason;
primaryFailureErrorName = errorName;
fallbackAttempted = true;
const message = '[requireJwtAuth] OpenID JWT auth failed; trying fallback';
const context = buildSafeAuthLogContext(req, authLogState, {
primary_strategy: 'openidJwt',
fallback_strategy: fallbackStrategy,
fallback_attempted: true,
reason,
error_name: errorName,
status,
});
logger.debug(formatAuthLogMessage(message, context), context);
};
const logAuthenticationFailure = ({ strategy, info, status, err }) => {
const message = '[requireJwtAuth] Authentication failed after all strategies';
const context = buildSafeAuthLogContext(req, authLogState, {
primary_strategy: strategies[0],
fallback_strategy: strategies[1],
fallback_attempted: fallbackAttempted,
fallback_succeeded: false,
attempted_strategies: strategies,
final_strategy: strategy,
reason: getAuthFailureReason(err, info),
error_name: getAuthFailureErrorName(err, info),
status: status || 401,
});
const log = fallbackAttempted ? logger.warn : logger.debug;
log.call(logger, formatAuthLogMessage(message, context), context);
};
const logFallbackSuccess = (strategy) => {
if (!fallbackAttempted || strategy !== 'jwt') {
return;
}
const message = '[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure';
const context = buildSafeAuthLogContext(req, authLogState, {
auth_strategy: 'jwt',
primary_strategy: 'openidJwt',
fallback_strategy: 'jwt',
fallback_attempted: true,
fallback_succeeded: true,
primary_failure_reason: primaryFailureReason,
reason: primaryFailureReason,
error_name: primaryFailureErrorName,
});
logger.debug(formatAuthLogMessage(message, context), context);
};
const authenticateWithStrategy = (index) => {
const strategy = strategies[index];
passport.authenticate(strategy, { session: false }, (err, user, info, status) => {
if (err) {
return next(err);
}
if (!user) {
if (index + 1 < strategies.length) {
logOpenIdFallbackAttempt({
fallbackStrategy: strategies[index + 1],
reason: getAuthFailureReason(err, info),
errorName: getAuthFailureErrorName(err, info),
status: status || 401,
});
return authenticateWithStrategy(index + 1);
}
logAuthenticationFailure({ strategy, info, status, err });
return res.status(status || 401).json({
message: info?.message || 'Unauthorized',
});
}
if (strategy === 'openidJwt' && getAuthenticatedUserId(user) !== openIdReuseUserId) {
if (index + 1 < strategies.length) {
logOpenIdFallbackAttempt({
fallbackStrategy: strategies[index + 1],
reason: 'openid user-id mismatch',
status: 401,
});
return authenticateWithStrategy(index + 1);
}
logAuthenticationFailure({ strategy, info, status: 401, err });
return res.status(401).json({ message: 'Unauthorized' });
}
req.user = user;
req.authStrategy = strategy;
logFallbackSuccess(strategy);
tenantContextMiddleware(req, res, (tenantErr) => {
if (tenantErr) {
return next(tenantErr);
}
refreshCloudFrontCookies(req, res, next);
});
})(req, res, next);
};
authenticateWithStrategy(0);
};
const requireRumProxyAuth = (req, res, next) => {
const { openIdReuseUserId, strategies } = getAuthStrategies(req);
const endpoint = getRumProxyEndpoint(req);
let authErrorSeen = false;
const dropTelemetry = () => {
recordRumProxyRequest(endpoint, authErrorSeen ? 'auth_error' : 'auth_drop');
dropRumTelemetry(res);
};
const finishAuthentication = (strategy, user) => {
req.user = user;
req.authStrategy = strategy;
next();
};
let nextStrategyIndex = 0;
const tryNextStrategy = () => {
const strategy = strategies[nextStrategyIndex];
nextStrategyIndex += 1;
if (!strategy) {
dropTelemetry();
return;
}
passport.authenticate(strategy, { session: false }, (err, user) => {
authErrorSeen = authErrorSeen || err != null;
if (err || !user || !isOpenIdReuseUser(strategy, user, openIdReuseUserId)) {
tryNextStrategy();
return;
}
finishAuthentication(strategy, user);
})(req, res, next);
};
tryNextStrategy();
};
module.exports = requireJwtAuth;
module.exports.requireRumProxyAuth = requireRumProxyAuth;
+22
View File
@@ -0,0 +1,22 @@
const passport = require('passport');
const requireLdapAuth = (req, res, next) => {
passport.authenticate('ldapauth', (err, user, info) => {
if (err) {
console.log({
title: '(requireLdapAuth) Error at passport.authenticate',
parameters: [{ name: 'error', value: err }],
});
return next(err);
}
if (!user) {
console.log({
title: '(requireLdapAuth) Error: No user',
});
return res.status(404).send(info);
}
req.user = user;
next();
})(req, res, next);
};
module.exports = requireLdapAuth;
+23
View File
@@ -0,0 +1,23 @@
const passport = require('passport');
const { logger } = require('@librechat/data-schemas');
const requireLocalAuth = (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) {
logger.error('[requireLocalAuth] Error at passport.authenticate:', err);
return next(err);
}
if (!user) {
logger.debug('[requireLocalAuth] Error: No user');
return res.status(404).send(info);
}
if (info && info.message) {
logger.debug('[requireLocalAuth] Error: ' + info.message);
return res.status(422).send({ message: info.message });
}
req.user = user;
next();
})(req, res, next);
};
module.exports = requireLocalAuth;
+370
View File
@@ -0,0 +1,370 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { checkAccess, generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { getRoleByName } = require('~/models');
const { Role } = require('~/db/models');
// Mock the logger from @librechat/data-schemas
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
},
}));
// Mock the cache to use a simple in-memory implementation
const mockCache = new Map();
jest.mock('~/cache/getLogStores', () => {
return jest.fn(() => ({
get: jest.fn(async (key) => mockCache.get(key)),
set: jest.fn(async (key, value) => mockCache.set(key, value)),
clear: jest.fn(async () => mockCache.clear()),
}));
});
describe('Access Middleware', () => {
let mongoServer;
let req, res, next;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
mockCache.clear(); // Clear the cache between tests
// Create test roles
await Role.create({
name: 'user',
permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: true,
},
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
},
});
await Role.create({
name: 'admin',
permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: true,
},
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARE]: true,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
},
});
// Create limited role with no AGENTS permissions
await Role.create({
name: 'limited',
permissions: {
// Explicitly set AGENTS permissions to false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
},
// Has permissions for other types
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
},
},
});
req = {
user: { id: 'user123', role: 'user' },
body: {},
originalUrl: '/test',
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
jest.clearAllMocks();
});
describe('checkAccess', () => {
test('should return false if user is not provided', async () => {
const result = await checkAccess({
user: null,
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return true if user has required permission', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(true);
});
test('should return false if user lacks required permission', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return false if user has only some of multiple permissions', async () => {
// User has USE but not CREATE, so should fail when checking for both
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE, Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return true if user has all of multiple permissions', async () => {
// Admin has both USE and CREATE
const result = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE, Permissions.USE],
getRoleByName,
});
expect(result).toBe(true);
});
test('should check body properties when permission is not directly granted', async () => {
const req = { body: { id: 'agent123' } };
const result = await checkAccess({
req,
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.UPDATE],
bodyProps: {
[Permissions.UPDATE]: ['id'],
},
checkObject: req.body,
getRoleByName,
});
expect(result).toBe(true);
});
test('should return false if role is not found', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'nonexistent' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return false if role has no permissions for the requested type', async () => {
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'limited' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should handle admin role with all permissions', async () => {
const createResult = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
expect(createResult).toBe(true);
const shareResult = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.SHARE],
getRoleByName,
});
expect(shareResult).toBe(true);
});
});
describe('generateCheckAccess', () => {
test('should call next() when user has required permission', async () => {
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should return 403 when user lacks permission', async () => {
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
test('should check body properties when configured', async () => {
req.body = { agentId: 'agent123', description: 'test' };
const bodyProps = {
[Permissions.CREATE]: ['agentId'],
};
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
bodyProps,
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should handle database errors gracefully', async () => {
// Mock getRoleByName to throw an error
const mockGetRoleByName = jest
.fn()
.mockRejectedValue(new Error('Database connection failed'));
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName: mockGetRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
message: expect.stringContaining('Server error:'),
});
});
test('should work with multiple permission types', async () => {
req.user.role = 'admin';
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle missing user gracefully', async () => {
req.user = null;
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
test('should handle role with no AGENTS permissions', async () => {
await Role.create({
name: 'noaccess',
permissions: {
// Explicitly set AGENTS with all permissions false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARE]: false,
},
},
});
req.user.role = 'noaccess';
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
});
});
+14
View File
@@ -0,0 +1,14 @@
const { SystemRoles } = require('librechat-data-provider');
function checkAdmin(req, res, next) {
try {
if (req.user.role !== SystemRoles.ADMIN) {
return res.status(403).json({ message: 'Forbidden' });
}
next();
} catch (error) {
res.status(500).json({ message: 'Internal Server Error' });
}
}
module.exports = checkAdmin;
@@ -0,0 +1,14 @@
const { generateCapabilityCheck, capabilityContextMiddleware } = require('@librechat/api');
const { getUserPrincipals, hasCapabilityForPrincipals } = require('~/models');
const { hasCapability, requireCapability, hasConfigCapability } = generateCapabilityCheck({
getUserPrincipals,
hasCapabilityForPrincipals,
});
module.exports = {
hasCapability,
requireCapability,
hasConfigCapability,
capabilityContextMiddleware,
};
+17
View File
@@ -0,0 +1,17 @@
/**
* NOTE: hasCapability, requireCapability, hasConfigCapability, and
* capabilityContextMiddleware are intentionally NOT re-exported here.
*
* capabilities.js depends on ~/models, and the middleware barrel
* (middleware/index.js) is frequently required by modules that are
* themselves loaded while the barrel is still initialising — creating
* a circular-require that silently returns an empty exports object.
*
* Always import capability helpers directly:
* require('~/server/middleware/roles/capabilities')
*/
const checkAdmin = require('./admin');
module.exports = {
checkAdmin,
};
+12
View File
@@ -0,0 +1,12 @@
function setHeaders(req, res, next) {
res.writeHead(200, {
Connection: 'keep-alive',
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no',
});
next();
}
module.exports = setHeaders;
@@ -0,0 +1,25 @@
const jwt = require('jsonwebtoken');
const setTwoFactorTempUser = (req, _res, next) => {
if (req.user?.id || req.user?._id) {
return next();
}
const { tempToken } = req.body ?? {};
if (!tempToken) {
return next();
}
try {
const payload = jwt.verify(tempToken, process.env.JWT_SECRET);
if (payload?.userId) {
req.user = { id: payload.userId };
}
} catch {
return next();
}
return next();
};
module.exports = setTwoFactorTempUser;
@@ -0,0 +1,474 @@
const jwt = require('jsonwebtoken');
const createValidateImageRequest = require('~/server/middleware/validateImageRequest');
// Mock only isEnabled, keep getBasePath real so it reads process.env.DOMAIN_CLIENT
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn(),
}));
const { isEnabled } = require('@librechat/api');
describe('validateImageRequest middleware', () => {
let req, res, next, validateImageRequest;
const validObjectId = '65cfb246f7ecadb8b1e8036b';
beforeEach(() => {
jest.clearAllMocks();
req = {
headers: {},
originalUrl: '',
};
res = {
status: jest.fn().mockReturnThis(),
send: jest.fn(),
};
next = jest.fn();
process.env.JWT_REFRESH_SECRET = 'test-secret';
process.env.OPENID_REUSE_TOKENS = 'false';
delete process.env.DOMAIN_CLIENT; // Clear for tests without basePath
// Default: OpenID token reuse disabled
isEnabled.mockReturnValue(false);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Factory function', () => {
test('should return a pass-through middleware if secureImageLinks is false', async () => {
const middleware = createValidateImageRequest(false);
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
test('should return validation middleware if secureImageLinks is true', async () => {
validateImageRequest = createValidateImageRequest(true);
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
});
describe('Standard LibreChat token flow', () => {
beforeEach(() => {
validateImageRequest = createValidateImageRequest(true);
});
test('should return 401 if refresh token is not provided', async () => {
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
test('should return 403 if refresh token is invalid', async () => {
req.headers.cookie = 'refreshToken=invalid-token';
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should return 403 if refresh token is expired', async () => {
const expiredToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${expiredToken}`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should call next() for valid image path', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/example.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should return 403 for invalid image path', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/example.jpg'; // Different ObjectId
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should allow agent avatar pattern for any valid ObjectId', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should prevent file traversal attempts', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
const traversalAttempts = [
`/images/${validObjectId}/../../../etc/passwd`,
`/images/${validObjectId}/..%2F..%2F..%2Fetc%2Fpasswd`,
`/images/${validObjectId}/image.jpg/../../../etc/passwd`,
`/images/${validObjectId}/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd`,
];
for (const attempt of traversalAttempts) {
req.originalUrl = attempt;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
jest.clearAllMocks();
// Reset mocks for next iteration
res.status = jest.fn().mockReturnThis();
res.send = jest.fn();
}
});
test('should handle URL encoded characters in valid paths', async () => {
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/image%20with%20spaces.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('OpenID token flow', () => {
beforeEach(() => {
validateImageRequest = createValidateImageRequest(true);
// Enable OpenID token reuse
isEnabled.mockReturnValue(true);
process.env.OPENID_REUSE_TOKENS = 'true';
});
test('should return 403 if no OpenID user ID cookie when token_provider is openid', async () => {
req.headers.cookie = 'refreshToken=dummy-token; token_provider=openid';
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should validate JWT-signed user ID for OpenID flow', async () => {
const signedUserId = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
req.originalUrl = `/images/${validObjectId}/example.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should return 403 for invalid JWT-signed user ID', async () => {
req.headers.cookie =
'refreshToken=dummy-token; token_provider=openid; openid_user_id=invalid-jwt';
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should return 403 for expired JWT-signed user ID', async () => {
const expiredSignedUserId = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) - 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${expiredSignedUserId}`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should validate image path against JWT-signed user ID', async () => {
const signedUserId = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
const differentObjectId = '65cfb246f7ecadb8b1e8036c';
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
req.originalUrl = `/images/${differentObjectId}/example.jpg`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should allow agent avatars in OpenID flow', async () => {
const signedUserId = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=dummy-token; token_provider=openid; openid_user_id=${signedUserId}`;
req.originalUrl = '/images/65cfb246f7ecadb8b1e8036c/agent-avatar-12345.png';
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
});
describe('Security edge cases', () => {
let validToken;
beforeEach(() => {
validateImageRequest = createValidateImageRequest(true);
validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
});
test('should handle very long image filenames', async () => {
const longFilename = 'a'.repeat(1000) + '.jpg';
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/${longFilename}`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle URLs with maximum practical length', async () => {
// Most browsers support URLs up to ~2000 characters
const longFilename = 'x'.repeat(1900) + '.jpg';
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/${longFilename}`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should accept URLs just under the 2048 limit', async () => {
// Create a URL exactly 2047 characters long
const baseLength = `/images/${validObjectId}/`.length + '.jpg'.length;
const filenameLength = 2047 - baseLength;
const filename = 'a'.repeat(filenameLength) + '.jpg';
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/${filename}`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle malformed URL encoding gracefully', async () => {
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test%ZZinvalid.jpg`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should reject URLs with null bytes', async () => {
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test\x00.jpg`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should handle URLs with repeated slashes', async () => {
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}//test.jpg`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should reject extremely long URLs as potential DoS', async () => {
// Create a URL longer than 2048 characters
const baseLength = `/images/${validObjectId}/`.length + '.jpg'.length;
const filenameLength = 2049 - baseLength; // Ensure total length exceeds 2048
const extremelyLongFilename = 'x'.repeat(filenameLength) + '.jpg';
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/${extremelyLongFilename}`;
// Verify our test URL is actually too long
expect(req.originalUrl.length).toBeGreaterThan(2048);
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
});
describe('basePath functionality', () => {
let originalDomainClient;
beforeEach(() => {
originalDomainClient = process.env.DOMAIN_CLIENT;
});
afterEach(() => {
process.env.DOMAIN_CLIENT = originalDomainClient;
});
test('should validate image paths with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should validate agent avatar paths with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/agent-avatar.png`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should reject image paths without base path when DOMAIN_CLIENT is set', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should handle empty base path (root deployment)', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle missing DOMAIN_CLIENT', async () => {
delete process.env.DOMAIN_CLIENT;
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle nested subdirectories in base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/apps/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should prevent path traversal with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/../../../etc/passwd`;
await validateImageRequest(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith('Access Denied');
});
test('should handle URLs with query parameters and base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg?version=1`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle URLs with fragments and base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg#section`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle HTTPS URLs with base path', async () => {
process.env.DOMAIN_CLIENT = 'https://example.com/librechat';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle invalid DOMAIN_CLIENT gracefully', async () => {
process.env.DOMAIN_CLIENT = 'not-a-valid-url';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}`;
req.originalUrl = `/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
test('should handle OpenID flow with base path', async () => {
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
process.env.OPENID_REUSE_TOKENS = 'true';
const validToken = jwt.sign(
{ id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 },
process.env.JWT_REFRESH_SECRET,
);
req.headers.cookie = `refreshToken=${validToken}; token_provider=openid; openid_user_id=${validToken}`;
req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`;
await validateImageRequest(req, res, next);
expect(next).toHaveBeenCalled();
});
});
});
+32
View File
@@ -0,0 +1,32 @@
const uap = require('ua-parser-js');
const { ViolationTypes } = require('librechat-data-provider');
const { handleError } = require('@librechat/api');
const { logViolation } = require('../../cache');
/**
* Middleware to parse User-Agent header and check if it's from a recognized browser.
* If the User-Agent is not recognized as a browser, logs a violation and sends an error response.
*
* @function
* @async
* @param {Object} req - Express request object.
* @param {Object} res - Express response object.
* @param {Function} next - Express next middleware function.
* @returns {void} Sends an error response if the User-Agent is not recognized as a browser.
*
* @example
* app.use(uaParser);
*/
async function uaParser(req, res, next) {
const { NON_BROWSER_VIOLATION_SCORE: score = 20 } = process.env;
const ua = uap(req.headers['user-agent']);
if (!ua.browser.name) {
const type = ViolationTypes.NON_BROWSER;
await logViolation(req, res, type, { type }, score);
return handleError(res, { message: 'Illegal request' });
}
next();
}
module.exports = uaParser;
@@ -0,0 +1,82 @@
const { isEnabled } = require('@librechat/api');
const { Constants, ViolationTypes, Time } = require('librechat-data-provider');
const denyRequest = require('~/server/middleware/denyRequest');
const { logViolation, getLogStores } = require('~/cache');
const { searchConversation } = require('~/models');
const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {};
/**
* Helper function to get conversationId from different request body structures.
* @param {Object} body - The request body.
* @returns {string|undefined} The conversationId.
*/
const getConversationId = (body) => {
return body.conversationId ?? body.arg?.conversationId;
};
/**
* Middleware to validate user's authorization for a conversation.
*
* This middleware checks if a user has the right to access a specific conversation.
* If the user doesn't have access, an error is returned. If the conversation doesn't exist,
* a not found error is returned. If the access is valid, the middleware allows the request to proceed.
* If the `cache` store is not available, the middleware will skip its logic.
*
* @function
* @param {ServerRequest} req - Express request object containing user information.
* @param {Express.Response} res - Express response object.
* @param {function} next - Express next middleware function.
* @throws {Error} Throws an error if the user doesn't have access to the conversation.
*/
const validateConvoAccess = async (req, res, next) => {
const namespace = ViolationTypes.CONVO_ACCESS;
const cache = getLogStores(namespace);
const conversationId = getConversationId(req.body);
if (!conversationId || conversationId === Constants.NEW_CONVO) {
return next();
}
const userId = req.user?.id ?? req.user?._id ?? '';
const type = ViolationTypes.CONVO_ACCESS;
const key = `${isEnabled(USE_REDIS) ? namespace : ''}:${userId}:${conversationId}`;
try {
if (cache) {
const cachedAccess = await cache.get(key);
if (cachedAccess === 'authorized') {
return next();
}
}
const conversation = await searchConversation(conversationId);
if (!conversation) {
return next();
}
if (conversation.user !== userId) {
const errorMessage = {
type,
error: 'User not authorized for this conversation',
};
if (cache) {
await logViolation(req, res, type, errorMessage, score);
}
return await denyRequest(req, res, errorMessage);
}
if (cache) {
await cache.set(key, 'authorized', Time.TEN_MINUTES);
}
next();
} catch (error) {
console.error('Error validating conversation access:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
module.exports = validateConvoAccess;
+4
View File
@@ -0,0 +1,4 @@
const validateConvoAccess = require('./convoAccess');
module.exports = {
validateConvoAccess,
};
@@ -0,0 +1,162 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, getBasePath } = require('@librechat/api');
const OBJECT_ID_LENGTH = 24;
const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i;
/**
* Validates if a string is a valid MongoDB ObjectId
* @param {string} id - String to validate
* @returns {boolean} - Whether string is a valid ObjectId format
*/
function isValidObjectId(id) {
if (typeof id !== 'string') {
return false;
}
if (id.length !== OBJECT_ID_LENGTH) {
return false;
}
return OBJECT_ID_PATTERN.test(id);
}
/**
* Validates a LibreChat refresh token
* @param {string} refreshToken - The refresh token to validate
* @returns {{valid: boolean, userId?: string, error?: string}} - Validation result
*/
function validateToken(refreshToken) {
try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
if (!isValidObjectId(payload.id)) {
return { valid: false, error: 'Invalid User ID' };
}
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
if (payload.exp < currentTimeInSeconds) {
return { valid: false, error: 'Refresh token expired' };
}
return { valid: true, userId: payload.id };
} catch (err) {
logger.warn('[validateToken]', err);
return { valid: false, error: 'Invalid token' };
}
}
/**
* Factory to create the `validateImageRequest` middleware with configured secureImageLinks
* @param {boolean} [secureImageLinks] - Whether secure image links are enabled
*/
function createValidateImageRequest(secureImageLinks) {
if (!secureImageLinks) {
return (_req, _res, next) => next();
}
/**
* Middleware to validate image request.
* Supports both LibreChat refresh tokens and OpenID JWT tokens.
* Must be set by `secureImageLinks` via custom config file.
*/
return async function validateImageRequest(req, res, next) {
try {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) {
logger.warn('[validateImageRequest] No cookies provided');
return res.status(401).send('Unauthorized');
}
const parsedCookies = cookies.parse(cookieHeader);
const tokenProvider = parsedCookies.token_provider;
let userIdForPath;
if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
/** For OpenID users with OPENID_REUSE_TOKENS, use openid_user_id cookie */
const openidUserId = parsedCookies.openid_user_id;
if (!openidUserId) {
logger.warn('[validateImageRequest] No OpenID user ID cookie found');
return res.status(403).send('Access Denied');
}
const validationResult = validateToken(openidUserId);
if (!validationResult.valid) {
logger.warn(`[validateImageRequest] ${validationResult.error}`);
return res.status(403).send('Access Denied');
}
userIdForPath = validationResult.userId;
} else {
/**
* For non-OpenID users (or OpenID without REUSE_TOKENS), use refreshToken from cookies.
* These users authenticate via setAuthTokens() which stores refreshToken in cookies.
*/
const refreshToken = parsedCookies.refreshToken;
if (!refreshToken) {
logger.warn('[validateImageRequest] Token not provided');
return res.status(401).send('Unauthorized');
}
const validationResult = validateToken(refreshToken);
if (!validationResult.valid) {
logger.warn(`[validateImageRequest] ${validationResult.error}`);
return res.status(403).send('Access Denied');
}
userIdForPath = validationResult.userId;
}
if (!userIdForPath) {
logger.warn('[validateImageRequest] No user ID available for path validation');
return res.status(403).send('Access Denied');
}
const MAX_URL_LENGTH = 2048;
if (req.originalUrl.length > MAX_URL_LENGTH) {
logger.warn('[validateImageRequest] URL too long');
return res.status(403).send('Access Denied');
}
if (req.originalUrl.includes('\x00')) {
logger.warn('[validateImageRequest] URL contains null byte');
return res.status(403).send('Access Denied');
}
let fullPath;
try {
fullPath = decodeURIComponent(req.originalUrl);
} catch {
logger.warn('[validateImageRequest] Invalid URL encoding');
return res.status(403).send('Access Denied');
}
const basePath = getBasePath();
const imagesPath = `${basePath}/images`;
const agentAvatarPattern = new RegExp(
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[a-f0-9]{24}/agent-[^/]*$`,
);
if (agentAvatarPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated');
return next();
}
const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pathPattern = new RegExp(
`^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`,
);
if (pathPattern.test(fullPath)) {
logger.debug('[validateImageRequest] Image request validated');
next();
} else {
logger.warn('[validateImageRequest] Invalid image path');
res.status(403).send('Access Denied');
}
} catch (error) {
logger.error('[validateImageRequest] Error:', error);
res.status(500).send('Internal Server Error');
}
};
}
module.exports = createValidateImageRequest;
@@ -0,0 +1,3 @@
const { validateMessageReq } = require('./messageValidation');
module.exports = validateMessageReq;
+68
View File
@@ -0,0 +1,68 @@
const { handleError } = require('@librechat/api');
const { ViolationTypes } = require('librechat-data-provider');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { getEndpointsConfig } = require('~/server/services/Config');
const { logViolation } = require('~/cache');
const MAX_MODEL_STRING_LENGTH = 256;
const MODEL_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.:/@+-]*$/;
/**
* Validates the model of the request.
*
* @async
* @param {ServerRequest} req - The Express request object.
* @param {Express.Response} res - The Express response object.
* @param {Function} next - The Express next function.
*/
const validateModel = async (req, res, next) => {
const { endpoint } = req.body;
const rawModel = req.body.model;
if (!rawModel || typeof rawModel !== 'string') {
return handleError(res, { text: 'Model not provided' });
}
const model = rawModel.trim();
if (!model || model.length > MAX_MODEL_STRING_LENGTH || !MODEL_PATTERN.test(model)) {
return handleError(res, { text: 'Invalid model identifier' });
}
req.body.model = model;
const endpointsConfig = await getEndpointsConfig(req);
const endpointConfig = endpointsConfig?.[endpoint];
if (endpointConfig?.userProvide) {
return next();
}
const modelsConfig = await getModelsConfig(req);
if (!modelsConfig) {
return handleError(res, { text: 'Models not loaded' });
}
const availableModels = modelsConfig[endpoint];
if (!availableModels) {
return handleError(res, { text: 'Endpoint models not loaded' });
}
let validModel = !!availableModels.find((availableModel) => availableModel === model);
if (validModel) {
return next();
}
const { ILLEGAL_MODEL_REQ_SCORE: score = 1 } = process.env ?? {};
const type = ViolationTypes.ILLEGAL_MODEL_REQUEST;
const errorMessage = {
type,
};
await logViolation(req, res, type, errorMessage, score);
return handleError(res, { text: 'Illegal model request' });
};
module.exports = validateModel;
@@ -0,0 +1,13 @@
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
function validatePasswordReset(req, res, next) {
if (isEnabled(process.env.ALLOW_PASSWORD_RESET)) {
next();
} else {
logger.warn(`Password reset attempt while not allowed. IP: ${req.ip}`);
res.status(403).send('Password reset is not allowed.');
}
}
module.exports = validatePasswordReset;
@@ -0,0 +1,17 @@
const { isEnabled } = require('@librechat/api');
function validateRegistration(req, res, next) {
if (req.invite) {
return next();
}
if (isEnabled(process.env.ALLOW_REGISTRATION)) {
next();
} else {
return res.status(403).json({
message: 'Registration is not allowed.',
});
}
}
module.exports = validateRegistration;