📄 fix: Harden Configured Rich Text Rendering (#13423)

This commit is contained in:
Danny Avila 2026-05-30 19:37:05 -04:00 committed by GitHub
parent 75baa5b848
commit 68d5958fe7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 311 additions and 85 deletions

View file

@ -1,8 +1,12 @@
import DOMPurify from 'dompurify';
import { XIcon } from 'lucide-react';
import { useRecoilState } from 'recoil';
import { Button, cn } from '@librechat/client';
import { useEffect, useMemo, useRef } from 'react';
import {
CONFIG_HTML_TEXT_TAGS,
CONFIG_HTML_CLASS_ATTR,
createConfigHtmlSanitizer,
} from '~/utils/configHtml';
import { useGetBannerQuery } from '~/data-provider';
import store from '~/store';
@ -10,25 +14,21 @@ export const Banner = ({ onHeightChange }: { onHeightChange?: (height: number) =
const { data: banner } = useGetBannerQuery();
const [hideBannerHint, setHideBannerHint] = useRecoilState<string[]>(store.hideBannerHint);
const bannerRef = useRef<HTMLDivElement>(null);
const sanitize = useMemo(
() =>
createConfigHtmlSanitizer({
allowedTags: CONFIG_HTML_TEXT_TAGS,
allowedAttr: CONFIG_HTML_CLASS_ATTR,
}),
[],
);
const sanitizedMessage = useMemo(() => {
if (!banner?.message) {
return '';
}
const sanitizer = DOMPurify();
sanitizer.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
return sanitizer.sanitize(banner.message, {
ALLOWED_TAGS: ['a', 'strong', 'b', 'em', 'i', 'br', 'code', 'span'],
ALLOWED_ATTR: ['href', 'class', 'target', 'rel'],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
});
}, [banner?.message]);
return sanitize(banner.message);
}, [banner?.message, sanitize]);
useEffect(() => {
if (onHeightChange && bannerRef.current) {

View file

@ -1,8 +1,12 @@
import DOMPurify from 'dompurify';
import React, { useEffect, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Button, Input, Label, OGDialog, OGDialogTemplate } from '@librechat/client';
import type { ConfigFieldDetail } from '~/common';
import {
CONFIG_HTML_BLOCK_TAGS,
CONFIG_HTML_CLASS_ATTR,
createConfigHtmlSanitizer,
} from '~/utils/configHtml';
import { useLocalize } from '~/hooks';
interface MCPConfigDialogProps {
@ -36,24 +40,14 @@ export default function MCPConfigDialog({
defaultValues: initialValues,
});
const sanitizer = useMemo(() => {
const instance = DOMPurify();
instance.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
return instance;
}, []);
const sanitize = (html: string) =>
sanitizer.sanitize(html, {
ALLOWED_TAGS: ['a', 'strong', 'b', 'em', 'i', 'br', 'code', 'span', 'p'],
ALLOWED_ATTR: ['href', 'class', 'target', 'rel'],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
});
const sanitize = useMemo(
() =>
createConfigHtmlSanitizer({
allowedTags: CONFIG_HTML_BLOCK_TAGS,
allowedAttr: CONFIG_HTML_CLASS_ATTR,
}),
[],
);
useEffect(() => {
if (isOpen) {

View file

@ -1,8 +1,12 @@
import React, { useMemo } from 'react';
import DOMPurify from 'dompurify';
import { useForm, Controller } from 'react-hook-form';
import { Input, Label, Button } from '@librechat/client';
import { useMCPAuthValuesQuery } from '~/data-provider/Tools/queries';
import {
CONFIG_HTML_INLINE_TAGS,
CONFIG_HTML_CLASS_ATTR,
createConfigHtmlSanitizer,
} from '~/utils/configHtml';
import { useLocalize } from '~/hooks';
export interface CustomUserVarConfig {
@ -29,34 +33,18 @@ interface AuthFieldProps {
function AuthField({ name, config, hasValue, control, errors, autoFocus }: AuthFieldProps) {
const localize = useLocalize();
const statusText = hasValue ? localize('com_ui_set') : localize('com_ui_unset');
const sanitizer = useMemo(() => {
const instance = DOMPurify();
instance.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName && node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
return instance;
}, []);
const sanitize = useMemo(
() =>
createConfigHtmlSanitizer({
allowedTags: CONFIG_HTML_INLINE_TAGS,
allowedAttr: CONFIG_HTML_CLASS_ATTR,
}),
[],
);
const sanitizedDescription = useMemo(() => {
if (!config.description) {
return '';
}
try {
return sanitizer.sanitize(config.description, {
ALLOWED_TAGS: ['a', 'strong', 'b', 'em', 'i', 'br', 'code'],
ALLOWED_ATTR: ['href', 'class', 'target', 'rel'],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
});
} catch (error) {
console.error('Sanitization failed', error);
return config.description;
}
}, [config.description, sanitizer]);
return sanitize(config.description);
}, [config.description, sanitize]);
return (
<div className="space-y-2">

View file

@ -1,17 +1,25 @@
import { useMemo } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { Checkbox, Label } from '@librechat/client';
import { useLocalize, useLocalizedConfig } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import { createConfigHtmlSanitizer } from '~/utils/configHtml';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
export default function TrustSection() {
const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig();
const getLocalizedValue = useLocalizedConfig();
const sanitize = useMemo(() => createConfigHtmlSanitizer(), []);
const {
control,
formState: { errors },
} = useFormContext<MCPServerFormData>();
const trustCheckbox = startupConfig?.interface?.mcpServers?.trustCheckbox;
const labelHTML = sanitize(getLocalizedValue(trustCheckbox?.label, localize('com_ui_trust_app')));
const subLabelHTML = sanitize(
getLocalizedValue(trustCheckbox?.subLabel, localize('com_agents_mcp_trust_subtext')),
);
return (
<div className="rounded-lg border border-border-light bg-surface-secondary p-2">
@ -37,35 +45,13 @@ export default function TrustSection() {
/>
<Label htmlFor="trust" className="flex cursor-pointer flex-col gap-0.5 text-sm">
<span id="trust-label" className="font-medium text-text-primary">
{startupConfig?.interface?.mcpServers?.trustCheckbox?.label ? (
<span
dangerouslySetInnerHTML={{
__html: getLocalizedValue(
startupConfig.interface.mcpServers.trustCheckbox.label,
localize('com_ui_trust_app'),
),
}}
/>
) : (
localize('com_ui_trust_app')
)}{' '}
<span dangerouslySetInnerHTML={{ __html: labelHTML }} />{' '}
<span aria-hidden="true" className="text-text-secondary">
*
</span>
</span>
<span id="trust-description" className="text-xs font-normal text-text-secondary">
{startupConfig?.interface?.mcpServers?.trustCheckbox?.subLabel ? (
<span
dangerouslySetInnerHTML={{
__html: getLocalizedValue(
startupConfig.interface.mcpServers.trustCheckbox.subLabel,
localize('com_agents_mcp_trust_subtext'),
),
}}
/>
) : (
localize('com_agents_mcp_trust_subtext')
)}
<span dangerouslySetInnerHTML={{ __html: subLabelHTML }} />
</span>
</Label>
</div>

View file

@ -0,0 +1,166 @@
import { render, screen } from '@testing-library/react';
import { FormProvider, useForm } from 'react-hook-form';
import type { ChangeEvent, ReactNode } from 'react';
import TrustSection from '../TrustSection';
import type { MCPServerFormData } from '../../hooks/useMCPServerForm';
type LocalizedValue = string | Record<string, string>;
type StartupConfigMock = {
interface?: {
mcpServers?: {
trustCheckbox?: {
label?: LocalizedValue;
subLabel?: LocalizedValue;
};
};
};
};
let mockStartupConfig: StartupConfigMock | undefined;
jest.mock('~/data-provider', () => ({
useGetStartupConfig: () => ({ data: mockStartupConfig }),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => {
const translations: Record<string, string> = {
com_ui_trust_app: 'I trust this app',
com_agents_mcp_trust_subtext: 'Only continue if you trust this MCP server.',
com_ui_field_required: 'This field is required',
};
return translations[key] ?? key;
},
useLocalizedConfig: () => (value: LocalizedValue | undefined, fallback: string) => {
if (value === undefined) {
return fallback;
}
if (typeof value === 'string') {
return value;
}
return value.en ?? Object.values(value)[0] ?? fallback;
},
}));
jest.mock(
'@librechat/client',
() => {
const React = jest.requireActual<typeof import('react')>('react');
return {
Checkbox: ({
checked,
onCheckedChange,
...props
}: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) =>
React.createElement('input', {
type: 'checkbox',
checked,
onChange: (event: ChangeEvent<HTMLInputElement>) => onCheckedChange(event.target.checked),
...props,
}),
Label: ({ children, ...props }: { children: ReactNode }) =>
React.createElement('label', props, children),
};
},
{ virtual: true },
);
function createDefaultValues(): MCPServerFormData {
return {
title: '',
description: '',
icon: '',
url: '',
type: 'streamable-http',
auth: {
auth_type: 'none' as MCPServerFormData['auth']['auth_type'],
},
trust: false,
};
}
function renderTrustSection() {
function Wrapper() {
const methods = useForm<MCPServerFormData>({
defaultValues: createDefaultValues(),
});
return (
<FormProvider {...methods}>
<TrustSection />
</FormProvider>
);
}
return render(<Wrapper />);
}
describe('TrustSection', () => {
beforeEach(() => {
mockStartupConfig = undefined;
});
it('sanitizes script-capable trust checkbox label and sub-label HTML', () => {
mockStartupConfig = {
interface: {
mcpServers: {
trustCheckbox: {
label:
'<img src=x onerror="window.__trustXss = true">Trust <script>alert(1)</script><strong>OK</strong>',
subLabel:
'<a href="javascript:alert(1)" onclick="window.__trustXss = true"><strong>Learn</strong></a><svg onload="alert(1)"></svg>',
},
},
},
};
const { container } = renderTrustSection();
const label = container.querySelector('#trust-label');
const description = container.querySelector('#trust-description');
const link = screen.getByText('Learn').closest('a');
expect(label).not.toBeNull();
expect(description).not.toBeNull();
expect(label?.innerHTML).not.toMatch(/onerror/i);
expect(description?.innerHTML).not.toMatch(/onclick/i);
expect(container.querySelector('img')).not.toBeInTheDocument();
expect(container.querySelector('script')).not.toBeInTheDocument();
expect(container.querySelector('svg')).not.toBeInTheDocument();
expect(link).not.toBeNull();
expect(link).not.toHaveAttribute('href');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('preserves documented formatting while normalizing links', () => {
mockStartupConfig = {
interface: {
mcpServers: {
trustCheckbox: {
label: { en: 'I <em>understand</em>' },
subLabel:
'Read <a href="https://example.com/docs" target="_self"><strong>Learn more.</strong></a><br><code>safe</code>',
},
},
},
};
const { container } = renderTrustSection();
const emphasis = screen.getByText('understand');
const strong = screen.getByText('Learn more.');
const code = screen.getByText('safe');
const link = strong.closest('a');
expect(emphasis.tagName).toBe('EM');
expect(strong.tagName).toBe('STRONG');
expect(code.tagName).toBe('CODE');
expect(container.querySelector('#trust-description br')).toBeInTheDocument();
expect(link).not.toBeNull();
expect(link).toHaveAttribute('href', 'https://example.com/docs');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
});

View file

@ -0,0 +1,46 @@
import {
CONFIG_HTML_BLOCK_TAGS,
CONFIG_HTML_CLASS_ATTR,
CONFIG_HTML_INLINE_TAGS,
createConfigHtmlSanitizer,
sanitizeConfigHtml,
} from '../configHtml';
describe('configHtml', () => {
it('removes active attributes and unsupported elements', () => {
const sanitized = sanitizeConfigHtml(
'<img src=x onerror="alert(1)"><a href="javascript:alert(1)" onclick="alert(1)"><strong>Learn</strong></a><script>alert(1)</script><svg onload="alert(1)"></svg>',
);
expect(sanitized).toBe(
'<a target="_blank" rel="noopener noreferrer"><strong>Learn</strong></a>',
);
});
it('keeps configured rich text tags and normalizes links', () => {
const sanitize = createConfigHtmlSanitizer({
allowedTags: CONFIG_HTML_BLOCK_TAGS,
allowedAttr: CONFIG_HTML_CLASS_ATTR,
});
const sanitized = sanitize(
'<p class="notice">Read <a href="https://example.com" target="_self"><strong>more</strong></a><br><code>safe</code></p>',
);
expect(sanitized).toBe(
'<p class="notice">Read <a href="https://example.com" target="_blank" rel="noopener noreferrer"><strong>more</strong></a><br><code>safe</code></p>',
);
});
it('keeps relative links but removes protocol-relative links', () => {
const sanitize = createConfigHtmlSanitizer({
allowedTags: CONFIG_HTML_INLINE_TAGS,
});
const sanitized = sanitize(
'<a href="/docs">Docs</a> <a href="//example.com/remote">Remote</a>',
);
expect(sanitized).toBe(
'<a href="/docs" target="_blank" rel="noopener noreferrer">Docs</a> <a target="_blank" rel="noopener noreferrer">Remote</a>',
);
});
});

View file

@ -0,0 +1,45 @@
import DOMPurify from 'dompurify';
export const CONFIG_HTML_INLINE_TAGS = ['a', 'strong', 'b', 'em', 'i', 'br', 'code'] as const;
export const CONFIG_HTML_TEXT_TAGS = [...CONFIG_HTML_INLINE_TAGS, 'span'] as const;
export const CONFIG_HTML_BLOCK_TAGS = [...CONFIG_HTML_TEXT_TAGS, 'p'] as const;
export const CONFIG_HTML_LINK_ATTR = ['href', 'target', 'rel'] as const;
export const CONFIG_HTML_CLASS_ATTR = [...CONFIG_HTML_LINK_ATTR, 'class'] as const;
const CONFIG_HTML_SAFE_URI =
/^(?:(?:https?|mailto|tel):|(?!(?:\s*[a-z][a-z0-9+.-]*:|\s*\/\/))[\s\S])/i;
type ConfigHtmlSanitizerOptions = {
allowedTags?: readonly string[];
allowedAttr?: readonly string[];
};
export function createConfigHtmlSanitizer({
allowedTags = CONFIG_HTML_INLINE_TAGS,
allowedAttr = CONFIG_HTML_LINK_ATTR,
}: ConfigHtmlSanitizerOptions = {}) {
const sanitizer = DOMPurify();
sanitizer.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
return (html?: string | null): string => {
if (!html) {
return '';
}
return sanitizer.sanitize(html, {
ALLOWED_TAGS: [...allowedTags],
ALLOWED_ATTR: [...allowedAttr],
ALLOWED_URI_REGEXP: CONFIG_HTML_SAFE_URI,
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
});
};
}
export function sanitizeConfigHtml(html?: string | null, options?: ConfigHtmlSanitizerOptions) {
return createConfigHtmlSanitizer(options)(html);
}

View file

@ -26,6 +26,7 @@ export * from './languages';
export * from './conversation';
export * from './endpoints';
export * from './resources';
export * from './configHtml';
export * from './downloadFile';
export * from './scaleImage';
export * from './timestamps';