mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
🛡️ fix: Sanitize HTML In Admin Banner And MCP Config Dialog (#12927)
Two `dangerouslySetInnerHTML` sites rendered admin-supplied HTML without sanitization: - `Banner.tsx` rendered `banner.message` directly. - `MCPConfigDialog.tsx` rendered each `customUserVars` description. Wrap both with DOMPurify, allowing only the inline tags needed for formatting (links, emphasis, line breaks). Hardens against compromised admin or yaml supply-chain scenarios. Pattern matches the existing `CustomUserVarsSection.tsx` and `Tooltip.tsx` sanitizer setup.
This commit is contained in:
parent
37429e8a3e
commit
619f28d76d
2 changed files with 44 additions and 4 deletions
|
|
@ -1,7 +1,8 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
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 { useGetBannerQuery } from '~/data-provider';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -10,6 +11,25 @@ export const Banner = ({ onHeightChange }: { onHeightChange?: (height: number) =
|
|||
const [hideBannerHint, setHideBannerHint] = useRecoilState<string[]>(store.hideBannerHint);
|
||||
const bannerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onHeightChange && bannerRef.current) {
|
||||
onHeightChange(bannerRef.current.offsetHeight);
|
||||
|
|
@ -45,7 +65,7 @@ export const Banner = ({ onHeightChange }: { onHeightChange?: (height: number) =
|
|||
'text-md w-full truncate text-center [&_a]:text-blue-700 [&_a]:underline dark:[&_a]:text-blue-400',
|
||||
!banner.persistable && 'px-4',
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: banner.message }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedMessage }}
|
||||
></div>
|
||||
{!banner.persistable && (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect } from 'react';
|
||||
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';
|
||||
|
|
@ -35,6 +36,25 @@ 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,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
reset(initialValues);
|
||||
|
|
@ -83,7 +103,7 @@ export default function MCPConfigDialog({
|
|||
{details.description && (
|
||||
<p
|
||||
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
|
||||
dangerouslySetInnerHTML={{ __html: details.description }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitize(details.description) }}
|
||||
/>
|
||||
)}
|
||||
{errors[key] && <p className="text-xs text-red-500">{errors[key]?.message}</p>}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue