diff --git a/web/default/src/components/html-content.tsx b/web/default/src/components/html-content.tsx index c4e9d9cd..1332d72f 100644 --- a/web/default/src/components/html-content.tsx +++ b/web/default/src/components/html-content.tsx @@ -16,18 +16,157 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import DOMPurify from 'dompurify' -import { useMemo } from 'react' +import DOMPurify, { type Config } from 'dompurify' +import { useEffect, useMemo, useRef } from 'react' import { cn } from '@/lib/utils' +export type HtmlContentVariant = 'inline' | 'isolated' + interface HtmlContentProps { content: string className?: string + variant?: HtmlContentVariant +} + +const isolatedContentSandbox = + 'allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation' + +const isolatedContentBaseStyles = ` + +` + +const isolatedSanitizeOptions = { + ADD_ATTR: [ + 'allowfullscreen', + 'autoplay', + 'class', + 'controls', + 'default', + 'id', + 'kind', + 'label', + 'loading', + 'loop', + 'muted', + 'playsinline', + 'poster', + 'preload', + 'referrerpolicy', + 'rel', + 'srclang', + 'style', + 'target', + ], + ADD_TAGS: ['audio', 'iframe', 'picture', 'source', 'style', 'track', 'video'], + FORBID_ATTR: ['srcdoc'], + FORBID_TAGS: ['base', 'embed', 'link', 'meta', 'object', 'script'], + FORCE_BODY: true, +} satisfies Config + +function hardenIsolatedHtml(html: string): string { + if (typeof document === 'undefined') { + return html + } + + const template = document.createElement('template') + template.innerHTML = html + + template.content.querySelectorAll('a[target="_blank"]').forEach((link) => { + const rel = new Set( + link + .getAttribute('rel') + ?.split(/\s+/) + .filter(Boolean) ?? [] + ) + + rel.add('noopener') + rel.add('noreferrer') + link.setAttribute('rel', [...rel].join(' ')) + }) + + template.content.querySelectorAll('iframe').forEach((frame) => { + frame.removeAttribute('srcdoc') + frame.setAttribute('sandbox', isolatedContentSandbox) + frame.setAttribute('referrerpolicy', 'no-referrer') + + if (!frame.hasAttribute('loading')) { + frame.setAttribute('loading', 'lazy') + } + }) + + return template.innerHTML +} + +function sanitizeHtmlContent( + content: string, + variant: HtmlContentVariant +): string { + if (variant === 'isolated') { + const html = DOMPurify.sanitize(content, isolatedSanitizeOptions) + + return hardenIsolatedHtml(html) + } + + return DOMPurify.sanitize(content) +} + +function IsolatedHtmlContent(props: { + className?: string + html: string +}): React.ReactElement { + const containerRef = useRef(null) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + const shadowRoot = + container.shadowRoot ?? container.attachShadow({ mode: 'open' }) + shadowRoot.innerHTML = `${isolatedContentBaseStyles}${props.html}` + }, [props.html]) + + return ( +
+ ) } export function HtmlContent(props: HtmlContentProps) { - const html = useMemo(() => DOMPurify.sanitize(props.content), [props.content]) + const variant = props.variant ?? 'inline' + const html = useMemo( + () => sanitizeHtmlContent(props.content, variant), + [props.content, variant] + ) + + if (variant === 'isolated') { + return + } return (
. For commercial licensing, please contact support@quantumnous.com */ -import { HtmlContent } from '@/components/html-content' +import { + HtmlContent, + type HtmlContentVariant, +} from '@/components/html-content' import { Markdown } from '@/components/ui/markdown' type RichContentMode = 'markdown' | 'html' @@ -26,11 +29,18 @@ interface RichContentProps { mode?: RichContentMode breaks?: boolean className?: string + htmlVariant?: HtmlContentVariant } export function RichContent(props: RichContentProps) { if (props.mode === 'html') { - return + return ( + + ) } return ( diff --git a/web/default/src/features/about/index.tsx b/web/default/src/features/about/index.tsx index ac19df2a..22834305 100644 --- a/web/default/src/features/about/index.tsx +++ b/web/default/src/features/about/index.tsx @@ -122,6 +122,7 @@ export function About() { const rawContent = data?.data?.trim() ?? '' const hasContent = rawContent.length > 0 const isUrl = hasContent && isHttpUrl(rawContent) + const contentIsHtml = hasContent && isLikelyHtml(rawContent) if (isLoading) { return ( @@ -157,11 +158,24 @@ export function About() { ) } + if (contentIsHtml) { + return ( + + + + ) + } + return (
diff --git a/web/default/src/features/home/index.tsx b/web/default/src/features/home/index.tsx index fefdd67f..0494191e 100644 --- a/web/default/src/features/home/index.tsx +++ b/web/default/src/features/home/index.tsx @@ -57,11 +57,26 @@ export function Home() { ) } + const contentIsHtml = isLikelyHtml(content) + + if (contentIsHtml) { + return ( + + + + ) + } + return (
diff --git a/web/default/src/features/legal/legal-document.tsx b/web/default/src/features/legal/legal-document.tsx index d60d8612..50d813e4 100644 --- a/web/default/src/features/legal/legal-document.tsx +++ b/web/default/src/features/legal/legal-document.tsx @@ -52,6 +52,7 @@ export function LegalDocument({ const rawContent = data?.data?.trim() ?? '' const hasContent = rawContent.length > 0 const isUrl = hasContent && isHttpUrl(rawContent) + const contentIsHtml = hasContent && isLikelyHtml(rawContent) const success = data?.success ?? false if (isLoading) { @@ -122,18 +123,26 @@ export function LegalDocument({ } return ( - -
-
-

{title}

-
- + + {contentIsHtml ? ( -
+ ) : ( +
+
+

{title}

+
+ + +
+ )}
) }