mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🛡️ fix: Sanitize Agent List Skill Scope (#13122)
This commit is contained in:
parent
5f7092e029
commit
62da4c28ed
4 changed files with 238 additions and 13 deletions
|
|
@ -59,6 +59,39 @@ const getSafeModelParameters = (modelParameters) => {
|
|||
const { useResponsesApi } = modelParameters ?? {};
|
||||
return typeof useResponsesApi === 'boolean' ? { useResponsesApi } : {};
|
||||
};
|
||||
const hasEditBit = (permission) => (permission & PermissionBits.EDIT) === PermissionBits.EDIT;
|
||||
|
||||
const sanitizeViewerSkillScope = (agent, accessibleSkillSet) => {
|
||||
const skillScopeEnabled = agent.skills_enabled === true;
|
||||
delete agent.skills_enabled;
|
||||
|
||||
if (!skillScopeEnabled) {
|
||||
delete agent.skills;
|
||||
return agent;
|
||||
}
|
||||
|
||||
const configuredSkills = Array.isArray(agent.skills) ? agent.skills : [];
|
||||
if (configuredSkills.length === 0) {
|
||||
delete agent.skills;
|
||||
if (accessibleSkillSet.size > 0) {
|
||||
agent.skills_enabled = true;
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
const visibleSkills = configuredSkills
|
||||
.map((skillId) => String(skillId))
|
||||
.filter((skillId) => accessibleSkillSet.has(skillId));
|
||||
|
||||
if (visibleSkills.length === 0) {
|
||||
delete agent.skills;
|
||||
return agent;
|
||||
}
|
||||
|
||||
agent.skills = visibleSkills;
|
||||
agent.skills_enabled = true;
|
||||
return agent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Looks up each referenced agent id in Mongo, splits them into three
|
||||
|
|
@ -848,6 +881,7 @@ const getListAgentsHandler = async (req, res) => {
|
|||
} else if (typeof requiredPermission !== 'number') {
|
||||
requiredPermission = PermissionBits.VIEW;
|
||||
}
|
||||
const canReturnSkillConfig = hasEditBit(requiredPermission);
|
||||
// Base filter
|
||||
const filter = {};
|
||||
|
||||
|
|
@ -921,6 +955,7 @@ const getListAgentsHandler = async (req, res) => {
|
|||
otherParams: filter,
|
||||
limit,
|
||||
after: cursor,
|
||||
includeSkillConfig: true,
|
||||
});
|
||||
|
||||
const agents = data?.data ?? [];
|
||||
|
|
@ -928,10 +963,24 @@ const getListAgentsHandler = async (req, res) => {
|
|||
return res.json(data);
|
||||
}
|
||||
|
||||
let accessibleSkillSet = null;
|
||||
if (!canReturnSkillConfig) {
|
||||
const accessibleSkillIds = await findAccessibleResources({
|
||||
userId,
|
||||
role: req.user.role,
|
||||
resourceType: ResourceType.SKILL,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
accessibleSkillSet = new Set(accessibleSkillIds.map((oid) => oid.toString()));
|
||||
}
|
||||
|
||||
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
|
||||
|
||||
const urlCache = cachedRefresh?.urlCache;
|
||||
data.data = agents.map((agent) => {
|
||||
if (accessibleSkillSet) {
|
||||
sanitizeViewerSkillScope(agent, accessibleSkillSet);
|
||||
}
|
||||
try {
|
||||
if (agent?._id && publicSet.has(agent._id.toString())) {
|
||||
agent.isPublic = true;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const mongoose = require('mongoose');
|
|||
const { nanoid } = require('nanoid');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { agentSchema, fileSchema } = require('@librechat/data-schemas');
|
||||
const { FileSources, PermissionBits } = require('librechat-data-provider');
|
||||
const { FileSources, PermissionBits, ResourceType } = require('librechat-data-provider');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
|
||||
// Only mock the dependencies that are not database-related
|
||||
|
|
@ -1303,6 +1303,68 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
expect(response.data[0].name).toBe('Agent A1');
|
||||
});
|
||||
|
||||
test('should return only expected safe list fields for VIEW callers', async () => {
|
||||
const hiddenSkillId = new mongoose.Types.ObjectId();
|
||||
await Agent.findByIdAndUpdate(agentA1._id, {
|
||||
avatar: { filepath: '/avatars/a1.png', source: FileSources.local },
|
||||
category: 'general',
|
||||
support_contact: { name: 'Support', email: 'support@example.com' },
|
||||
is_promoted: true,
|
||||
instructions: 'private system instructions',
|
||||
tools: ['execute_code'],
|
||||
actions: ['example.com::action'],
|
||||
model_parameters: { temperature: 0.7 },
|
||||
tool_resources: { file_search: { file_ids: ['file-1'] } },
|
||||
tool_options: { execute_code: { defer_loading: true } },
|
||||
subagents: { enabled: true, agent_ids: [agentA2.id] },
|
||||
edges: [{ from: agentA1.id, to: agentA2.id }],
|
||||
skills_enabled: true,
|
||||
skills: [hiddenSkillId.toString()],
|
||||
});
|
||||
|
||||
mockReq.user.id = userB.toString();
|
||||
mockReq.query.requiredPermission = String(PermissionBits.VIEW);
|
||||
findAccessibleResources.mockImplementation(({ resourceType }) => {
|
||||
if (resourceType === ResourceType.AGENT) {
|
||||
return Promise.resolve([agentA1._id]);
|
||||
}
|
||||
if (resourceType === ResourceType.SKILL) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
findPubliclyAccessibleResources.mockResolvedValue([]);
|
||||
|
||||
await getListAgentsHandler(mockReq, mockRes);
|
||||
|
||||
const response = mockRes.json.mock.calls[0][0];
|
||||
const agent = response.data[0];
|
||||
expect(Object.keys(agent).sort()).toEqual(
|
||||
[
|
||||
'_id',
|
||||
'author',
|
||||
'avatar',
|
||||
'category',
|
||||
'description',
|
||||
'id',
|
||||
'is_promoted',
|
||||
'name',
|
||||
'support_contact',
|
||||
'updatedAt',
|
||||
].sort(),
|
||||
);
|
||||
expect(agent).toEqual(
|
||||
expect.objectContaining({
|
||||
id: agentA1.id,
|
||||
name: 'Agent A1',
|
||||
description: 'User A agent 1',
|
||||
author: userA.toString(),
|
||||
category: 'general',
|
||||
is_promoted: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should return multiple accessible agents', async () => {
|
||||
// User B has access to multiple agents
|
||||
mockReq.user.id = userB.toString();
|
||||
|
|
@ -1428,6 +1490,91 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
expect(response.data).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should return only viewer-accessible skill scope for VIEW list callers', async () => {
|
||||
const visibleSkillId = new mongoose.Types.ObjectId();
|
||||
const hiddenSkillId = new mongoose.Types.ObjectId();
|
||||
await Agent.findByIdAndUpdate(agentA1._id, {
|
||||
skills_enabled: true,
|
||||
skills: [visibleSkillId.toString(), hiddenSkillId.toString()],
|
||||
});
|
||||
|
||||
mockReq.user.id = userB.toString();
|
||||
mockReq.query.requiredPermission = String(PermissionBits.VIEW);
|
||||
findAccessibleResources.mockImplementation(({ resourceType }) => {
|
||||
if (resourceType === ResourceType.AGENT) {
|
||||
return Promise.resolve([agentA1._id]);
|
||||
}
|
||||
if (resourceType === ResourceType.SKILL) {
|
||||
return Promise.resolve([visibleSkillId]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
findPubliclyAccessibleResources.mockResolvedValue([]);
|
||||
|
||||
await getListAgentsHandler(mockReq, mockRes);
|
||||
|
||||
const response = mockRes.json.mock.calls[0][0];
|
||||
expect(response.data).toHaveLength(1);
|
||||
expect(response.data[0].skills_enabled).toBe(true);
|
||||
expect(response.data[0].skills).toEqual([visibleSkillId.toString()]);
|
||||
expect(response.data[0].skills).not.toContain(hiddenSkillId.toString());
|
||||
});
|
||||
|
||||
test('should omit skill scope for VIEW list callers with no accessible configured skills', async () => {
|
||||
const hiddenSkillId = new mongoose.Types.ObjectId();
|
||||
await Agent.findByIdAndUpdate(agentA1._id, {
|
||||
skills_enabled: true,
|
||||
skills: [hiddenSkillId.toString()],
|
||||
});
|
||||
|
||||
mockReq.user.id = userB.toString();
|
||||
mockReq.query.requiredPermission = String(PermissionBits.VIEW);
|
||||
findAccessibleResources.mockImplementation(({ resourceType }) => {
|
||||
if (resourceType === ResourceType.AGENT) {
|
||||
return Promise.resolve([agentA1._id]);
|
||||
}
|
||||
if (resourceType === ResourceType.SKILL) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
findPubliclyAccessibleResources.mockResolvedValue([]);
|
||||
|
||||
await getListAgentsHandler(mockReq, mockRes);
|
||||
|
||||
const response = mockRes.json.mock.calls[0][0];
|
||||
expect(response.data).toHaveLength(1);
|
||||
expect(response.data[0].skills).toBeUndefined();
|
||||
expect(response.data[0].skills_enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return raw skill configuration for EDIT list callers', async () => {
|
||||
const visibleSkillId = new mongoose.Types.ObjectId();
|
||||
const hiddenSkillId = new mongoose.Types.ObjectId();
|
||||
await Agent.findByIdAndUpdate(agentA1._id, {
|
||||
skills_enabled: true,
|
||||
skills: [visibleSkillId.toString(), hiddenSkillId.toString()],
|
||||
});
|
||||
|
||||
mockReq.user.id = userB.toString();
|
||||
mockReq.query.requiredPermission = String(PermissionBits.EDIT);
|
||||
findAccessibleResources.mockResolvedValue([agentA1._id]);
|
||||
findPubliclyAccessibleResources.mockResolvedValue([]);
|
||||
|
||||
await getListAgentsHandler(mockReq, mockRes);
|
||||
|
||||
const response = mockRes.json.mock.calls[0][0];
|
||||
expect(response.data).toHaveLength(1);
|
||||
expect(response.data[0].skills_enabled).toBe(true);
|
||||
expect(response.data[0].skills).toEqual([
|
||||
visibleSkillId.toString(),
|
||||
hiddenSkillId.toString(),
|
||||
]);
|
||||
expect(findAccessibleResources).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ resourceType: ResourceType.SKILL }),
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle promoted filter with ACL', async () => {
|
||||
// Create a promoted agent
|
||||
const promotedAgent = await Agent.create({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue