mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-19 14:16:34 +01:00
* 🛡️ fix: Block SSRF via user-provided baseURL in endpoint initialization User-provided baseURL values (when endpoint is configured with `user_provided`) were passed through to the OpenAI SDK without validation. Combined with `directEndpoint`, this allowed arbitrary server-side requests to internal/metadata URLs. Adds `validateEndpointURL` that checks against known SSRF targets and DNS-resolves hostnames to block private IPs. Applied in both custom and OpenAI endpoint initialization paths. * 🧪 test: Add validateEndpointURL SSRF tests Covers unparseable URLs, localhost, private IPs, link-local/metadata, internal Docker/K8s hostnames, DNS resolution to private IPs, and legitimate public URLs. * 🛡️ fix: Add protocol enforcement and import order fix - Reject non-HTTP/HTTPS schemes (ftp://, file://, data:, etc.) in validateEndpointURL before SSRF hostname checks - Document DNS rebinding limitation and fail-open semantics in JSDoc - Fix import order in custom/initialize.ts per project conventions * 🧪 test: Expand SSRF validation coverage and add initializer integration tests Unit tests for validateEndpointURL: - Non-HTTP/HTTPS schemes (ftp, file, data) - IPv6 loopback, link-local, and unique-local addresses - .local and .internal TLD hostnames - DNS fail-open path (lookup failure allows request) Integration tests for initializeCustom and initializeOpenAI: - Guard fires when userProvidesURL is true - Guard skipped when URL is system-defined or falsy - SSRF rejection propagates and prevents getOpenAIConfig call * 🐛 fix: Correct broken env restore in OpenAI initialize spec process.env was captured by reference, not by value, making the restore closure a no-op. Snapshot individual env keys before mutation so they can be properly restored after each test. * 🛡️ fix: Throw structured ErrorTypes for SSRF base URL validation Replace plain-string Error throws in validateEndpointURL with JSON-structured errors using type 'invalid_base_url' (matching new ErrorTypes.INVALID_BASE_URL enum value). This ensures the client-side Error component can look up a localized message instead of falling through to the raw-text default. Changes across workspaces: - data-provider: add INVALID_BASE_URL to ErrorTypes enum - packages/api: throwInvalidBaseURL helper emits structured JSON - client: add errorMessages entry and localization key - tests: add structured JSON format assertion * 🧹 refactor: Use ErrorTypes enum key in Error.tsx for consistency Replace bare string literal 'invalid_base_url' with computed property [ErrorTypes.INVALID_BASE_URL] to match every other entry in the errorMessages map.
151 lines
5.9 KiB
TypeScript
151 lines
5.9 KiB
TypeScript
// file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets
|
|
import { ViolationTypes, ErrorTypes, alternateName } from 'librechat-data-provider';
|
|
import type { LocalizeFunction } from '~/common';
|
|
import { formatJSON, extractJson, isJson } from '~/utils/json';
|
|
import { useLocalize } from '~/hooks';
|
|
import CodeBlock from './CodeBlock';
|
|
|
|
const localizedErrorPrefix = 'com_error';
|
|
|
|
type TConcurrent = {
|
|
limit: number;
|
|
};
|
|
|
|
type TMessageLimit = {
|
|
max: number;
|
|
windowInMinutes: number;
|
|
};
|
|
|
|
type TTokenBalance = {
|
|
type: ViolationTypes | ErrorTypes;
|
|
balance: number;
|
|
tokenCost: number;
|
|
promptTokens: number;
|
|
prev_count: number;
|
|
violation_count: number;
|
|
date: Date;
|
|
generations?: unknown[];
|
|
};
|
|
|
|
type TExpiredKey = {
|
|
expiredAt: string;
|
|
endpoint: string;
|
|
};
|
|
|
|
type TGenericError = {
|
|
info: string;
|
|
};
|
|
|
|
const errorMessages = {
|
|
[ErrorTypes.MODERATION]: 'com_error_moderation',
|
|
[ErrorTypes.NO_USER_KEY]: 'com_error_no_user_key',
|
|
[ErrorTypes.INVALID_USER_KEY]: 'com_error_invalid_user_key',
|
|
[ErrorTypes.NO_BASE_URL]: 'com_error_no_base_url',
|
|
[ErrorTypes.INVALID_BASE_URL]: 'com_error_invalid_base_url',
|
|
[ErrorTypes.INVALID_ACTION]: `com_error_${ErrorTypes.INVALID_ACTION}`,
|
|
[ErrorTypes.INVALID_REQUEST]: `com_error_${ErrorTypes.INVALID_REQUEST}`,
|
|
[ErrorTypes.REFUSAL]: 'com_error_refusal',
|
|
[ErrorTypes.MISSING_MODEL]: (json: TGenericError, localize: LocalizeFunction) => {
|
|
const { info: endpoint } = json;
|
|
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
|
return localize('com_error_missing_model', { 0: provider });
|
|
},
|
|
[ErrorTypes.MODELS_NOT_LOADED]: 'com_error_models_not_loaded',
|
|
[ErrorTypes.ENDPOINT_MODELS_NOT_LOADED]: (json: TGenericError, localize: LocalizeFunction) => {
|
|
const { info: endpoint } = json;
|
|
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
|
return localize('com_error_endpoint_models_not_loaded', { 0: provider });
|
|
},
|
|
[ErrorTypes.NO_SYSTEM_MESSAGES]: `com_error_${ErrorTypes.NO_SYSTEM_MESSAGES}`,
|
|
[ErrorTypes.EXPIRED_USER_KEY]: (json: TExpiredKey, localize: LocalizeFunction) => {
|
|
const { expiredAt, endpoint } = json;
|
|
return localize('com_error_expired_user_key', { 0: endpoint, 1: expiredAt });
|
|
},
|
|
[ErrorTypes.INPUT_LENGTH]: (json: TGenericError, localize: LocalizeFunction) => {
|
|
const { info } = json;
|
|
return localize('com_error_input_length', { 0: info });
|
|
},
|
|
[ErrorTypes.INVALID_AGENT_PROVIDER]: (json: TGenericError, localize: LocalizeFunction) => {
|
|
const { info } = json;
|
|
const provider = (alternateName[info] as string | undefined) ?? info;
|
|
return localize('com_error_invalid_agent_provider', { 0: provider });
|
|
},
|
|
[ErrorTypes.GOOGLE_ERROR]: (json: TGenericError) => {
|
|
const { info } = json;
|
|
return info;
|
|
},
|
|
[ErrorTypes.GOOGLE_TOOL_CONFLICT]: 'com_error_google_tool_conflict',
|
|
[ViolationTypes.BAN]:
|
|
'Your account has been temporarily banned due to violations of our service.',
|
|
[ViolationTypes.ILLEGAL_MODEL_REQUEST]: (json: TGenericError, localize: LocalizeFunction) => {
|
|
const { info } = json;
|
|
const [endpoint, model = 'unknown'] = info?.split('|') ?? [];
|
|
const provider = (alternateName[endpoint ?? ''] as string | undefined) ?? endpoint ?? 'unknown';
|
|
return localize('com_error_illegal_model_request', { 0: model, 1: provider });
|
|
},
|
|
invalid_api_key:
|
|
'Invalid API key. Please check your API key and try again. You can do this by clicking on the model logo in the left corner of the textbox and selecting "Set Token" for the current selected endpoint. Thank you for your understanding.',
|
|
insufficient_quota:
|
|
'We apologize for any inconvenience caused. The default API key has reached its limit. To continue using this service, please set up your own API key. You can do this by clicking on the model logo in the left corner of the textbox and selecting "Set Token" for the current selected endpoint. Thank you for your understanding.',
|
|
concurrent: (json: TConcurrent) => {
|
|
const { limit } = json;
|
|
const plural = limit > 1 ? 's' : '';
|
|
return `Only ${limit} message${plural} at a time. Please allow any other responses to complete before sending another message, or wait one minute.`;
|
|
},
|
|
message_limit: (json: TMessageLimit) => {
|
|
const { max, windowInMinutes } = json;
|
|
const plural = max > 1 ? 's' : '';
|
|
return `You hit the message limit. You have a cap of ${max} message${plural} per ${
|
|
windowInMinutes > 1 ? `${windowInMinutes} minutes` : 'minute'
|
|
}.`;
|
|
},
|
|
token_balance: (json: TTokenBalance) => {
|
|
const { balance, tokenCost, promptTokens, generations } = json;
|
|
const message = `Insufficient Funds! Balance: ${balance}. Prompt tokens: ${promptTokens}. Cost: ${tokenCost}.`;
|
|
return (
|
|
<>
|
|
{message}
|
|
{generations && (
|
|
<>
|
|
<br />
|
|
<br />
|
|
</>
|
|
)}
|
|
{generations && (
|
|
<CodeBlock
|
|
lang="Generations"
|
|
error={true}
|
|
codeChildren={formatJSON(JSON.stringify(generations))}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
},
|
|
};
|
|
|
|
const Error = ({ text }: { text: string }) => {
|
|
const localize = useLocalize();
|
|
const jsonString = extractJson(text);
|
|
const errorMessage = text.length > 512 && !jsonString ? text.slice(0, 512) + '...' : text;
|
|
const defaultResponse = `Something went wrong. Here's the specific error message we encountered: ${errorMessage}`;
|
|
|
|
if (!isJson(jsonString)) {
|
|
return defaultResponse;
|
|
}
|
|
|
|
const json = JSON.parse(jsonString);
|
|
const errorKey = json.code || json.type;
|
|
const keyExists = errorKey && errorMessages[errorKey];
|
|
|
|
if (keyExists && typeof errorMessages[errorKey] === 'function') {
|
|
return errorMessages[errorKey](json, localize);
|
|
} else if (keyExists && keyExists.startsWith(localizedErrorPrefix)) {
|
|
return localize(errorMessages[errorKey]);
|
|
} else if (keyExists) {
|
|
return errorMessages[errorKey];
|
|
} else {
|
|
return defaultResponse;
|
|
}
|
|
};
|
|
|
|
export default Error;
|