mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
📄 fix: Harden Configured Rich Text Rendering (#13423)
This commit is contained in:
parent
75baa5b848
commit
68d5958fe7
8 changed files with 311 additions and 85 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
46
client/src/utils/__tests__/configHtml.test.ts
Normal file
46
client/src/utils/__tests__/configHtml.test.ts
Normal 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>',
|
||||
);
|
||||
});
|
||||
});
|
||||
45
client/src/utils/configHtml.ts
Normal file
45
client/src/utils/configHtml.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue