🛡️ fix: Sanitize Agent List Skill Scope (#13122)

This commit is contained in:
Danny Avila 2026-05-14 09:27:41 -04:00 committed by GitHub
parent 5f7092e029
commit 62da4c28ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 238 additions and 13 deletions

View file

@ -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;

View file

@ -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({