This commit is contained in:
LeonardSEO 2025-09-20 09:44:44 +02:00 committed by GitHub
commit 30aed11811
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 106 additions and 43 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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 },

View 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 };

View file

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

View 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 };

View file

@ -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 */}

View file

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

View file

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

View file

@ -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, '');
});
}