mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-22 18:26:12 +01:00
🛂 fix: Encode Non-ASCII Characters in MCP Server Headers (#11432)
Fixes ByteString conversion errors when user names contain Unicode characters > 255 (e.g., ć, đ, ł, š, ž) in MCP server headers. - Add encodeHeaderValue() function to Base64 encode extended Unicode - Update processUserPlaceholders() to encode name/username/email in headers - Update processSingleValue() with isHeader parameter - Apply encoding in processMCPEnv() and resolveHeaders() Tested locally with MCP server using user name 'Đorđe' (contains đ=272). Headers are correctly encoded as base64, preventing ByteString errors. Co-authored-by: kenzaelk98 <kenzaelk98@leoninestudios.com> Co-authored-by: heptapod <164861708+leondape@users.noreply.github.com>
This commit is contained in:
parent
11210d8b98
commit
191cd3983c
2 changed files with 165 additions and 13 deletions
|
|
@ -1,5 +1,10 @@
|
|||
import { TokenExchangeMethodEnum } from 'librechat-data-provider';
|
||||
import { resolveHeaders, resolveNestedObject, processMCPEnv } from './env';
|
||||
import {
|
||||
resolveHeaders,
|
||||
resolveNestedObject,
|
||||
processMCPEnv,
|
||||
encodeHeaderValue,
|
||||
} from './env';
|
||||
import type { MCPOptions } from 'librechat-data-provider';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
import { Types } from 'mongoose';
|
||||
|
|
@ -32,6 +37,83 @@ function createTestUser(overrides: Partial<IUser> = {}): IUser {
|
|||
} as IUser;
|
||||
}
|
||||
|
||||
describe('encodeHeaderValue', () => {
|
||||
it('should return empty string for empty input', () => {
|
||||
expect(encodeHeaderValue('')).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for null/undefined coerced to empty string', () => {
|
||||
// TypeScript would prevent these, but testing runtime behavior
|
||||
expect(encodeHeaderValue(null as any)).toBe('');
|
||||
expect(encodeHeaderValue(undefined as any)).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for non-string values', () => {
|
||||
expect(encodeHeaderValue(123 as any)).toBe('');
|
||||
expect(encodeHeaderValue(false as any)).toBe('');
|
||||
expect(encodeHeaderValue({} as any)).toBe('');
|
||||
});
|
||||
|
||||
it('should pass through ASCII characters (0-127) unchanged', () => {
|
||||
expect(encodeHeaderValue('Hello')).toBe('Hello');
|
||||
expect(encodeHeaderValue('test@example.com')).toBe('test@example.com');
|
||||
expect(encodeHeaderValue('ABC123')).toBe('ABC123');
|
||||
});
|
||||
|
||||
it('should pass through Latin-1 characters (128-255) unchanged', () => {
|
||||
// Characters with Unicode values 128-255 are safe
|
||||
expect(encodeHeaderValue('José')).toBe('José'); // é = U+00E9 (233)
|
||||
expect(encodeHeaderValue('Müller')).toBe('Müller'); // ü = U+00FC (252)
|
||||
expect(encodeHeaderValue('Zoë')).toBe('Zoë'); // ë = U+00EB (235)
|
||||
expect(encodeHeaderValue('Björk')).toBe('Björk'); // ö = U+00F6 (246)
|
||||
});
|
||||
|
||||
it('should Base64 encode Slavic characters (>255)', () => {
|
||||
// Slavic characters that cause ByteString errors
|
||||
expect(encodeHeaderValue('Marić')).toBe('b64:TWFyacSH'); // ć = U+0107 (263)
|
||||
expect(encodeHeaderValue('Đorđe')).toBe('b64:xJBvcsSRZQ=='); // Đ = U+0110 (272), đ = U+0111 (273)
|
||||
});
|
||||
|
||||
it('should Base64 encode Polish characters (>255)', () => {
|
||||
expect(encodeHeaderValue('Łukasz')).toBe('b64:xYF1a2Fzeg=='); // Ł = U+0141 (321)
|
||||
});
|
||||
|
||||
it('should Base64 encode various extended Unicode characters (>255)', () => {
|
||||
expect(encodeHeaderValue('Žarko')).toBe('b64:xb1hcmtv'); // Ž = U+017D (381)
|
||||
expect(encodeHeaderValue('Šime')).toBe('b64:xaBpbWU='); // Š = U+0160 (352)
|
||||
});
|
||||
|
||||
it('should have correct b64: prefix format', () => {
|
||||
const result = encodeHeaderValue('Ćiro'); // Ć = U+0106 (262)
|
||||
expect(result.startsWith('b64:')).toBe(true);
|
||||
// Verify the encoded part after prefix is valid Base64
|
||||
const base64Part = result.slice(4);
|
||||
expect(Buffer.from(base64Part, 'base64').toString('utf8')).toBe('Ćiro');
|
||||
});
|
||||
|
||||
it('should handle mixed safe and unsafe characters', () => {
|
||||
const result = encodeHeaderValue('Hello Đorđe!');
|
||||
expect(result).toBe('b64:SGVsbG8gxJBvcsSRZSE=');
|
||||
});
|
||||
|
||||
it('should be reversible with Base64 decode', () => {
|
||||
const original = 'Marko Marić';
|
||||
const encoded = encodeHeaderValue(original);
|
||||
expect(encoded.startsWith('b64:')).toBe(true);
|
||||
|
||||
// Verify decoding works
|
||||
const decoded = Buffer.from(encoded.slice(4), 'base64').toString('utf8');
|
||||
expect(decoded).toBe(original);
|
||||
});
|
||||
|
||||
it('should handle emoji and other high Unicode characters', () => {
|
||||
const result = encodeHeaderValue('Hello 👋');
|
||||
expect(result.startsWith('b64:')).toBe(true);
|
||||
const decoded = Buffer.from(result.slice(4), 'base64').toString('utf8');
|
||||
expect(decoded).toBe('Hello 👋');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeaders', () => {
|
||||
beforeEach(() => {
|
||||
process.env.TEST_API_KEY = 'test-api-key-value';
|
||||
|
|
|
|||
|
|
@ -31,6 +31,50 @@ const ALLOWED_USER_FIELDS = [
|
|||
type AllowedUserField = (typeof ALLOWED_USER_FIELDS)[number];
|
||||
type SafeUser = Pick<IUser, AllowedUserField>;
|
||||
|
||||
/**
|
||||
* Encodes a string value to be safe for HTTP headers.
|
||||
* HTTP headers are restricted to ASCII characters (0-255) per the Fetch API standard.
|
||||
* Non-ASCII characters with Unicode values > 255 are Base64 encoded with 'b64:' prefix.
|
||||
*
|
||||
* NOTE: This is a LibreChat-specific encoding scheme to work around Fetch API limitations.
|
||||
* MCP servers receiving headers with the 'b64:' prefix should:
|
||||
* 1. Detect the 'b64:' prefix in header values
|
||||
* 2. Remove the prefix and Base64-decode the remaining string
|
||||
* 3. Use the decoded UTF-8 string as the actual value
|
||||
*
|
||||
* Example decoding (Node.js):
|
||||
* if (headerValue.startsWith('b64:')) {
|
||||
* const decoded = Buffer.from(headerValue.slice(4), 'base64').toString('utf8');
|
||||
* }
|
||||
*
|
||||
* @param value - The string value to encode
|
||||
* @returns ASCII-safe string (encoded if necessary)
|
||||
*
|
||||
* @example
|
||||
* encodeHeaderValue("José") // Returns "José" (é = 233, safe)
|
||||
* encodeHeaderValue("Marić") // Returns "b64:TWFyacSH" (ć = 263, needs encoding)
|
||||
*/
|
||||
export function encodeHeaderValue(value: string): string {
|
||||
// Handle non-string or empty values
|
||||
if (!value || typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Check if string contains extended Unicode characters (> 255)
|
||||
// Characters 0-255 (ASCII + Latin-1) are safe and don't need encoding
|
||||
// Characters > 255 (e.g., ć=263, đ=272, ł=322) need Base64 encoding
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const hasExtendedUnicode = /[^\u0000-\u00FF]/.test(value);
|
||||
|
||||
if (!hasExtendedUnicode) {
|
||||
return value; // Safe to pass through
|
||||
}
|
||||
|
||||
// Encode to Base64 for extended Unicode characters
|
||||
const base64 = Buffer.from(value, 'utf8').toString('base64');
|
||||
return `b64:${base64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a safe user object containing only allowed fields.
|
||||
* Preserves federatedTokens for OpenID token template variable resolution.
|
||||
|
|
@ -66,12 +110,15 @@ export function createSafeUser(
|
|||
const ALLOWED_BODY_FIELDS = ['conversationId', 'parentMessageId', 'messageId'] as const;
|
||||
|
||||
/**
|
||||
* Processes a string value to replace user field placeholders
|
||||
* Processes a string value to replace user field placeholders.
|
||||
* When isHeader is true, non-ASCII characters in certain fields are Base64 encoded.
|
||||
*
|
||||
* @param value - The string value to process
|
||||
* @param user - The user object
|
||||
* @returns The processed string with placeholders replaced
|
||||
* @param isHeader - Whether this value will be used in an HTTP header
|
||||
* @returns The processed string with placeholders replaced (and encoded if necessary)
|
||||
*/
|
||||
function processUserPlaceholders(value: string, user?: IUser): string {
|
||||
function processUserPlaceholders(value: string, user?: IUser, isHeader: boolean = false): string {
|
||||
if (!user || typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
|
@ -95,7 +142,18 @@ function processUserPlaceholders(value: string, user?: IUser): string {
|
|||
continue;
|
||||
}
|
||||
|
||||
const replacementValue = fieldValue == null ? '' : String(fieldValue);
|
||||
let replacementValue = fieldValue == null ? '' : String(fieldValue);
|
||||
|
||||
// Encode non-ASCII characters when used in headers
|
||||
// Fields like name, username, email can contain non-ASCII characters
|
||||
// that would cause ByteString conversion errors in the Fetch API
|
||||
if (isHeader) {
|
||||
const fieldsToEncode = ['name', 'username', 'email'];
|
||||
if (fieldsToEncode.includes(field)) {
|
||||
replacementValue = encodeHeaderValue(replacementValue);
|
||||
}
|
||||
}
|
||||
|
||||
value = value.replace(new RegExp(placeholder, 'g'), replacementValue);
|
||||
}
|
||||
|
||||
|
|
@ -133,10 +191,12 @@ function processBodyPlaceholders(value: string, body: RequestBody): string {
|
|||
|
||||
/**
|
||||
* Processes a single string value by replacing various types of placeholders
|
||||
*
|
||||
* @param originalValue - The original string value to process
|
||||
* @param customUserVars - Optional custom user variables to replace placeholders
|
||||
* @param user - Optional user object for replacing user field placeholders
|
||||
* @param body - Optional request body object for replacing body field placeholders
|
||||
* @param isHeader - Whether this value will be used in an HTTP header (enables encoding)
|
||||
* @returns The processed string with all placeholders replaced
|
||||
*/
|
||||
function processSingleValue({
|
||||
|
|
@ -144,11 +204,13 @@ function processSingleValue({
|
|||
customUserVars,
|
||||
user,
|
||||
body = undefined,
|
||||
isHeader = false,
|
||||
}: {
|
||||
originalValue: string;
|
||||
customUserVars?: Record<string, string>;
|
||||
user?: IUser;
|
||||
body?: RequestBody;
|
||||
isHeader?: boolean;
|
||||
}): string {
|
||||
// Type guard: ensure we're working with a string
|
||||
if (typeof originalValue !== 'string') {
|
||||
|
|
@ -166,7 +228,7 @@ function processSingleValue({
|
|||
}
|
||||
}
|
||||
|
||||
value = processUserPlaceholders(value, user);
|
||||
value = processUserPlaceholders(value, user, isHeader);
|
||||
|
||||
const openidTokenInfo = extractOpenIDTokenInfo(user);
|
||||
if (openidTokenInfo && isOpenIDTokenValid(openidTokenInfo)) {
|
||||
|
|
@ -258,7 +320,13 @@ export function processMCPEnv(params: {
|
|||
if ('headers' in newObj && newObj.headers) {
|
||||
const processedHeaders: Record<string, string> = {};
|
||||
for (const [key, originalValue] of Object.entries(newObj.headers)) {
|
||||
processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user, body });
|
||||
processedHeaders[key] = processSingleValue({
|
||||
originalValue,
|
||||
customUserVars,
|
||||
user,
|
||||
body,
|
||||
isHeader: true, // Important: Enable header encoding
|
||||
});
|
||||
}
|
||||
newObj.headers = processedHeaders;
|
||||
}
|
||||
|
|
@ -356,13 +424,14 @@ export function resolveNestedObject<T = unknown>(options?: {
|
|||
|
||||
/**
|
||||
* Resolves header values by replacing user placeholders, body variables, custom variables, and environment variables.
|
||||
* Automatically encodes non-ASCII characters for header safety.
|
||||
*
|
||||
* @param options - Optional configuration object.
|
||||
* @param options.headers - The headers object to process.
|
||||
* @param options.user - Optional user object for replacing user field placeholders (can be partial with just id).
|
||||
* @param options.body - Optional request body object for replacing body field placeholders.
|
||||
* @param options.customUserVars - Optional custom user variables to replace placeholders.
|
||||
* @returns The processed headers with all placeholders replaced.
|
||||
* @param options - Optional configuration object
|
||||
* @param options.headers - The headers object to process
|
||||
* @param options.user - Optional user object for replacing user field placeholders (can be partial with just id)
|
||||
* @param options.body - Optional request body object for replacing body field placeholders
|
||||
* @param options.customUserVars - Optional custom user variables to replace placeholders
|
||||
* @returns The processed headers with all placeholders replaced
|
||||
*/
|
||||
export function resolveHeaders(options?: {
|
||||
headers: Record<string, string> | undefined;
|
||||
|
|
@ -382,6 +451,7 @@ export function resolveHeaders(options?: {
|
|||
customUserVars,
|
||||
user: user as IUser,
|
||||
body,
|
||||
isHeader: true, // Important: Enable header encoding
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue