🛡️ 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:
Danny Avila 2026-05-03 22:17:44 -04:00 committed by GitHub
parent 37429e8a3e
commit 619f28d76d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 44 additions and 4 deletions

View file

@ -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

View file

@ -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>}