🗝️ fix: Enforce Skill Share Role Permission (#13062)

* fix: enforce skill share role permission

* fix: preserve share capability bypass

* refactor: move share policy middleware to api package

* style: order share middleware imports

* fix: satisfy share middleware type checks

* test: cover share policy resource types
This commit is contained in:
Danny Avila 2026-05-11 09:39:58 -04:00 committed by GitHub
parent 7631366f52
commit 0449c423a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 882 additions and 90 deletions

View file

@ -10,3 +10,4 @@ export { preAuthTenantMiddleware } from './preAuthTenant';
export * from './concurrency';
export * from './checkBalance';
export * from './remoteAgentAuth';
export * from './share';

View file

@ -0,0 +1,264 @@
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
warn: jest.fn(),
error: jest.fn(),
},
}));
import { Permissions, PermissionTypes, ResourceType } from 'librechat-data-provider';
import type { NextFunction, Response } from 'express';
import type { IRole } from '@librechat/data-schemas';
import type { ServerRequest } from '~/types/http';
import type { SharePolicyDeps } from './share';
import { createSharePolicyMiddleware } from './share';
type ShareTestRequest = ServerRequest & {
params: {
resourceType?: string;
};
body: ServerRequest['body'] & {
public?: boolean;
};
};
const createResponse = (): Response => {
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
} as Partial<Response>;
return res as Response;
};
const createRequest = (overrides: Partial<ShareTestRequest> = {}): ShareTestRequest =>
({
user: { id: 'user123', role: 'USER' },
params: { resourceType: ResourceType.SKILL },
body: {},
...overrides,
}) as ShareTestRequest;
const createRole = (permissions: IRole['permissions']): IRole =>
({
permissions,
}) as IRole;
describe('createSharePolicyMiddleware', () => {
let getRoleByName: jest.MockedFunction<SharePolicyDeps['getRoleByName']>;
let hasCapability: jest.MockedFunction<SharePolicyDeps['hasCapability']>;
let next: jest.MockedFunction<NextFunction>;
beforeEach(() => {
getRoleByName = jest.fn();
hasCapability = jest.fn().mockResolvedValue(false);
next = jest.fn();
});
it('skips public sharing checks when public is not true', async () => {
const { checkSharePublicAccess } = createSharePolicyMiddleware({
getRoleByName,
hasCapability,
});
const req = createRequest({ body: { public: false } });
const res = createResponse();
await checkSharePublicAccess(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(getRoleByName).not.toHaveBeenCalled();
});
it('blocks non-public skill sharing when role SKILLS.SHARE is disabled', async () => {
const { checkShareAccess } = createSharePolicyMiddleware({
getRoleByName,
hasCapability,
});
getRoleByName.mockResolvedValue(
createRole({
[PermissionTypes.SKILLS]: {
[Permissions.SHARE]: false,
[Permissions.SHARE_PUBLIC]: false,
},
}),
);
const req = createRequest();
const res = createResponse();
await checkShareAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: `You do not have permission to share ${ResourceType.SKILL} resources`,
});
expect(next).not.toHaveBeenCalled();
});
it('allows non-public skill sharing when role SKILLS.SHARE is enabled', async () => {
const { checkShareAccess } = createSharePolicyMiddleware({
getRoleByName,
hasCapability,
});
getRoleByName.mockResolvedValue(
createRole({
[PermissionTypes.SKILLS]: {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: false,
},
}),
);
const req = createRequest();
const res = createResponse();
await checkShareAccess(req, res, next);
expect(hasCapability).toHaveBeenCalledWith(
{ id: 'user123', role: 'USER', tenantId: undefined },
'manage:skills',
);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('preserves resource management capability bypass for skills', async () => {
const { checkShareAccess } = createSharePolicyMiddleware({
getRoleByName,
hasCapability,
});
hasCapability.mockResolvedValue(true);
const req = createRequest();
const res = createResponse();
await checkShareAccess(req, res, next);
expect(hasCapability).toHaveBeenCalledWith(
{ id: 'user123', role: 'USER', tenantId: undefined },
'manage:skills',
);
expect(getRoleByName).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('still requires SHARE_PUBLIC when public sharing is enabled', async () => {
const { checkSharePublicAccess } = createSharePolicyMiddleware({
getRoleByName,
hasCapability,
});
getRoleByName.mockResolvedValue(
createRole({
[PermissionTypes.SKILLS]: {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: false,
},
}),
);
const req = createRequest({ body: { public: true } });
const res = createResponse();
await checkSharePublicAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
message: `You do not have permission to share ${ResourceType.SKILL} resources publicly`,
});
expect(next).not.toHaveBeenCalled();
});
it('reuses the role permission lookup for public sharing checks', async () => {
const { checkShareAccess, checkSharePublicAccess } = createSharePolicyMiddleware({
getRoleByName,
hasCapability,
});
getRoleByName.mockResolvedValue(
createRole({
[PermissionTypes.SKILLS]: {
[Permissions.SHARE]: true,
[Permissions.SHARE_PUBLIC]: true,
},
}),
);
const req = createRequest({ body: { public: true } });
const res = createResponse();
await checkShareAccess(req, res, next);
await checkSharePublicAccess(req, res, next);
expect(getRoleByName).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledTimes(2);
expect(res.status).not.toHaveBeenCalled();
});
it('returns 401 when user is not authenticated', async () => {
const { checkShareAccess } = createSharePolicyMiddleware({
getRoleByName,
hasCapability,
});
const req = createRequest({ user: undefined });
const res = createResponse();
await checkShareAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
message: 'Authentication required',
});
expect(next).not.toHaveBeenCalled();
});
it('returns 400 for unsupported resource type', async () => {
const { checkShareAccess } = createSharePolicyMiddleware({
getRoleByName,
hasCapability,
});
const req = createRequest({ params: { resourceType: 'unsupported' } });
const res = createResponse();
await checkShareAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: 'Bad Request',
message: 'Unsupported resource type for sharing: unsupported',
});
});
it('returns 403 when role has no permissions object', async () => {
const { checkShareAccess } = createSharePolicyMiddleware({
getRoleByName,
hasCapability,
});
const role = createRole({});
Object.defineProperty(role, 'permissions', { value: null });
getRoleByName.mockResolvedValue(role);
const req = createRequest();
const res = createResponse();
await checkShareAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('returns 500 when role lookup fails', async () => {
const { checkShareAccess } = createSharePolicyMiddleware({
getRoleByName,
hasCapability,
});
getRoleByName.mockRejectedValue(new Error('Database error'));
const req = createRequest();
const res = createResponse();
await checkShareAccess(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: 'Failed to check sharing permissions',
});
});
});

View file

@ -0,0 +1,248 @@
import { logger, ResourceCapabilityMap } from '@librechat/data-schemas';
import { Permissions, PermissionTypes, ResourceType } from 'librechat-data-provider';
import type { NextFunction, Response } from 'express';
import type { IRole } from '@librechat/data-schemas';
import type { CapabilityUser, HasCapabilityFn } from './capabilities';
import type { RequestBody, ServerRequest } from '~/types/http';
type ShareResourcePermissions = Partial<Record<Permissions, boolean>>;
interface SharePermissionCache {
cacheKey: string;
resourcePerms: ShareResourcePermissions;
}
type ShareRequest = ServerRequest & {
params: {
resourceType?: string;
};
body: RequestBody & {
public?: boolean;
};
sharePermissionContext?: SharePermissionCache;
};
interface ShareContext {
user: CapabilityUser;
resourceType: ResourceType;
permissionType: PermissionTypes;
}
export interface SharePolicyDeps {
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
hasCapability: HasCapabilityFn;
}
type ShareMiddleware = (
req: ShareRequest,
res: Response,
next: NextFunction,
) => Promise<Response | void>;
const resourceToPermissionType: Record<ResourceType, PermissionTypes> = {
[ResourceType.AGENT]: PermissionTypes.AGENTS,
[ResourceType.PROMPTGROUP]: PermissionTypes.PROMPTS,
[ResourceType.MCPSERVER]: PermissionTypes.MCP_SERVERS,
[ResourceType.REMOTE_AGENT]: PermissionTypes.REMOTE_AGENTS,
[ResourceType.SKILL]: PermissionTypes.SKILLS,
};
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function getShareContext(req: ShareRequest, res: Response, action: string): ShareContext | null {
const { user } = req;
const role = user?.role;
if (!user || !role) {
res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
return null;
}
const resourceType = req.params.resourceType as ResourceType | undefined;
const permissionType = resourceType ? resourceToPermissionType[resourceType] : undefined;
if (!resourceType || !permissionType) {
res.status(400).json({
error: 'Bad Request',
message: `Unsupported resource type for ${action}: ${req.params.resourceType}`,
});
return null;
}
return {
user: {
id: user.id,
role,
tenantId: user.tenantId,
},
resourceType,
permissionType,
};
}
export function createSharePolicyMiddleware({ getRoleByName, hasCapability }: SharePolicyDeps): {
checkShareAccess: ShareMiddleware;
checkSharePublicAccess: ShareMiddleware;
} {
async function getResourcePerms(
req: ShareRequest,
res: Response,
action: string,
context?: ShareContext,
): Promise<{
user: CapabilityUser;
resourceType: ResourceType;
resourcePerms: ShareResourcePermissions;
} | null> {
const resolvedContext = context ?? getShareContext(req, res, action);
if (!resolvedContext) {
return null;
}
const { user, resourceType, permissionType } = resolvedContext;
const cacheKey = `${user.role}:${resourceType}`;
const cached = req.sharePermissionContext;
if (cached?.cacheKey === cacheKey) {
return {
user,
resourceType,
resourcePerms: cached.resourcePerms,
};
}
const role = await getRoleByName(user.role);
if (!role?.permissions) {
res.status(403).json({
error: 'Forbidden',
message: 'No permissions configured for user role',
});
return null;
}
const resourcePerms = role.permissions[permissionType] ?? {};
req.sharePermissionContext = {
cacheKey,
resourcePerms,
};
return {
user,
resourceType,
resourcePerms,
};
}
async function hasResourceManagementCapability(
user: CapabilityUser,
resourceType: ResourceType,
): Promise<boolean> {
const capability = ResourceCapabilityMap[resourceType];
if (!capability) {
return false;
}
try {
return await hasCapability(user, capability);
} catch (error) {
logger.warn(
`[checkShareAccess] capability check failed, denying bypass: ${formatError(error)}`,
);
return false;
}
}
async function checkShareAccess(
req: ShareRequest,
res: Response,
next: NextFunction,
): Promise<Response | void> {
try {
const context = getShareContext(req, res, 'sharing');
if (!context) {
return;
}
if (await hasResourceManagementCapability(context.user, context.resourceType)) {
return next();
}
const result = await getResourcePerms(req, res, 'sharing', context);
if (!result) {
return;
}
const { user, resourceType, resourcePerms } = result;
const canShare = resourcePerms[Permissions.SHARE] === true;
if (!canShare) {
logger.warn(`[checkShareAccess][${user.id}] User denied SHARE for ${resourceType}`);
return res.status(403).json({
error: 'Forbidden',
message: `You do not have permission to share ${resourceType} resources`,
});
}
return next();
} catch (error) {
logger.error(`[checkShareAccess][${req.user?.id}] Error checking SHARE permission`, error);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to check sharing permissions',
});
}
}
async function checkSharePublicAccess(
req: ShareRequest,
res: Response,
next: NextFunction,
): Promise<Response | void> {
try {
const { public: isPublic } = req.body;
if (!isPublic) {
return next();
}
const result = await getResourcePerms(req, res, 'public sharing');
if (!result) {
return;
}
const { user, resourceType, resourcePerms } = result;
const canSharePublic = resourcePerms[Permissions.SHARE_PUBLIC] === true;
if (!canSharePublic) {
logger.warn(
`[checkSharePublicAccess][${user.id}] User denied SHARE_PUBLIC for ${resourceType}`,
);
return res.status(403).json({
error: 'Forbidden',
message: `You do not have permission to share ${resourceType} resources publicly`,
});
}
return next();
} catch (error) {
logger.error(
`[checkSharePublicAccess][${req.user?.id}] Error checking SHARE_PUBLIC permission`,
error,
);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to check public sharing permissions',
});
}
}
return {
checkShareAccess,
checkSharePublicAccess,
};
}