mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-15 12:16:33 +01:00
🛡️ fix: Agent Permission Check on Image Upload Route (#12219)
* fix: add agent permission check to image upload route
* refactor: remove unused SystemRoles import and format test file for clarity
* fix: address review findings for image upload agent permission check
* refactor: move agent upload auth logic to TypeScript in packages/api
Extract pure authorization logic from agentPermCheck.js into
checkAgentUploadAuth() in packages/api/src/files/agentUploadAuth.ts.
The function returns a structured result ({ allowed, status, error })
instead of writing HTTP responses directly, eliminating the dual
responsibility and confusing sentinel return value. The JS wrapper
in /api is now a thin adapter that translates the result to HTTP.
* test: rewrite image upload permission tests as integration tests
Replace mock-heavy images-agent-perm.spec.js with integration tests
using MongoMemoryServer, real models, and real PermissionService.
Follows the established pattern in files.agents.test.js. Moves test
to sibling location (images.agents.test.js) matching backend convention.
Adds temp file cleanup assertions on 403/404 responses and covers
message_file exemption paths (boolean true, string "true", false).
* fix: widen AgentUploadAuthDeps types to accept ObjectId from Mongoose
The injected getAgent returns Mongoose documents where _id and author
are Types.ObjectId at runtime, not string. Widen the DI interface to
accept string | Types.ObjectId for _id, author, and resourceId so the
contract accurately reflects real callers.
* chore: move agent upload auth into files/agents/ subdirectory
* refactor: delete agentPermCheck.js wrapper, move verifyAgentUploadPermission to packages/api
The /api-only dependencies (getAgent, checkPermission) are now passed
as object-field params from the route call sites. Both images.js and
files.js import verifyAgentUploadPermission from @librechat/api and
inject the deps directly, eliminating the intermediate JS wrapper.
* style: fix import type ordering in agent upload auth
* fix: prevent token TTL race in MCPTokenStorage.storeTokens
When expires_in is provided, use it directly instead of round-tripping
through Date arithmetic. The previous code computed accessTokenExpiry
as a Date, then after an async encryptV2 call, recomputed expiresIn by
subtracting Date.now(). On loaded CI runners the elapsed time caused
Math.floor to truncate to 0, triggering the 1-year fallback and making
the token appear permanently valid — so refresh never fired.
This commit is contained in:
parent
71a3b48504
commit
c6982dc180
7 changed files with 525 additions and 60 deletions
113
packages/api/src/files/agents/auth.ts
Normal file
113
packages/api/src/files/agents/auth.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { IUser } from '@librechat/data-schemas';
|
||||
import type { Response } from 'express';
|
||||
import type { Types } from 'mongoose';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { SystemRoles, ResourceType, PermissionBits } from 'librechat-data-provider';
|
||||
import type { ServerRequest } from '~/types';
|
||||
|
||||
export type AgentUploadAuthResult =
|
||||
| { allowed: true }
|
||||
| { allowed: false; status: number; error: string; message: string };
|
||||
|
||||
export interface AgentUploadAuthParams {
|
||||
userId: string;
|
||||
userRole: string;
|
||||
agentId?: string;
|
||||
toolResource?: string | null;
|
||||
messageFile?: boolean | string;
|
||||
}
|
||||
|
||||
export interface AgentUploadAuthDeps {
|
||||
getAgent: (params: { id: string }) => Promise<{
|
||||
_id: string | Types.ObjectId;
|
||||
author?: string | Types.ObjectId | null;
|
||||
} | null>;
|
||||
checkPermission: (params: {
|
||||
userId: string;
|
||||
role: string;
|
||||
resourceType: ResourceType;
|
||||
resourceId: string | Types.ObjectId;
|
||||
requiredPermission: number;
|
||||
}) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export async function checkAgentUploadAuth(
|
||||
params: AgentUploadAuthParams,
|
||||
deps: AgentUploadAuthDeps,
|
||||
): Promise<AgentUploadAuthResult> {
|
||||
const { userId, userRole, agentId, toolResource, messageFile } = params;
|
||||
const { getAgent, checkPermission } = deps;
|
||||
|
||||
const isMessageAttachment = messageFile === true || messageFile === 'true';
|
||||
if (!agentId || toolResource == null || isMessageAttachment) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
if (userRole === SystemRoles.ADMIN) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const agent = await getAgent({ id: agentId });
|
||||
if (!agent) {
|
||||
return { allowed: false, status: 404, error: 'Not Found', message: 'Agent not found' };
|
||||
}
|
||||
|
||||
if (agent.author?.toString() === userId) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const hasEditPermission = await checkPermission({
|
||||
userId,
|
||||
role: userRole,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
});
|
||||
|
||||
if (hasEditPermission) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[agentUploadAuth] User ${userId} denied upload to agent ${agentId} (insufficient permissions)`,
|
||||
);
|
||||
return {
|
||||
allowed: false,
|
||||
status: 403,
|
||||
error: 'Forbidden',
|
||||
message: 'Insufficient permissions to upload files to this agent',
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns true if denied (response already sent), false if allowed */
|
||||
export async function verifyAgentUploadPermission({
|
||||
req,
|
||||
res,
|
||||
metadata,
|
||||
getAgent,
|
||||
checkPermission,
|
||||
}: {
|
||||
req: ServerRequest;
|
||||
res: Response;
|
||||
metadata: { agent_id?: string; tool_resource?: string | null; message_file?: boolean | string };
|
||||
getAgent: AgentUploadAuthDeps['getAgent'];
|
||||
checkPermission: AgentUploadAuthDeps['checkPermission'];
|
||||
}): Promise<boolean> {
|
||||
const user = req.user as IUser;
|
||||
const result = await checkAgentUploadAuth(
|
||||
{
|
||||
userId: user.id,
|
||||
userRole: user.role ?? '',
|
||||
agentId: metadata.agent_id,
|
||||
toolResource: metadata.tool_resource,
|
||||
messageFile: metadata.message_file,
|
||||
},
|
||||
{ getAgent, checkPermission },
|
||||
);
|
||||
|
||||
if (!result.allowed) {
|
||||
res.status(result.status).json({ error: result.error, message: result.message });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
1
packages/api/src/files/agents/index.ts
Normal file
1
packages/api/src/files/agents/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './auth';
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './agents';
|
||||
export * from './audio';
|
||||
export * from './context';
|
||||
export * from './documents/crud';
|
||||
|
|
|
|||
|
|
@ -83,46 +83,40 @@ export class MCPTokenStorage {
|
|||
`${logPrefix} Token expires_in: ${'expires_in' in tokens ? tokens.expires_in : 'N/A'}, expires_at: ${'expires_at' in tokens ? tokens.expires_at : 'N/A'}`,
|
||||
);
|
||||
|
||||
// Handle both expires_in and expires_at formats
|
||||
const defaultTTL = 365 * 24 * 60 * 60;
|
||||
|
||||
let accessTokenExpiry: Date;
|
||||
let expiresInSeconds: number;
|
||||
if ('expires_at' in tokens && tokens.expires_at) {
|
||||
/** MCPOAuthTokens format - already has calculated expiry */
|
||||
logger.debug(`${logPrefix} Using expires_at: ${tokens.expires_at}`);
|
||||
accessTokenExpiry = new Date(tokens.expires_at);
|
||||
expiresInSeconds = Math.floor((accessTokenExpiry.getTime() - Date.now()) / 1000);
|
||||
} else if (tokens.expires_in) {
|
||||
/** Standard OAuthTokens format - calculate expiry */
|
||||
/** Standard OAuthTokens format - use expires_in directly to avoid lossy Date round-trip */
|
||||
logger.debug(`${logPrefix} Using expires_in: ${tokens.expires_in}`);
|
||||
expiresInSeconds = tokens.expires_in;
|
||||
accessTokenExpiry = new Date(Date.now() + tokens.expires_in * 1000);
|
||||
} else {
|
||||
/** No expiry provided - default to 1 year */
|
||||
logger.debug(`${logPrefix} No expiry provided, using default`);
|
||||
accessTokenExpiry = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
|
||||
expiresInSeconds = defaultTTL;
|
||||
accessTokenExpiry = new Date(Date.now() + defaultTTL * 1000);
|
||||
}
|
||||
|
||||
logger.debug(`${logPrefix} Calculated expiry date: ${accessTokenExpiry.toISOString()}`);
|
||||
logger.debug(
|
||||
`${logPrefix} Date object: ${JSON.stringify({
|
||||
time: accessTokenExpiry.getTime(),
|
||||
valid: !isNaN(accessTokenExpiry.getTime()),
|
||||
iso: accessTokenExpiry.toISOString(),
|
||||
})}`,
|
||||
);
|
||||
|
||||
// Ensure the date is valid before passing to createToken
|
||||
if (isNaN(accessTokenExpiry.getTime())) {
|
||||
logger.error(`${logPrefix} Invalid expiry date calculated, using default`);
|
||||
accessTokenExpiry = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
|
||||
accessTokenExpiry = new Date(Date.now() + defaultTTL * 1000);
|
||||
expiresInSeconds = defaultTTL;
|
||||
}
|
||||
|
||||
// Calculate expiresIn (seconds from now)
|
||||
const expiresIn = Math.floor((accessTokenExpiry.getTime() - Date.now()) / 1000);
|
||||
|
||||
const accessTokenData = {
|
||||
userId,
|
||||
type: 'mcp_oauth',
|
||||
identifier,
|
||||
token: encryptedAccessToken,
|
||||
expiresIn: expiresIn > 0 ? expiresIn : 365 * 24 * 60 * 60, // Default to 1 year if negative
|
||||
expiresIn: expiresInSeconds > 0 ? expiresInSeconds : defaultTTL,
|
||||
};
|
||||
|
||||
// Check if token already exists and update if it does
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue