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
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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user