LibreChat/api/server/middleware/checkPeoplePickerAccess.spec.js
Danny Avila 2f09d29c71
🛂 fix: Validate types Query Param in People Picker Access Middleware (#12276)
* 🛂 fix: Validate `types` query param in people picker access middleware

checkPeoplePickerAccess only inspected `req.query.type` (singular),
allowing callers to bypass type-specific permission checks by using
the `types` (plural) parameter accepted by the controller. Now both
`type` and `types` are collected and each requested principal type is
validated against the caller's role permissions.

* 🛂 refactor: Hoist valid types constant, improve logging, and add edge-case tests

- Hoist VALID_PRINCIPAL_TYPES to module-level Set to avoid per-request allocation
- Include both `type` and `types` in error log for debuggability
- Restore detailed JSDoc documenting per-type permission requirements
- Add missing .json() assertion on partial-denial test
- Add edge-case tests: all-invalid types, empty string types, PrincipalType.PUBLIC

* 🏷️ fix: Align TPrincipalSearchParams with actual controller API

The stale type used `type` (singular) but the controller and all callers
use `types` (plural array). Aligns with PrincipalSearchParams in
types/queries.ts.
2026-03-17 02:46:11 -04:00

416 lines
12 KiB
JavaScript

const { logger } = require('@librechat/data-schemas');
const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider');
const { checkPeoplePickerAccess } = require('./checkPeoplePickerAccess');
const { getRoleByName } = require('~/models/Role');
jest.mock('~/models/Role');
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();
});
});