From 191cd3983c1aa56873ab9e35812107ddbb1f9138 Mon Sep 17 00:00:00 2001 From: kenzaelk98 Date: Wed, 21 Jan 2026 20:00:25 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=82=20fix:=20Encode=20Non-ASCII=20Char?= =?UTF-8?q?acters=20in=20MCP=20Server=20Headers=20(#11432)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: heptapod <164861708+leondape@users.noreply.github.com> --- packages/api/src/utils/env.spec.ts | 84 +++++++++++++++++++++++++- packages/api/src/utils/env.ts | 94 ++++++++++++++++++++++++++---- 2 files changed, 165 insertions(+), 13 deletions(-) diff --git a/packages/api/src/utils/env.spec.ts b/packages/api/src/utils/env.spec.ts index 426afd1970..eec15c1c25 100644 --- a/packages/api/src/utils/env.spec.ts +++ b/packages/api/src/utils/env.spec.ts @@ -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 { } 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'; diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index c69c57bd22..5a8ea19ac3 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -31,6 +31,50 @@ const ALLOWED_USER_FIELDS = [ type AllowedUserField = (typeof ALLOWED_USER_FIELDS)[number]; type SafeUser = Pick; +/** + * 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; 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 = {}; 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(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 | undefined; @@ -382,6 +451,7 @@ export function resolveHeaders(options?: { customUserVars, user: user as IUser, body, + isHeader: true, // Important: Enable header encoding }); }); }