From 619f28d76d73ff34448451ed25a9629e6c28a640 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 3 May 2026 22:17:44 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Sanitize=20HTML?= =?UTF-8?q?=20In=20Admin=20Banner=20And=20MCP=20Config=20Dialog=20(#12927)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/components/Banners/Banner.tsx | 24 +++++++++++++++++-- .../components/Chat/Input/MCPConfigDialog.tsx | 24 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/client/src/components/Banners/Banner.tsx b/client/src/components/Banners/Banner.tsx index b10fa70941..a1e9056c07 100644 --- a/client/src/components/Banners/Banner.tsx +++ b/client/src/components/Banners/Banner.tsx @@ -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(store.hideBannerHint); const bannerRef = useRef(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 }} > {!banner.persistable && (