mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-30 12:27:19 +02:00
🛡️ fix: Enforce MULTI_CONVO and agent ACL checks on addedConvo (#12243)
* 🛡️ fix: Enforce MULTI_CONVO and agent ACL checks on addedConvo
addedConvo.agent_id was passed through to loadAddedAgent without any
permission check, enabling an authenticated user to load and execute
another user's private agent via the parallel multi-convo feature.
The middleware now chains a checkAddedConvoAccess gate after the primary
agent check: when req.body.addedConvo is present it verifies the user
has MULTI_CONVO:USE role permission, and when the addedConvo agent_id is
a real (non-ephemeral) agent it runs the same canAccessResource ACL
check used for the primary agent.
* refactor: Harden addedConvo middleware and avoid duplicate agent fetch
- Convert checkAddedConvoAccess to curried factory matching Express
middleware signature: (requiredPermission) => (req, res, next)
- Call checkPermission directly for the addedConvo agent instead of
routing through canAccessResource's tempReq pattern; this avoids
orphaning the resolved agent document and enables caching it on
req.resolvedAddedAgent for downstream loadAddedAgent
- Update loadAddedAgent to use req.resolvedAddedAgent when available,
eliminating a duplicate getAgent DB call per chat request
- Validate addedConvo is a plain object and agent_id is a string
before passing to isEphemeralAgentId (prevents TypeError on object
injection, returns 400-equivalent early exit instead of 500)
- Fix JSDoc: "VIEW access" → "same permission as primary agent",
add @param/@returns to helpers, restore @example on factory
- Fix redundant return await in resolveAgentIdFromBody
* test: Add canAccessAgentFromBody spec covering IDOR fix
26 integration tests using MongoMemoryServer with real models, ACL
entries, and PermissionService — no mocks for core logic.
Covered paths:
- Factory validation (requiredPermission type check)
- Primary agent: missing agent_id, ephemeral, non-agents endpoint
- addedConvo absent / invalid shape (string, array, object injection)
- MULTI_CONVO:USE gate: denied, missing role, ADMIN bypass
- Agent resource ACL: no ACL → 403, insufficient bits → 403,
nonexistent agent → 404, valid ACL → next + cached on req
- End-to-end: both real agents, primary denied short-circuits,
ephemeral primary + real addedConvo
This commit is contained in:
parent
07d0ce4ce9
commit
8dc6d60750
3 changed files with 637 additions and 40 deletions
|
|
@ -1,42 +1,144 @@
|
|||
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 { getRoleByName } = require('~/models/Role');
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
|
||||
/**
|
||||
* Agent ID resolver function for agent_id from request body
|
||||
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
|
||||
* This is used specifically for chat routes where agent_id comes from request body
|
||||
*
|
||||
* 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 not found
|
||||
* @returns {Promise<Object|null>} Agent document with _id field, or null if ephemeral/not found
|
||||
*/
|
||||
const resolveAgentIdFromBody = async (agentCustomId) => {
|
||||
// Handle ephemeral agents - they don't need permission checks
|
||||
// Real agent IDs always start with "agent_", so anything else is ephemeral
|
||||
if (isEphemeralAgentId(agentCustomId)) {
|
||||
return null; // No permission check needed for ephemeral agents
|
||||
return null;
|
||||
}
|
||||
|
||||
return await getAgent({ id: agentCustomId });
|
||||
return getAgent({ id: agentCustomId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware factory that creates middleware to check agent access permissions from request body.
|
||||
* This middleware is specifically designed for chat routes where the agent_id comes from req.body
|
||||
* instead of route parameters.
|
||||
* 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
|
||||
* // Basic usage for agent chat (requires VIEW permission)
|
||||
* router.post('/chat',
|
||||
* canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW }),
|
||||
* buildEndpointOption,
|
||||
|
|
@ -46,11 +148,12 @@ const resolveAgentIdFromBody = async (agentCustomId) => {
|
|||
const canAccessAgentFromBody = (options) => {
|
||||
const { requiredPermission } = options;
|
||||
|
||||
// Validate required 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;
|
||||
|
|
@ -67,28 +170,13 @@ const canAccessAgentFromBody = (options) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Skip permission checks for ephemeral agents
|
||||
// Real agent IDs always start with "agent_", so anything else is ephemeral
|
||||
const afterPrimaryCheck = () => addedConvoMiddleware(req, res, next);
|
||||
|
||||
if (isEphemeralAgentId(agentId)) {
|
||||
return next();
|
||||
return afterPrimaryCheck();
|
||||
}
|
||||
|
||||
const agentAccessMiddleware = canAccessResource({
|
||||
resourceType: ResourceType.AGENT,
|
||||
requiredPermission,
|
||||
resourceIdParam: 'agent_id', // This will be ignored since we use custom resolver
|
||||
idResolver: () => resolveAgentIdFromBody(agentId),
|
||||
});
|
||||
|
||||
const tempReq = {
|
||||
...req,
|
||||
params: {
|
||||
...req.params,
|
||||
agent_id: agentId,
|
||||
},
|
||||
};
|
||||
|
||||
return agentAccessMiddleware(tempReq, res, next);
|
||||
return checkAgentResourceAccess(agentId, requiredPermission, req, res, afterPrimaryCheck);
|
||||
} catch (error) {
|
||||
logger.error('Failed to validate agent access permissions', error);
|
||||
return res.status(500).json({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue