fix(llm): XSS risk when displaying the message

This commit is contained in:
Elian Doran
2026-03-30 19:36:22 +03:00
parent 948f160d14
commit be60479122
3 changed files with 28 additions and 17 deletions

View File

@@ -1,4 +1,5 @@
import type { LlmMessage, LlmCitation, LlmChatConfig, LlmUsage, LlmModelInfo } from "@triliumnext/commons";
import type { LlmChatConfig, LlmCitation, LlmMessage, LlmModelInfo,LlmUsage } from "@triliumnext/commons";
import server from "./server.js";
/**
@@ -98,7 +99,7 @@ export async function streamChatCompletion(
break;
}
} catch {
// Ignore JSON parse errors for partial data
console.error("Failed to parse SSE data line:", line, e);
}
}
}

View File

@@ -1,3 +1,4 @@
import DOMPurify from "dompurify";
import type { CSSProperties, HTMLProps, RefObject } from "preact/compat";
type HTMLElementLike = string | HTMLElement | JQuery<HTMLElement>;
@@ -14,16 +15,16 @@ export default function RawHtml({containerRef, ...props}: RawHtmlProps & { conta
}
export function RawHtmlBlock({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject<HTMLDivElement>}) {
return <div ref={containerRef} {...getProps(props)} />
return <div ref={containerRef} {...getProps(props)} />;
}
function getProps({ className, html, style, onClick }: RawHtmlProps) {
return {
className: className,
className,
dangerouslySetInnerHTML: getHtml(html ?? ""),
style,
onClick
}
};
}
export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
@@ -39,3 +40,19 @@ export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
__html: html as string
};
}
/**
* Renders HTML content sanitized via DOMPurify to prevent XSS.
* Use this instead of {@link RawHtml} when the HTML originates from
* untrusted sources (e.g. LLM responses, user-generated markdown).
*/
export function SanitizedHtml({ className, html, style }: { className?: string; html: string; style?: CSSProperties }) {
return (
<div
className={className}
style={style}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}
/>
);
}

View File

@@ -1,11 +1,11 @@
import "./LlmChat.css";
import DOMPurify from "dompurify";
import { marked } from "marked";
import { useMemo } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import utils from "../../../services/utils.js";
import { SanitizedHtml } from "../../react/RawHtml.js";
import { type ContentBlock, getMessageText, type StoredMessage, type ToolCall } from "./llm_chat_types.js";
function shortenNumber(n: number): string {
@@ -20,10 +20,9 @@ marked.setOptions({
gfm: true // GitHub Flavored Markdown
});
/** Parse markdown and sanitize the resulting HTML to prevent XSS. */
/** Parse markdown to HTML. Sanitization is handled by SanitizedHtml. */
function renderMarkdown(markdown: string): string {
const raw = marked.parse(markdown) as string;
return DOMPurify.sanitize(raw);
return marked.parse(markdown) as string;
}
interface Props {
@@ -75,10 +74,7 @@ function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) {
const html = renderMarkdown(block.content);
return (
<div key={idx}>
<div
className="llm-chat-markdown"
dangerouslySetInnerHTML={{ __html: html }}
/>
<SanitizedHtml className="llm-chat-markdown" html={html} />
{isStreaming && idx === blocks.length - 1 && <span className="llm-chat-cursor" />}
</div>
);
@@ -143,10 +139,7 @@ export default function ChatMessage({ message, isStreaming }: Props) {
renderContentBlocks(message.content as ContentBlock[], isStreaming)
) : (
<>
<div
className="llm-chat-markdown"
dangerouslySetInnerHTML={{ __html: renderedContent || "" }}
/>
<SanitizedHtml className="llm-chat-markdown" html={renderedContent || ""} />
{isStreaming && <span className="llm-chat-cursor" />}
</>
)