mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
Merge 957c4fd5c6
into 68c9f668c1
This commit is contained in:
commit
30aed11811
15 changed files with 106 additions and 43 deletions
|
@ -766,3 +766,6 @@ OPENWEATHER_API_KEY=
|
|||
|
||||
# Cache connection status checks for this many milliseconds to avoid expensive verification
|
||||
# MCP_CONNECTION_CHECK_TTL=60000
|
||||
|
||||
# Allowed origins for CORS, comma separated
|
||||
# CORS_ORIGIN=http://localhost:3080,https://example.com
|
||||
|
|
|
@ -749,9 +749,9 @@ class AnthropicClient extends BaseClient {
|
|||
}
|
||||
|
||||
logger.debug('modelOptions', { modelOptions });
|
||||
const metadata = {
|
||||
user_id: this.user,
|
||||
};
|
||||
const metadata = this.user
|
||||
? { user_hash: require('crypto').createHash('sha256').update(this.user).digest('hex') }
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
stream,
|
||||
|
|
|
@ -75,7 +75,17 @@ const startServer = async () => {
|
|||
app.use(express.json({ limit: '3mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
|
||||
app.use(mongoSanitize());
|
||||
app.use(cors());
|
||||
const allowed = process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : [];
|
||||
app.use(
|
||||
cors({
|
||||
origin: function (origin, callback) {
|
||||
if (!origin) return callback(null, true);
|
||||
if (allowed.includes(origin)) return callback(null, true);
|
||||
return callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
app.use(cookieParser());
|
||||
|
||||
if (!isEnabled(DISABLE_COMPRESSION)) {
|
||||
|
|
|
@ -17,6 +17,7 @@ const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/User
|
|||
const { fetchModels } = require('~/server/services/ModelService');
|
||||
const OpenAIClient = require('~/app/clients/OpenAIClient');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { validateExternalUrl } = require('~/server/utils/validateUrl');
|
||||
|
||||
const { PROXY } = process.env;
|
||||
|
||||
|
@ -63,6 +64,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
|||
|
||||
let apiKey = userProvidesKey ? userValues?.apiKey : CUSTOM_API_KEY;
|
||||
let baseURL = userProvidesURL ? userValues?.baseURL : CUSTOM_BASE_URL;
|
||||
await validateExternalUrl(baseURL);
|
||||
|
||||
if (userProvidesKey & !apiKey) {
|
||||
throw new Error(
|
||||
|
@ -132,6 +134,10 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
|||
customOptions.streamRate = allConfig.streamRate;
|
||||
}
|
||||
|
||||
if (customOptions.directEndpoint) {
|
||||
await validateExternalUrl(customOptions.directEndpoint);
|
||||
}
|
||||
|
||||
let clientOptions = {
|
||||
reverseProxyUrl: baseURL ?? null,
|
||||
proxy: PROXY ?? null,
|
||||
|
|
|
@ -5,6 +5,7 @@ const { logger } = require('@librechat/data-schemas');
|
|||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
const { generateShortLivedToken } = require('@librechat/api');
|
||||
const { getBufferMetadata } = require('~/server/utils');
|
||||
const { validateExternalUrl } = require('~/server/utils/validateUrl');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
/**
|
||||
|
@ -103,9 +104,12 @@ async function saveLocalBuffer({ userId, buffer, fileName, basePath = 'images' }
|
|||
*/
|
||||
async function saveFileFromURL({ userId, URL, fileName, basePath = 'images' }) {
|
||||
try {
|
||||
await validateExternalUrl(URL);
|
||||
const response = await axios({
|
||||
url: URL,
|
||||
responseType: 'arraybuffer',
|
||||
maxContentLength: 10 * 1024 * 1024,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const buffer = Buffer.from(response.data, 'binary');
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
const sharp = require('sharp');
|
||||
const fs = require('fs').promises;
|
||||
const fetch = require('node-fetch');
|
||||
const axios = require('axios');
|
||||
const { validateExternalUrl } = require('~/server/utils/validateUrl');
|
||||
const { EImageOutputType } = require('librechat-data-provider');
|
||||
const { resizeAndConvert } = require('./resize');
|
||||
const { logger } = require('~/config');
|
||||
|
@ -29,12 +30,13 @@ async function resizeAvatar({ userId, input, desiredFormat = EImageOutputType.PN
|
|||
|
||||
let imageBuffer;
|
||||
if (typeof input === 'string') {
|
||||
const response = await fetch(input);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image from URL. Status: ${response.status}`);
|
||||
}
|
||||
imageBuffer = await response.buffer();
|
||||
await validateExternalUrl(input);
|
||||
const response = await axios.get(input, {
|
||||
responseType: 'arraybuffer',
|
||||
maxContentLength: 10 * 1024 * 1024,
|
||||
timeout: 5000,
|
||||
});
|
||||
imageBuffer = Buffer.from(response.data);
|
||||
} else if (input instanceof Buffer) {
|
||||
imageBuffer = input;
|
||||
} else if (typeof input === 'object' && input instanceof File) {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
const axios = require('axios');
|
||||
const { validateExternalUrl } = require('~/server/utils/validateUrl');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { logAxiosError, processTextWithTokenLimit } = require('@librechat/api');
|
||||
const {
|
||||
|
@ -60,8 +61,11 @@ async function streamToBase64(stream, destroyStream = true) {
|
|||
*/
|
||||
async function fetchImageToBase64(url) {
|
||||
try {
|
||||
await validateExternalUrl(url);
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
maxContentLength: 10 * 1024 * 1024,
|
||||
timeout: 5000,
|
||||
});
|
||||
const base64Data = Buffer.from(response.data).toString('base64');
|
||||
response.data = null;
|
||||
|
|
|
@ -34,6 +34,7 @@ const { checkCapability } = require('~/server/services/Config');
|
|||
const { LB_QueueAsyncCall } = require('~/server/utils/queue');
|
||||
const { getStrategyFunctions } = require('./strategies');
|
||||
const { determineFileType } = require('~/server/utils');
|
||||
const { base64ToBuffer } = require('./utils');
|
||||
const { STTService } = require('./Audio/STTService');
|
||||
|
||||
/**
|
||||
|
@ -901,31 +902,6 @@ async function retrieveAndProcessFile({
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a base64 string to a buffer.
|
||||
* @param {string} base64String
|
||||
* @returns {Buffer<ArrayBufferLike>}
|
||||
*/
|
||||
function base64ToBuffer(base64String) {
|
||||
try {
|
||||
const typeMatch = base64String.match(/^data:([A-Za-z-+/]+);base64,/);
|
||||
const type = typeMatch ? typeMatch[1] : '';
|
||||
|
||||
const base64Data = base64String.replace(/^data:([A-Za-z-+/]+);base64,/, '');
|
||||
|
||||
if (!base64Data) {
|
||||
throw new Error('Invalid base64 string');
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(base64Data, 'base64'),
|
||||
type,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to convert base64 to buffer: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBase64Image(
|
||||
url,
|
||||
{ req, file_id: _file_id, filename: _filename, endpoint, context, resolution },
|
||||
|
|
23
api/server/services/Files/utils.js
Normal file
23
api/server/services/Files/utils.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Converts a base64 string to a buffer.
|
||||
* @param {string} base64String
|
||||
* @returns {{buffer: Buffer, type: string}}
|
||||
*/
|
||||
function base64ToBuffer(base64String) {
|
||||
try {
|
||||
const typeMatch = base64String.match(/^data:([A-Za-z-+/]+);base64,/);
|
||||
const type = typeMatch ? typeMatch[1] : '';
|
||||
const base64Data = base64String.replace(/^data:([A-Za-z-+/]+);base64,/, '');
|
||||
if (!base64Data) {
|
||||
throw new Error('Invalid base64 string');
|
||||
}
|
||||
return {
|
||||
buffer: Buffer.from(base64Data, 'base64'),
|
||||
type,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to convert base64 to buffer: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { base64ToBuffer };
|
|
@ -1,5 +1,6 @@
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||
const pLimit = require('p-limit');
|
||||
const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { updateMCPUserTools } = require('~/server/services/Config');
|
||||
|
@ -17,7 +18,9 @@ const { getLogStores } = require('~/cache');
|
|||
* @param {(authURL: string) => Promise<boolean>} [params.oauthStart]
|
||||
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
|
||||
*/
|
||||
async function reinitMCPServer({
|
||||
const reinitLimit = pLimit(1);
|
||||
|
||||
async function internalReinit({
|
||||
req,
|
||||
signal,
|
||||
forceNew,
|
||||
|
@ -137,6 +140,10 @@ async function reinitMCPServer({
|
|||
}
|
||||
}
|
||||
|
||||
async function reinitMCPServer(params) {
|
||||
return reinitLimit(() => internalReinit(params));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
reinitMCPServer,
|
||||
};
|
||||
|
|
23
api/server/utils/validateUrl.js
Normal file
23
api/server/utils/validateUrl.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
const dns = require('dns').promises;
|
||||
const ipaddr = require('ipaddr.js');
|
||||
|
||||
const PRIVATE_RANGES = new Set(['private', 'loopback', 'linkLocal', 'uniqueLocal', 'unspecified', 'reserved']);
|
||||
|
||||
/**
|
||||
* Validates a URL to prevent SSRF by restricting protocols and private IP ranges.
|
||||
* @param {string} urlString
|
||||
* @throws {Error} if URL is invalid or points to a private/internal address
|
||||
*/
|
||||
async function validateExternalUrl(urlString) {
|
||||
const url = new URL(urlString);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error('Invalid URL protocol');
|
||||
}
|
||||
const { address } = await dns.lookup(url.hostname);
|
||||
const ip = ipaddr.parse(address);
|
||||
if (PRIVATE_RANGES.has(ip.range())) {
|
||||
throw new Error('Disallowed IP address');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateExternalUrl };
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Label } from '@librechat/client';
|
||||
import DOMPurify from 'dompurify';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks';
|
||||
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
|
||||
|
@ -82,9 +83,8 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
|||
id={`agent-${agent.id}-description`}
|
||||
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
|
||||
{...(agent.description ? { 'aria-label': `Description: ${agent.description}` } : {})}
|
||||
>
|
||||
{agent.description ?? ''}
|
||||
</p>
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(agent.description ?? '') }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Owner info - moved to bottom right */}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useGetBannerQuery } from '~/data-provider';
|
||||
|
@ -33,7 +34,7 @@ export const Banner = ({ onHeightChange }: { onHeightChange?: (height: number) =
|
|||
>
|
||||
<div
|
||||
className="w-full truncate px-4 text-center text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: banner.message }}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(banner.message) }}
|
||||
></div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Button, Input, Label, OGDialog, OGDialogTemplate } from '@librechat/client';
|
||||
import type { ConfigFieldDetail } from '~/common';
|
||||
|
@ -83,7 +84,7 @@ export default function MCPConfigDialog({
|
|||
{details.description && (
|
||||
<p
|
||||
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
|
||||
dangerouslySetInnerHTML={{ __html: details.description }}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(details.description) }}
|
||||
/>
|
||||
)}
|
||||
{errors[key] && <p className="text-xs text-red-500">{errors[key]?.message}</p>}
|
||||
|
|
|
@ -232,12 +232,15 @@ export function resolveHeaders(options?: {
|
|||
|
||||
if (inputHeaders && typeof inputHeaders === 'object' && !Array.isArray(inputHeaders)) {
|
||||
Object.keys(inputHeaders).forEach((key) => {
|
||||
if (!/^[A-Za-z0-9-]+$/.test(key)) {
|
||||
return;
|
||||
}
|
||||
resolvedHeaders[key] = processSingleValue({
|
||||
originalValue: inputHeaders[key],
|
||||
customUserVars,
|
||||
user: user as TUser,
|
||||
body,
|
||||
});
|
||||
}).replace(/\r|\n/g, '');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue