diff --git a/packages/api/src/mcp/icons.spec.ts b/packages/api/src/mcp/icons.spec.ts index 8011570a4a..ef06e3a652 100644 --- a/packages/api/src/mcp/icons.spec.ts +++ b/packages/api/src/mcp/icons.spec.ts @@ -1,7 +1,12 @@ +import { MAX_MCP_ICON_PATH_LENGTH } from 'librechat-data-provider'; import { sanitizeMcpIconPath } from './icons'; -/** Decode the percent-encoded SVG body `sanitizeMcpIconPath` re-emits. */ +/** Decode the SVG body `sanitizeMcpIconPath` re-emits (base64 or percent-form). */ function decode(dataUri: string): string { + const base64Prefix = 'data:image/svg+xml;base64,'; + if (dataUri.startsWith(base64Prefix)) { + return Buffer.from(dataUri.slice(base64Prefix.length), 'base64').toString('utf-8'); + } return decodeURIComponent(dataUri.replace(/^data:image\/svg\+xml,/, '')); } @@ -176,4 +181,26 @@ describe('sanitizeMcpIconPath', () => { it('returns an empty string for a malformed SVG data URI', () => { expect(sanitizeMcpIconPath('data:image/svg+xml')).toBe(''); }); + + it('keeps the sanitized output within the schema length cap', () => { + const raw = `${'A'.repeat(150_000)}`; + const input = `data:image/svg+xml;base64,${Buffer.from(raw, 'utf-8').toString('base64')}`; + expect(input.length).toBeLessThanOrEqual(MAX_MCP_ICON_PATH_LENGTH); + const out = sanitizeMcpIconPath(input); + expect(out.length).toBeLessThanOrEqual(MAX_MCP_ICON_PATH_LENGTH); + expect(decode(out)).toContain('AAAA'); + }); + + it('never stores an icon over the length cap even when sanitizing grows it', () => { + // A base64 input under the cap whose many self-closing tags expand under + // sanitization (explicit close tags) past the cap; it must be dropped, not + // stored over-limit where the next edit's re-validation would reject it. + const cell = ''; + const raw = `${cell.repeat(3400)}`; + const input = `data:image/svg+xml;base64,${Buffer.from(raw, 'utf-8').toString('base64')}`; + expect(input.length).toBeLessThanOrEqual(MAX_MCP_ICON_PATH_LENGTH); + const out = sanitizeMcpIconPath(input); + expect(out.length).toBeLessThanOrEqual(MAX_MCP_ICON_PATH_LENGTH); + expect(out).toBe(''); + }); }); diff --git a/packages/api/src/mcp/icons.ts b/packages/api/src/mcp/icons.ts index aad6b4269f..bfe43a313d 100644 --- a/packages/api/src/mcp/icons.ts +++ b/packages/api/src/mcp/icons.ts @@ -1,4 +1,5 @@ import sanitizeHtml from 'sanitize-html'; +import { MAX_MCP_ICON_PATH_LENGTH } from 'librechat-data-provider'; /** * Server-side sanitization for user-provided MCP server icons. The client @@ -305,10 +306,17 @@ function normalizeIconValue(value: string): string { /** * Sanitize a user-provided MCP `iconPath`. SVG data URIs are decoded, stripped - * of active content via an allowlist, and re-encoded; a malformed SVG data URI - * resolves to an empty string so a broken icon is stored rather than raw markup. - * All other values (raster data URIs, URLs, relative paths) are returned - * unchanged. + * of active content via an allowlist, and re-encoded as base64; a malformed SVG + * data URI resolves to an empty string so a broken icon is stored rather than + * raw markup. All other values (raster data URIs, URLs, relative paths) are + * returned unchanged. + * + * Base64 is far more compact than percent-encoding for the angle-bracket-heavy + * SVG markup that sanitizing can even slightly grow (it expands self-closing + * tags to explicit close tags), so it minimizes needless rejection. The result + * is then measured against the schema length cap and dropped if it still exceeds + * it, so a value that passed the cap on input can never be stored over it and + * then rejected when the prefilled value is resubmitted on the next edit. */ export function sanitizeMcpIconPath(iconPath: string): string { const normalized = normalizeIconValue(iconPath); @@ -320,5 +328,6 @@ export function sanitizeMcpIconPath(iconPath: string): string { return ''; } const clean = scrubStyleBlocks(sanitizeHtml(svg, SVG_SANITIZE_OPTIONS)); - return `data:image/svg+xml,${encodeURIComponent(clean)}`; + const encoded = `data:image/svg+xml;base64,${Buffer.from(clean, 'utf-8').toString('base64')}`; + return encoded.length > MAX_MCP_ICON_PATH_LENGTH ? '' : encoded; }